Module refinery.lib.scripts.ps1.deobfuscation.folding

PowerShell constant folding transforms.

Expand source code Browse git
"""
PowerShell constant folding transforms.
"""
from __future__ import annotations

import base64
import codecs
import re

from typing import Iterator, NamedTuple, TypeGuard

from refinery.lib.scripts import Node, Transformer, canonical, reattach
from refinery.lib.scripts.ps1.analysis.cache import model_cache
from refinery.lib.scripts.ps1.analysis.effects import is_fault_free, may_be_dropped
from refinery.lib.scripts.ps1.analysis.worldflow import Ps1WorldReach
from refinery.lib.scripts.ps1.analysis.values import (
    NULL,
    Ps1Constant,
    Ps1Fact,
    apply,
    apply_unary,
    coerced_text,
    collect_byte_array,
    collect_integers,
    collect_texts,
    convert,
    integer_at,
    integer_of,
    invariant_text,
    is_truthy,
    read_operand,
    make_string_literal,
    pattern_at,
    read,
    render,
    text_of,
    type_of,
    type_test,
    unwrap_to_array_literal,
)
from refinery.lib.scripts.ps1.ast import get_member_name, unwrap_parens
from refinery.lib.scripts.ps1.data import ENCODING_MAP, instance_overloads, named_type, resolve_type
from refinery.lib.scripts.ps1.dotnet import Ps1TypeName
from refinery.lib.scripts.ps1.deobfuscation.constants import PS1_ENV_CONSTANTS
from refinery.lib.scripts.ps1.deobfuscation.helpers import (
    StringMethodError,
    apply_format_string,
    apply_string_method,
    collect_format_arguments,
    detect_encoding_chain,
    dotnet_regex_replace,
    extract_foreach_scriptblock,
    is_pipeline_item,
    is_static_type_call,
    unwrap_single_paren,
)
from refinery.lib.scripts.ps1.deobfuscation.substitution import (
    substitute_field,
    substitute_list,
    substituted,
)
from refinery.lib.scripts.ps1.data import MemberLookup, member_record, type_names
from refinery.lib.scripts.ps1.model import (
    Expression,
    Ps1ArrayExpression,
    Ps1ArrayLiteral,
    Ps1BinaryExpression,
    Ps1ExpandableString,
    Ps1ExpressionStatement,
    Ps1HashLiteral,
    Ps1IndexExpression,
    Ps1IntegerLiteral,
    Ps1InvokeMember,
    Ps1MemberAccess,
    Ps1Pipeline,
    Ps1RangeExpression,
    Ps1ScopeModifier,
    Ps1ScriptBlock,
    Ps1StringLiteral,
    Ps1TypeExpression,
    Ps1UnaryExpression,
    Ps1Variable,
)

_REGEX_OPTION_FLAGS: dict[str, int] = {
    'ignorecase'              : re.IGNORECASE,
    'multiline'               : re.MULTILINE,
    'singleline'              : re.DOTALL,
    'ignorepatternwhitespace' : re.VERBOSE,
    'none'                    : 0,
}

_REGEX_OPTION_INT: dict[int, int] = {
    1  : re.IGNORECASE,
    2  : re.MULTILINE,
    16 : re.DOTALL,
    32 : re.VERBOSE,
}

_RIGHT_TO_LEFT = 64
_MAX_STRING_EXPAND = 0x1000
_MAX_RANGES_EXPAND = 15

_CHAR = named_type('System.Char')
_INT32 = named_type('System.Int32')
_STRING = named_type('System.String')

#: The operators that read every operand as the text `[string]` of it produces, so that a constant
#: operand may be written as that text without changing what the operator does. Measured on both
#: sides of each: `'abc' -match [char]98` and `[char]98 -match 'b'` are both True, `[char]120
#: -replace 'x', 'y'` and `'x' -replace [char]120, 'y'` are both `y`, `[char]44 -split ','` is two
#: elements and `('a','b') -join [char]45` is `a-b`.
#:
#: `-f` is deliberately absent although a Char under it does contribute its text: what an operand
#: contributes there is decided by the format specifier, and `'{0:X}' -f 65` is `41` where the text
#: of the same operand is `65`. A comparison is absent for a stronger reason — `-eq` is decided by
#: its *left* operand's type, so `[char]65 -eq 65` and `'A' -eq 65` are not the same question.
#:
#: **`-split` is here on a narrower claim than the sentence above makes.** Its right operand may be
#: a collection, and only the first element of one is a pattern: the second is a *limit* and the
#: third the split options, so `'a,b,c' -split ',', 2` is emitted as `-Split ',', '2'` and the
#: number written as a limit comes back as text. That is measured to preserve the behaviour — the
#: parameter converts a String back to the count, and the corpus row for it reports no difference
#: through the host differential — but it is preserved by the *parameter's* conversion and not by
#: the rule stated here. An operand slot of `-split` that ever stops converting its text would
#: break silently, so a delimiter is what this set covers and a limit is what it happens to survive.
_TEXT_OPERATORS = frozenset({
    '-match', '-cmatch', '-imatch', '-notmatch', '-cnotmatch', '-inotmatch',
    '-like', '-clike', '-ilike', '-notlike', '-cnotlike', '-inotlike',
    '-replace', '-creplace', '-ireplace',
    '-split', '-csplit', '-isplit',
    '-join',
})

#: The whitespace `[Convert]::To<integer>(string)` strips, measured as `' 5 '` converting to 5. Only
#: the one-argument form strips anything: `[Convert]::ToInt32(' 5 ', 16)` throws.
_CONVERT_TRIM = ' \t\r\n'

#: What `[Convert]::To<integer>(string)` reads, which is neither what a cast reads nor what Python's
#: `int` does: an optional sign and decimal digits. Measured — `'+7'` is 7, `'007'` is 7 and `'-5'`
#: is -5, while `'0x10'`, `'1_0'`, `'0b1010'`, `'7.5'`, `'1e3'`, `'1,000'` and `''` each throw.
_CONVERT_SIGNED = re.compile(r'[+-]?[0-9]+\Z')

#: What `[Convert]::To<integer>(string, base)` reads for a base that is not ten: the digits of that
#: base and nothing else, and only base sixteen takes a `0x` prefix — `'0b1010'` at base two throws.
#: A sign throws and so does whitespace, so neither is written here.
_CONVERT_PATTERN: dict[int, re.Pattern[str]] = {
    2: re.compile(r'[01]+\Z'),
    8: re.compile(r'[0-7]+\Z'),
    16: re.compile(r'(?:0[xX])?[0-9a-fA-F]+\Z'),
}


def _is_static_regex_call(node: Ps1InvokeMember) -> bool:
    return is_static_type_call(node, 'system.text.regularexpressions.regex')


def _parse_regex_options(fact: Ps1Fact) -> tuple[int, bool] | None:
    """
    The Python `re` flags and the right-to-left switch a `RegexOptions` argument names, or `None`
    for one this does not recognize.

    The two spellings the enum accepts are its member names and its bit values, and neither is a
    text a value merely coerces to: a Char that renders as `m` names no option, and the number a
    flag is spelled with is the value rather than the digits — so the integer is read as the number
    its spelling names and not as the magnitude `0xFFFFFFFF` would give.
    """
    text = text_of(fact)
    if text is not None:
        flags = 0
        right_to_left = False
        for part in text.split(','):
            key = part.strip().lower()
            if not key:
                continue
            if key == 'righttoleft':
                right_to_left = True
                continue
            flag = _REGEX_OPTION_FLAGS.get(key)
            if flag is None:
                return None
            flags |= flag
        return flags, right_to_left
    value = integer_of(fact)
    if value is None:
        return None
    flags = 0
    for bit, flag in _REGEX_OPTION_INT.items():
        if value & bit:
            flags |= flag
    return flags, bool(value & _RIGHT_TO_LEFT)


def _iter_regex_matches(node: Ps1InvokeMember) -> Iterator[str] | None:
    """
    Yield matched strings from a call to

        [Regex]::Match/Matches(input, pattern[, options])

    Returns `None` if the arguments cannot be resolved.
    """
    if len(node.arguments) not in (2, 3):
        return None
    input = coerced_text(read(node.arguments[0]))
    pattern = coerced_text(read(node.arguments[1]))
    if input is None or pattern is None:
        return None
    if len(node.arguments) == 3:
        if (options := _parse_regex_options(read(node.arguments[2]))) is None:
            return None
        flags, right_to_left = options
    else:
        flags, right_to_left = 0, False
    try:
        matches = [m[0] for m in re.finditer(pattern, input, flags)]
    except re.error:
        return None
    if right_to_left:
        matches.reverse()
    return iter(matches)


def _compute_regex_matches(node: Ps1InvokeMember) -> list[str] | None:
    if it := _iter_regex_matches(node):
        return list(it)


def _compute_regex_match(node: Ps1InvokeMember) -> str | None:
    if it := _iter_regex_matches(node):
        return next(it, '')


def _integer(value: int) -> Ps1IntegerLiteral:
    return Ps1IntegerLiteral(raw=str(value))


#: The members whose value is decided by the shape of the receiver rather than by anything the
#: receiver holds, and what each answers. Dispatch is on the member and never on the type its read
#: produces: `Rank`, `Length` and `Count` all produce an `Int32`, so a gate that asked only for an
#: integer result answered `Rank` with the element count — 5.1 says 1, because `Rank` is the number
#: of dimensions of the array and not the number of things in it.
#:
#: Each answer is measured on a 5.1 host; see `TYPE_TRANSCRIPTS` in
#: `test.lib.scripts.ps1.test_oracle`. The one that reads oddly is `Count` on a string, which is 1
#: and not the character count: `Count` comes from the object adapter, which counts the value as one
#: object, while `Length` is the string's own member.
_SHAPE_MEMBERS = frozenset({'length', 'count', 'rank'})


def _foreach_extracts_value(sb: Ps1ScriptBlock) -> bool:
    """
    Check whether a ForEach scriptblock body is of the form `$_.Value`,
    `$_.Groups.Value`, or `$_.Groups.Captures.Groups.Value` — i.e. it
    extracts the string value from Match objects.
    """
    if sb.body is None or len(sb.body) != 1:
        return False
    stmt = sb.body[0]
    if not isinstance(stmt, Ps1ExpressionStatement) or stmt.expression is None:
        return False
    node = stmt.expression
    if not isinstance(node, Ps1Pipeline):
        expr = node
    elif len(node.elements) == 1 and node.elements[0].expression is not None:
        expr = node.elements[0].expression
    else:
        return False
    if not isinstance(expr, Ps1MemberAccess):
        return False
    member = expr.member if isinstance(expr.member, str) else None
    if member is None or member.lower() != 'value':
        return False
    inner = expr.object
    while isinstance(inner, Ps1MemberAccess):
        prop = inner.member if isinstance(inner.member, str) else None
        if prop is None or prop.lower() not in ('groups', 'captures'):
            return False
        inner = inner.object
    return is_pipeline_item(inner)


def _escape_for_expandable(text: str) -> str:
    """
    Escape characters that are special inside double-quoted strings.
    """
    return text.replace('`', '``').replace('$', '`$')


def _variable_raw(var: Ps1Variable) -> str:
    """
    Produce the braced variable reference for use inside an expandable string.
    """
    prefix = '@' if var.splatted else '$'
    scope = var.scope.value
    if scope:
        return F'{prefix}{{{scope}:{var.name}}}'
    return F'{prefix}{{{var.name}}}'


def _is_string_typed_variable(node: Expression | None) -> TypeGuard[Ps1Variable]:
    """
    Return `True` only for a variable whose value is provably a string, so that folding a `+`
    concatenation into an expandable string cannot change array/number `+` semantics. Environment
    variables are always strings in PowerShell.
    """
    return isinstance(node, Ps1Variable) and node.scope == Ps1ScopeModifier.ENV


def _variable_string_to_expandable(
    var: Ps1Variable,
    text: str,
    *,
    var_first: bool,
) -> Ps1ExpandableString:
    """
    Fold `$var + 'text'` or `'text' + $var` into a
    `refinery.lib.scripts.ps1.model.Ps1ExpandableString`.
    """
    escaped = _escape_for_expandable(text)
    var_raw = _variable_raw(var)
    text_part = Ps1StringLiteral(value=text, raw=F"'{text}'")
    if var_first:
        raw = F'"{var_raw}{escaped}"'
        parts = [var, text_part]
    else:
        raw = F'"{escaped}{var_raw}"'
        parts = [text_part, var]
    return Ps1ExpandableString(parts=parts, raw=raw)


def _resolve_index_values(index: Expression) -> int | list[int] | None:
    """
    The index or indices an expression names. A scalar and a collection are told apart because a
    read at one index yields the element and a read at several yields a collection of them, so the
    two are different values rather than one of length one.
    """
    single = integer_of(read(index))
    if single is not None:
        return single
    array = unwrap_to_array_literal(index)
    return None if array is None else collect_integers(array)


class _Selection(NamedTuple):
    """
    What reading a value out of a literal container yields, beside what building the container
    evaluated and the read then leaves behind.

    Indexing is one such read and `.Length` is another: the count carries nothing forward at all, so
    every element is dropped and every element has to be answered for.

    The two halves are answered together because they are one decision. A fold that reports only
    what it carries forward leaves its caller to reconstruct the rest, and the reconstruction is
    what went wrong: indexing an array literal was read as choosing among *values*, where the
    elements are also *work* — `@(1, (Start-Process calc))[0]` folded to `1` and the command ran in
    the original. It is the same rule the effect layer already states for `[Void](Start-Process x)`,
    which is an `EFFECT` because the wrapper discards a value and never the evaluation behind it.
    """
    carried: Expression
    dropped: list[Expression]


def _character(text: str, index: int) -> Expression | None:
    """
    The character at `index`, spelled as the `Char` it is. Measured: `'ABC'[0]` reports
    `System.Char` and `('abc'[0]) + 'x'` is `ax`, where a one-character String there would have made
    the same `+` a numeric addition on the other side of the operator.
    """
    if not -len(text) <= index < len(text):
        return None
    return render(Ps1Constant(_CHAR, text[index]))


def _index_into_string(s: str, indices: int | list[int]) -> _Selection | None:
    """
    A string is a value and not a container of expressions, so a character selected out of one
    leaves no evaluation behind, whatever the index.
    """
    if isinstance(indices, int):
        one = _character(s, indices)
        return None if one is None else _Selection(one, [])
    selected: list[Expression] = []
    for i in indices:
        element = _character(s, i)
        if element is None:
            return None
        selected.append(element)
    return _Selection(Ps1ArrayLiteral(elements=selected), [])


def _index_into_array(
    array: Ps1ArrayLiteral, indices: int | list[int],
) -> _Selection | None:
    """
    The element or elements a literal array yields for `indices`, beside the elements the selection
    leaves behind.

    **An index that repeats is refused rather than folded.** The selected elements are the array's
    own nodes, so `@(1, 2, 3)[0, 0]` would put one object in two slots of the result: `Node.parent`
    holds one holder, so a later `refinery.lib.scripts._replace_in_parent` rewrites one occurrence
    of two, a transformer visits it twice, and a walk counts whatever it carries twice.

    Copying the node instead would answer a different question — whether the element may be
    *evaluated* twice — and the answer is no for anything with an effect: `@($a.B(), 2)[0, 0]`
    builds the array once and calls `B` once, where the copy calls it twice. That question has no
    caller, because nothing in the corpus or the suite selects a repeated index out of an array
    literal, so it is refused here rather than answered. `_index_into_string` is unaffected: it
    builds a fresh literal per index out of a value that was never a node.
    """
    n = len(array.elements)
    if isinstance(indices, int):
        if not (-n <= indices < n):
            return None
        selected = [array.elements[indices]]
        carried = selected[0]
    else:
        selected = []
        for i in indices:
            if not (-n <= i < n):
                return None
            selected.append(array.elements[i])
        if len({id(element) for element in selected}) != len(selected):
            return None
        carried = Ps1ArrayLiteral(elements=list(selected))
    kept = {id(element) for element in selected}
    return _Selection(
        carried, [element for element in array.elements if id(element) not in kept])


def _lookup_hashtable(ht: Ps1HashLiteral, index: Expression) -> _Selection | None:
    """
    The value a literal hash table holds for `index`, beside every other part of the literal.

    Both halves of each pair are reported as dropped, keys included. PowerShell 5.1 rejects a bare
    subexpression key outright, so the shape that runs is an expandable string holding one, and
    telling that spelling apart from a plain name here would be a second rule about which parts of
    a literal are evaluated — where the whole literal plainly is.
    """
    key = read(index)
    if not isinstance(key, Ps1Constant):
        return None
    for pair_key, pair_value in ht.pairs:
        if _same_key(key, read(pair_key)):
            return _Selection(pair_value, [
                part
                for other_key, other_value in ht.pairs
                for part in (other_key, other_value)
                if part is not pair_value
            ])
    return None


def _same_key(one: Ps1Fact, other: Ps1Fact) -> bool:
    """
    Whether two values are the same hash table key. A key carries its type: measured,
    `@{ a = 1 }[[char]97]` and `@{ 1 = 'x' }['1']` each find nothing, and so does `$h[1L]` for a key
    written `1`, so a Char, a String and each integer width are different keys holding the same
    characters or the same number. Two Strings are the one case that is not identity — `@{ a = 1
    }['A']` is 1, because PowerShell hashes a String key case-insensitively.
    """
    one_text, other_text = text_of(one), text_of(other)
    if one_text is not None and other_text is not None:
        return one_text.lower() == other_text.lower()
    return isinstance(one, Ps1Constant) and one == other


def _pipeline_output(value: Expression | None) -> Expression | None:
    """
    What a pipeline hands to whoever consumes it. A pipeline that emits exactly one object passes
    that object along; it does not wrap it in a collection. Folding a single match out of
    `[regex]::Matches(...) | %{ $_.Value }` therefore yields the string, and an array literal of one
    element here would assign an array where PowerShell assigns a string. Two or more values are a
    collection either way and are left alone.
    """
    if isinstance(value, Ps1ArrayLiteral) and len(value.elements) == 1:
        return value.elements[0]
    return value


def _method_arguments(
    owner: Ps1TypeName, member: str, nodes: list[Expression],
) -> list[str | int] | None:
    """
    The arguments an instance call contributes to the emulated method, or `None` where the call may
    not be folded at all — a member the receiver's type carries no non-static overload of, an arity
    no overload has, or an argument whose value this cannot decide.

    **A method does not coerce its arguments the way an operator does.** An operator turns every
    operand into the text `[string]` of it produces; a method converts each argument to the
    *declared* type of the parameter it binds to, and the two disagree — measured,
    `'abc'.Substring([char]1)` is `bc`, where the Char converts to the number one, and its text
    would be a control character that throws.

    Which of the two an argument takes is read off the collected parameter types rather than
    guessed: a position every candidate overload declares an `Int32` for takes the number, and one
    none of them does takes the text. A position the candidates disagree about is refused, because
    which overload 5.1 binds is a question this does not answer.
    """
    positions = [
        parameters for overload in instance_overloads(owner, member)
        if len(parameters := overload.get('parameters') or ()) == len(nodes)
    ]
    if not positions:
        return None
    arguments: list[str | int] = []
    for index, argument in enumerate(nodes):
        declared = {resolve_type(parameters[index]['type']) for parameters in positions}
        value = _argument_value(read(argument), declared)
        if value is None:
            return None
        arguments.append(value)
    return arguments


def _argument_value(fact: Ps1Fact, declared: set[Ps1TypeName | None]) -> str | int | None:
    """
    What one argument contributes, given the parameter types the candidate overloads declare for its
    position.

    An `Int32` parameter takes the number the argument converts to. Anything else takes its text,
    and only from a value that already is a String or a Char: a `Char[]` parameter is satisfied by a
    String's own characters, which is what makes `'a,b;c'.Split(',;')` three parts, and by a Char,
    which makes `'abc'.Split([char]98)` two. An *integer* there is not the digits — measured,
    `'a9b'.Split(57)` splits on the character 57 is the code of — so it is refused rather than read
    as a text it never becomes.
    """
    if declared == {_INT32}:
        outcome = convert(fact, _INT32)
        return None if outcome.may_throw else integer_of(outcome.value)
    if _INT32 in declared or type_of(fact) not in (_STRING, _CHAR):
        return None
    return coerced_text(fact)


def _value_to_string(receiver: Ps1Fact, member: str, arguments: list[str | int]) -> Expression | None:
    """
    A method on a receiver that is not a String, of which exactly one is folded: `ToString()` with
    no argument, and only for a value whose text carries no culture.

    `ToString()` is *not* `[string]` in general — the method reads the current culture and the
    cast does not — so what it writes is `invariant_text` rather than `coerced_text`, which is the
    same question the separator of a collection asks and is answered once for both.
    """
    if member != 'tostring' or arguments:
        return None
    text = invariant_text(receiver)
    return None if text is None else make_string_literal(text)


def _as_text(operand: Expression | None) -> Expression | None:
    """
    An operand written as the text it contributes, or `None` where it names none or already is that
    text. `refinery.lib.scripts.canonical` is what decides *already*: a rewrite of a tree into an
    equal one is a rewrite that never converges.
    """
    if operand is None:
        return None
    text = coerced_text(read(operand))
    if text is None:
        return None
    spelled = make_string_literal(text)
    return None if canonical(spelled) == canonical(operand) else spelled


def _folded(outcome) -> Expression | None:
    """
    The expression a domain outcome folds to, or `None` where it does not fold. An outcome that may
    throw is never folded: the script's throw is part of what it does, and replacing it with the
    value the operation would have had deletes that. Everything else is left to `render`, which is
    the one place that decides whether a value has a spelling — including `$null`, which is a value
    an operation can produce and not a sign that it produced nothing.
    """
    return None if outcome.may_throw else render(outcome.value)


class Ps1ConstantFolding(Transformer):
    """
    Fold constant expressions to their values. The purity questions the fold rests on are asked
    against the run's shared `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`, captured
    once at entry from the version-keyed model cache rather than re-read per fold. One capture keeps
    every fold in a sweep judged against one world, and — unlike the delete-only passes, which fetch
    the world per body — folding cannot afford a per-question fetch: it edits in post-order, so a
    fresh fetch after each child fold would rebuild the world flood on every index or member fold,
    of which a string-picking obfuscation has thousands. Holding is sound because the captured world
    reads its fail-closed pole once a fold advances the tree version, so a fold it can no longer
    vouch for is deferred, not mis-granted; the pipeline re-runs this pass to a fixpoint, and the
    deferred fold lands in the next sweep against a world rebuilt over the changed tree.
    """

    def __init__(self):
        super().__init__()
        self._world: Ps1WorldReach | None = None
        self._entry = False

    def visit(self, node: Node):
        if self._entry:
            return super().visit(node)
        self._entry = True
        try:
            self._world = model_cache(self, node).world_reach
            return super().visit(node)
        finally:
            self._entry = False

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        if len(node.elements) == 2:
            result = substituted(node, _pipeline_output(self._try_fold_regex_pipeline(node)))
            if result is not None:
                return result
        self.generic_visit(node)
        return None

    @staticmethod
    def _fold_regex_call_result(
        invoke: Ps1InvokeMember, member_lower: str,
    ) -> Expression | None:
        if member_lower == 'matches':
            matches = _compute_regex_matches(invoke)
            if matches is not None:
                elements: list[Expression] = [make_string_literal(s) for s in matches]
                return Ps1ArrayLiteral(elements=elements)
        elif member_lower == 'match':
            result = _compute_regex_match(invoke)
            if result is not None:
                return make_string_literal(result)
        return None

    def _try_fold_regex_pipeline(self, node: Ps1Pipeline) -> Expression | None:
        first = node.elements[0].expression
        second_expr = node.elements[1].expression
        if not isinstance(first, Ps1InvokeMember) or not _is_static_regex_call(first):
            return None
        member = get_member_name(first.member)
        if member is None:
            return None
        if second_expr is None:
            return None
        shadowed = model_cache(self, node).closed_world.shadowed_names
        sb = extract_foreach_scriptblock(second_expr, shadowed)
        if sb is None or not _foreach_extracts_value(sb):
            return None
        return self._fold_regex_call_result(first, member.lower())

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        member = get_member_name(node.member)
        if member is None:
            return None
        obj = node.object
        if obj is None:
            return None
        shaped = self._fold_shape_member(node, obj, member)
        if shaped is not None:
            return shaped
        owner = type_of(read(obj))
        if owner is not None and member.lower() == 'pstypenames':
            names = type_names(owner)
            if names is not None:
                return Ps1ArrayLiteral(elements=[make_string_literal(name) for name in names])
        if owner is not None and member_record(owner, member) is MemberLookup.ABSENT:
            return Ps1Variable(name='Null')
        result = self._try_fold_regex_member_access(node, member)
        if result is not None:
            return result
        return None

    def _fold_shape_member(
        self,
        node: Ps1MemberAccess,
        obj: Expression,
        member: str,
    ) -> Expression | None:
        """
        Fold a member whose value the receiver's shape decides. The array answers go through
        `_selected` because folding one discards the elements that built it, and whether that is
        safe is the selection's question rather than this one's.

        A scalar answers 1 to both `Length` and `Count` whatever it is, which is PowerShell's object
        adapter and not the type's own surface — `System.Char` carries neither member, and
        `([char]65).Length` is 1 all the same. So the receiver is read as a *value* rather than
        matched by spelling: `$null` is not one, and its `Count` is 0.

        A collection is a scalar to neither. A spelling this cannot take apart into element nodes —
        `@()`, `@(@(1, 2))`, `@((1, 2))` — still pins how many elements it holds when every one of
        them is a literal: `read` unrolls the array operator exactly as 5.1 does and hands back the
        elements, and both `Count` and `Length` of the `Object[]` those build are that many. The
        answer goes through `_selected` for the reason the array arm does, and the elements it
        discards are the pinned literals that let `read` answer at all, so it never refuses.
        """
        name = member.lower()
        if name not in _SHAPE_MEMBERS:
            return None
        array = unwrap_to_array_literal(obj)
        if array is not None:
            count = 1 if name == 'rank' else len(array.elements)
            return self._selected(node, _Selection(_integer(count), list(array.elements)))
        fact = read(obj)
        if name == 'rank':
            return None
        if fact is NULL:
            return None if self._strict_v2_may_be_in_force(node) else _integer(0)
        if not isinstance(fact, Ps1Constant):
            return None
        if isinstance(fact.payload, tuple):
            return self._selected(node, _Selection(_integer(len(fact.payload)), [obj]))
        text = text_of(fact)
        if name == 'length' and text is not None:
            return _integer(len(text))
        if self._strict_v2_may_be_in_force(node):
            return None
        return _integer(1)

    def _strict_v2_may_be_in_force(self, node: Node) -> bool:
        """
        Whether `Set-StrictMode -Version 2` may be armed at *node*, under which the `Count` and
        `Length` the object adapter fakes onto a scalar or `$null` raise rather than answer. Only
        those fakes ask this; a real member — a `String`'s `Length`, an array's `Count` — reads on
        under strict mode and is folded without it.
        """
        return model_cache(self, node).faults.strict_mode_v2_may_be_in_force()

    def _try_fold_regex_member_access(
        self, node: Ps1MemberAccess, member: str,
    ) -> Expression | None:
        chain: list[str] = [member]
        inner = node.object
        while isinstance(inner, Ps1MemberAccess):
            prop = get_member_name(inner.member)
            if prop is None:
                return None
            chain.append(prop)
            inner = inner.object
        chain.reverse()
        if not isinstance(inner, Ps1InvokeMember) or not _is_static_regex_call(inner):
            return None
        normalized = [c.lower() for c in chain]
        if normalized[-1] != 'value':
            return None
        for c in normalized[:-1]:
            if c not in ('groups', 'captures'):
                return None
        call_member = inner.member if isinstance(inner.member, str) else None
        if call_member is None:
            return None
        return self._fold_regex_call_result(inner, call_member.lower())

    @staticmethod
    def _try_join_regex_matches(operand: Expression) -> Expression | None:
        unwrapped = unwrap_parens(operand)
        if not isinstance(unwrapped, Ps1InvokeMember) or not _is_static_regex_call(unwrapped):
            return None
        member = unwrapped.member if isinstance(unwrapped.member, str) else None
        if member is None or member.lower() != 'matches':
            return None
        matches = _compute_regex_matches(unwrapped)
        if matches is None:
            return None
        return make_string_literal(''.join(matches))

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        self.generic_visit(node)
        if node.operand is None:
            return None
        op = node.operator.lower()
        if op == '-join':
            return self._handle_unary_join(node)
        if op in ('-', '-bnot'):
            return _folded(apply_unary(op, read_operand(node.operand)))
        if op in ('-not', '!'):
            truth = is_truthy(node.operand)
            if truth is not None:
                return Ps1Variable(name='False' if truth else 'True')
        return None

    def _handle_unary_join(self, node: Ps1UnaryExpression) -> Expression | None:
        operand = node.operand
        if operand is None:
            return None
        result = self._try_join_regex_matches(operand)
        if result is not None:
            return result
        parts = collect_texts(operand)
        return None if parts is None else make_string_literal(''.join(parts))

    def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
        self.generic_visit(node)
        if isinstance(node.parent, Ps1RangeExpression):
            return None
        a = integer_of(read(node.start))
        b = integer_of(read(node.end))
        if a is None or b is None:
            return None
        step = 1 if b >= a else -1
        count = abs(b - a) + 1
        if count > _MAX_RANGES_EXPAND:
            return None
        if not is_fault_free(node):
            return None
        return Ps1ArrayLiteral(elements=[
            Ps1IntegerLiteral(raw=str(v)) for v in range(a, b + step, step)])

    def _selected(self, node: Node, selection: _Selection | None) -> Expression | None:
        """
        The expression a selection out of `node` folds to, or `None` when what it leaves behind is
        work the script would no longer do; see
        `refinery.lib.scripts.ps1.analysis.effects.may_be_dropped` for what that means.

        The world is the one this pass captured at entry (see the class docstring). This pass only
        folds, so a verdict taken before its own edits is the more open, and so the more
        conservative, of the two.

        A refused selection is released the way
        `refinery.lib.scripts.ps1.deobfuscation.substitution` releases one: a multi-index read has
        already built the array literal that carries the result, and building it adopted elements
        that are still standing under `node`.
        """
        if selection is None:
            return None
        if not all(may_be_dropped(part, self._world) for part in selection.dropped):
            reattach(node)
            return None
        return selection.carried

    def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
        self.generic_visit(node)
        if node.index is None or node.object is None:
            return None
        if isinstance(node.object, Ps1HashLiteral):
            return self._selected(node, _lookup_hashtable(node.object, node.index))
        indices = _resolve_index_values(node.index)
        if indices is None:
            return None
        obj_str = text_of(read(node.object))
        if obj_str is not None:
            return self._selected(node, _index_into_string(obj_str, indices))
        array = unwrap_to_array_literal(node.object)
        if array is not None:
            return self._selected(node, _index_into_array(array, indices))
        return None

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        lower = member_name.lower()
        return (
            self._try_fold_invoke_redirect(node, lower)
            or self._try_fold_instance_method(node, lower)
            or self._try_fold_static_method(node, lower)
        ) or None

    @staticmethod
    def _try_fold_invoke_redirect(
        node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if lower == 'invoke' and isinstance(node.object, Ps1MemberAccess):
            return Ps1InvokeMember(
                offset=node.offset,
                object=node.object.object,
                member=node.object.member,
                arguments=node.arguments,
                access=node.object.access,
            )
        return None

    @staticmethod
    def _try_fold_instance_method(
        node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        """
        A method called on a constant receiver, which folds only where the receiver's type really
        carries it.

        The gate is the collected member surface and not the shape of the value: `System.Char` has a
        `ToUpper` whose every overload is static, so `([char]65).ToUpper()` reports `MethodNotFound`
        on 5.1 while `[char]::ToUpper('a')` answers — and a receiver that used to reach here spelled
        as a one-character String had every String method instead of none of them.
        """
        receiver = read(node.object)
        owner = type_of(receiver)
        if owner is None:
            return None
        arguments = _method_arguments(owner, lower, node.arguments)
        if arguments is None:
            return None
        text = text_of(receiver)
        if text is None:
            return _value_to_string(receiver, lower, arguments)
        try:
            result = apply_string_method(text, lower, arguments)
        except StringMethodError:
            return None
        if isinstance(result, str):
            return make_string_literal(result)
        if isinstance(result, bool):
            return Ps1Variable(name='True' if result else 'False')
        if isinstance(result, int):
            return Ps1IntegerLiteral(raw=str(result))
        if isinstance(result, list):
            elements: list[Expression] = [make_string_literal(p) for p in result]
            return Ps1ArrayLiteral(elements=elements)
        return None

    def _try_fold_static_method(
        self, node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if is_static_type_call(node, 'system.convert'):
            return self._try_fold_convert(node, lower)
        encoding_name = detect_encoding_chain(node)
        if encoding_name is not None:
            if len(node.arguments) == 1:
                int_values = collect_integers(unwrap_single_paren(node.arguments[0]))
                if int_values is not None:
                    try:
                        raw_bytes = bytearray(int_values)
                    except (ValueError, OverflowError):
                        return None
                    encoding = ENCODING_MAP.get(
                        encoding_name.lower(), encoding_name)
                    try:
                        codecs.lookup(encoding)
                    except LookupError:
                        encoding = 'utf-8'
                    try:
                        decoded_str = raw_bytes.decode(encoding)
                    except Exception:
                        return None
                    return make_string_literal(decoded_str)
        if is_static_type_call(node, 'system.string'):
            if lower == 'concat' and len(node.arguments) >= 1:
                parts: list[str] = []
                for arg in node.arguments:
                    if (sv := coerced_text(read(arg))) is None:
                        break
                    parts.append(sv)
                else:
                    return make_string_literal(''.join(parts))
            if lower == 'join' and len(node.arguments) >= 2:
                separator = coerced_text(read(node.arguments[0]))
                if separator is not None:
                    joined: list[str] = []
                    for arg in node.arguments[1:]:
                        if (sv := coerced_text(read(arg))) is None:
                            break
                        joined.append(sv)
                    else:
                        return make_string_literal(separator.join(joined))
                    if len(node.arguments) == 2:
                        args = collect_texts(node.arguments[1])
                        if args is not None:
                            return make_string_literal(separator.join(args))
        if _is_static_regex_call(node) and lower == 'replace':
            return self._handle_regex_replace(node)
        if is_static_type_call(node, 'system.bitconverter') and lower == 'tostring':
            return self._try_fold_bitconverter_tostring(node)
        if (
            is_static_type_call(node, 'system.environment')
            and lower == 'getenvironmentvariable'
            and len(na := node.arguments) == 1
            and (_en := coerced_text(read(na[0]))) is not None
            and (_ev := PS1_ENV_CONSTANTS.get(_en.lower())) is not None
        ):
            return make_string_literal(_ev)
        return None

    #: The integer type each `[Convert]::To<T>` produces, which is what the result is spelled at.
    #: Measured: `[Convert]::ToByte('FF', 16)` is a Byte and `[Convert]::ToInt64(5)` an Int64, so a
    #: fold that wrote a bare numeral for either reported an Int32 the call never produced.
    _CONVERT_INT_METHODS: dict[str, Ps1TypeName] = {
        'tobyte'  : named_type('System.Byte'),
        'toint16' : named_type('System.Int16'),
        'toint32' : named_type('System.Int32'),
        'toint64' : named_type('System.Int64'),
        'tosbyte' : named_type('System.SByte'),
        'touint16': named_type('System.UInt16'),
        'touint32': named_type('System.UInt32'),
        'touint64': named_type('System.UInt64'),
    }

    def _try_fold_convert(
        self, node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if lower == 'frombase64string' and len(node.arguments) == 1:
            b64_str = coerced_text(read(node.arguments[0]))
            if b64_str is not None:
                try:
                    decoded = base64.b64decode(b64_str)
                except Exception:
                    return None
                elements: list[Expression] = [
                    Ps1IntegerLiteral(raw=F'0x{b:02X}') for b in decoded
                ]
                array = Ps1ArrayLiteral(elements=elements)
                return Ps1ArrayExpression(
                    body=[Ps1ExpressionStatement(expression=array)])
        target = self._CONVERT_INT_METHODS.get(lower)
        if target is not None:
            return self._fold_convert_int(node, target)
        if lower == 'tochar':
            return self._fold_convert_char(node)
        return None

    @staticmethod
    def _fold_convert_char(node: Ps1InvokeMember) -> Expression | None:
        """
        `[Convert]::ToChar(n)`, which produces a Char and is *not* the cast to one: measured,
        `[Convert]::ToChar(1.5)` throws where `[char]1.5` rounds, because .NET defines no conversion
        from a Double to a Char. So only an integer is read, and only inside the range.
        """
        if len(node.arguments) != 1:
            return None
        code = integer_of(read(node.arguments[0]))
        if code is None or not 0 <= code <= 0xFFFF:
            return None
        return render(Ps1Constant(_CHAR, chr(code)))

    @staticmethod
    def _fold_convert_int(node: Ps1InvokeMember, target: Ps1TypeName) -> Expression | None:
        """
        `[Convert]::To<integer>(...)`, whose String operand is read by an oracle that is neither the
        cast's nor Python's — a third one, measured, and this is where the difference is written.

        For every source but a String the call *is* the cast, so it asks `convert`:
        `[Convert]::ToInt32(1.5)` and `(2.5)` are both 2, which is the half-to-even a cast performs,
        and a Char, a `$true` and a `$null` each convert exactly as they do under one.

        A String is stricter than the cast in the one-argument form. `[Convert]::ToInt32('0x10')`
        throws where `[int]'0x10'` is 16, and so do `'7.5'`, `'1e3'`, `'1,000'`, `'1_0'`, `'0b1010'`
        and the empty String, each of which a cast or Python's own `int` reads as a number — which
        is what this used to do, so `[Convert]::ToInt32('0x10')` answered 16 for a script that stops.

        With an explicit base it is stricter still and it reads a *pattern*: `'FFFFFFFF'` at base
        sixteen is the Int32 -1 and `'80000000'` is -2147483648, where the digits read as a number
        are out of range and this refused to fold them at all.
        """
        arguments = node.arguments
        if len(arguments) == 1:
            fact = read(arguments[0])
            text = text_of(fact)
            if text is None:
                return _folded(convert(fact, target))
            digits = text.strip(_CONVERT_TRIM)
            if not _CONVERT_SIGNED.match(digits):
                return None
            return render(integer_at(target, int(digits)))
        if len(arguments) != 2:
            return None
        text = text_of(read(arguments[0]))
        base = integer_of(read(arguments[1]))
        if text is None or base is None:
            return None
        if base == 10:
            if not _CONVERT_SIGNED.match(text):
                return None
            return render(integer_at(target, int(text)))
        digits_of_base = _CONVERT_PATTERN.get(base)
        if digits_of_base is None or not digits_of_base.match(text):
            return None
        return render(pattern_at(target, int(text, base)))

    @staticmethod
    def _try_fold_bitconverter_tostring(node: Ps1InvokeMember) -> Expression | None:
        if not node.arguments:
            return None
        data = collect_byte_array(node.arguments[0])
        if data is None:
            return None
        offset = 0
        length = len(data)
        if len(node.arguments) >= 2:
            n = integer_of(read(node.arguments[1]))
            if n is None:
                return None
            offset = n
        if len(node.arguments) >= 3:
            n = integer_of(read(node.arguments[2]))
            if n is None:
                return None
            length = n
        if offset < 0 or length < 0 or offset + length > len(data):
            return None
        segment = data[offset:offset + length]
        return make_string_literal('-'.join(F'{b:02X}' for b in segment))

    def _handle_regex_replace(self, node: Ps1InvokeMember) -> Expression | None:
        if len(node.arguments) not in (3, 4):
            return None
        input_str = coerced_text(read(node.arguments[0]))
        pattern_str = coerced_text(read(node.arguments[1]))
        replacement_str = coerced_text(read(node.arguments[2]))
        if input_str is None or pattern_str is None or replacement_str is None:
            return None
        flags = 0
        if len(node.arguments) == 4:
            opts = _parse_regex_options(read(node.arguments[3]))
            if opts is None:
                return None
            flags, _ = opts
        try:
            result = dotnet_regex_replace(pattern_str, replacement_str, input_str, flags=flags)
        except re.error:
            return None
        return make_string_literal(result)

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        self.generic_visit(node)
        op = node.operator.lower()
        return self._folded_operator(node, op) or self._spelled_as_text(node, op)

    def _folded_operator(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        if op == '-f':
            return self._handle_format(node)
        if op == '+':
            return self._handle_arithmetic(node, op) or self._handle_concat(node)
        if op == '*':
            return self._handle_string_multiply(node) or self._handle_arithmetic(node, op)
        if op == '-join':
            return self._handle_binary_join(node)
        if op in ('-replace', '-creplace', '-ireplace'):
            return self._handle_binary_replace(node, op)
        if op in ('-split', '-csplit', '-isplit'):
            return self._handle_binary_split(node, op)
        if op in ('-and', '-or', '-xor'):
            return self._handle_logical(node, op)
        if op in ('-is', '-isnot'):
            return self._handle_type_test(node, op)
        return self._handle_comparison(node, op) or self._handle_arithmetic(node, op)

    def _spelled_as_text(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Every constant operand of a text-only operator, written as the text it contributes.

        This runs only where the operator itself declined to fold, which means one of its operands
        is not constant. The others still stand as whatever they were written or folded to, and for
        a Char that spelling is `[char]12499` where what the operator reads is the character — so
        `-Match [char]12499` becomes `-Match 'ビ'`, which is the same operation said in the form
        that shows what it does. See `_TEXT_OPERATORS` for which operators read an operand this way
        and what was measured to establish it.
        """
        if op not in _TEXT_OPERATORS:
            return None
        changed = False
        for field in ('left', 'right'):
            spelled = _as_text(getattr(node, field))
            if spelled is not None and substitute_field(node, field, spelled):
                changed = True
        right = node.right
        if isinstance(right, Ps1ArrayLiteral):
            elements = [_as_text(element) or element for element in right.elements]
            if any(new is not old for new, old in zip(elements, right.elements)):
                changed = substitute_list(right, 'elements', elements) or changed
        if not changed:
            return None
        self.mark_changed()
        return node

    @staticmethod
    def _handle_arithmetic(node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold an arithmetic, bitwise or shift operator by asking the value domain what it produces.
        The result is spelled at the type the domain gives it, so a fold cannot quietly change one:
        `0xFFFFFFFF -bxor 0x5A` is Int32 -91 rather than the 4294967205 an operand read as an
        unsigned Python integer would give, and `2147483647 + 1` widens to a Double as a host does.
        """
        return _folded(apply(op, read_operand(node.left), read_operand(node.right)))

    @staticmethod
    def _handle_string_multiply(node: Ps1BinaryExpression) -> Expression | None:
        """
        Replication, which `*` performs when its *left* operand is a String and nothing else.

        A negative count is a throw and not an empty string. Measured: `'ab' * -1` terminates the
        script with an `ArgumentOutOfRangeException`, and so does `'ab' * 0xFFFFFFFF`, whose count
        is the Int32 -1. Clamping it to zero answered `''` for both, which is the direction that
        turns a script that stopped into one that carries on — and it only became reachable once
        the count was read as the number its spelling names.
        """
        s = text_of(read(node.left))
        count = integer_of(read(node.right))
        if s is None or count is None or count < 0:
            return None
        if len(s) * count > _MAX_STRING_EXPAND:
            return None
        return make_string_literal(s * count)

    @staticmethod
    def _bool_literal(result: bool) -> Ps1Variable:
        """
        Build the `$True`/`$False` variable node that represents a folded boolean value.
        """
        return Ps1Variable(name='True' if result else 'False')

    def _handle_comparison(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold a comparison, which the value domain answers alone: the equality of two texts, the case
        a spelling counts and what an absent operand compares as all live in
        `refinery.lib.scripts.ps1.analysis.values`, so that one reader decides what a comparison is
        and a measurement that moves it moves in one place.
        """
        return _folded(apply(op, read_operand(node.left), read_operand(node.right)))

    def _handle_type_test(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold `-is` and `-isnot`, whose right operand is a type rather than a value, so the value
        grid `_handle_comparison` reads does not answer them. The left operand's runtime type decides
        the test and the value domain answers it; a right operand that is not a type literal, or a
        left one whose type is not known, leaves the test standing. `-isnot` is the negation of the
        same answer.
        """
        named = node.right
        if not isinstance(named, Ps1TypeExpression):
            return None
        verdict = type_test(read_operand(node.left), named.name)
        if verdict is None:
            return None
        return self._bool_literal(verdict if op == '-is' else not verdict)

    def _handle_logical(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold the logical operators `-and`, `-or`, and `-xor` when both operands are constant.
        """
        left = is_truthy(node.left)
        right = is_truthy(node.right)
        if left is None or right is None:
            return None
        if op == '-and':
            result = left and right
        elif op == '-or':
            result = left or right
        else:
            result = left != right
        return self._bool_literal(result)

    def _handle_format(self, node: Ps1BinaryExpression) -> Expression | None:
        fmt_str = text_of(read(node.left))
        if fmt_str is None or node.right is None:
            return None
        args = collect_format_arguments(node.right)
        if args is None:
            return None
        result = apply_format_string(fmt_str, args)
        if result is None:
            return None
        return make_string_literal(result)

    def _handle_concat(self, node: Ps1BinaryExpression) -> Expression | None:
        """
        What `+` does that the value domain does not answer: appending to a literal collection, and
        rewriting a concatenation with an unknown operand into an expandable string.

        Two constants are not here at all — the domain answers them exactly, `apply` is asked first,
        and the branch that stood here answered `'a' + $null` with the expandable `"a${Null}"` where
        the value is the String `a`.

        Re-associating a chain is gone with it. `($x + 'a') + 'b'` was rewritten to `$x + 'ab'`,
        which is a different value wherever `$x` is not a String: measured, `@(1) + 'a' + 'b'` is a
        three-element array and `@(1) + 'ab'` a two-element one. It only ever fired for an inner
        left operand the domain cannot read, which is exactly the case it is wrong for.
        """
        appended = self._appended_to_array(node)
        if appended is not None:
            return appended
        is_inner_concat = (
            isinstance(node.parent, Ps1BinaryExpression)
            and node.parent.operator == '+'
            and node.parent.left is node
        )
        if is_inner_concat:
            return None
        # `'literal' + $var` is always string concatenation (the string-typed left operand governs
        # `+`), so it is safe to fold into an expandable string. `$var + 'literal'` depends on
        # $var's runtime type (array append / numeric add), so only fold it when the variable is
        # provably a string.
        left, right = node.left, node.right
        left_str = text_of(read(left))
        if isinstance(right, Ps1Variable) and left_str is not None:
            return _variable_string_to_expandable(right, left_str, var_first=False)
        right_str = text_of(read(right))
        if _is_string_typed_variable(left) and right_str is not None:
            return _variable_string_to_expandable(left, right_str, var_first=True)
        return None

    @staticmethod
    def _appended_to_array(node: Ps1BinaryExpression) -> Expression | None:
        """
        A constant appended to a literal collection, which keeps the value's own type rather than
        its text: measured, `@(1, 2) + [char]65` is a three-element array whose last element reports
        `System.Char`.
        """
        if not isinstance(node.left, Ps1ArrayLiteral) or node.right is None:
            return None
        appended = render(read(node.right))
        if appended is None:
            return None
        return Ps1ArrayLiteral(elements=[*node.left.elements, appended])

    def _handle_binary_join(self, node: Ps1BinaryExpression) -> Expression | None:
        separator = coerced_text(read(node.right))
        if separator is None:
            return None
        parts = collect_texts(node.left)
        return None if parts is None else make_string_literal(separator.join(parts))

    def _handle_binary_replace(
        self, node: Ps1BinaryExpression, op: str,
    ) -> Expression | None:
        haystack = coerced_text(read(node.left))
        if haystack is None or node.right is None:
            return None
        if not isinstance(node.right, Ps1ArrayLiteral) or len(node.right.elements) != 2:
            return None
        needle_str = coerced_text(read(node.right.elements[0]))
        insert_str = coerced_text(read(node.right.elements[1]))
        if needle_str is None or insert_str is None:
            return None
        flags = re.IGNORECASE if op != '-creplace' else 0
        try:
            result = dotnet_regex_replace(needle_str, insert_str, haystack, flags=flags)
        except re.error:
            return None
        return make_string_literal(result)

    def _handle_binary_split(
        self, node: Ps1BinaryExpression, op: str,
    ) -> Expression | None:
        pattern_str = coerced_text(read(node.right))
        if pattern_str is None:
            return None
        inputs = collect_texts(node.left)
        if inputs is None:
            return None
        flags = re.IGNORECASE if op != '-csplit' else 0
        try:
            parts: list[str] = []
            for s in inputs:
                parts.extend(re.split(pattern_str, s, flags=flags))
        except re.error:
            return None
        elements: list[Expression] = [make_string_literal(p) for p in parts]
        return Ps1ArrayLiteral(elements=elements)

Classes

class Ps1ConstantFolding

Fold constant expressions to their values. The purity questions the fold rests on are asked against the run's shared Ps1WorldReach, captured once at entry from the version-keyed model cache rather than re-read per fold. One capture keeps every fold in a sweep judged against one world, and — unlike the delete-only passes, which fetch the world per body — folding cannot afford a per-question fetch: it edits in post-order, so a fresh fetch after each child fold would rebuild the world flood on every index or member fold, of which a string-picking obfuscation has thousands. Holding is sound because the captured world reads its fail-closed pole once a fold advances the tree version, so a fold it can no longer vouch for is deferred, not mis-granted; the pipeline re-runs this pass to a fixpoint, and the deferred fold lands in the next sweep against a world rebuilt over the changed tree.

Expand source code Browse git
class Ps1ConstantFolding(Transformer):
    """
    Fold constant expressions to their values. The purity questions the fold rests on are asked
    against the run's shared `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach`, captured
    once at entry from the version-keyed model cache rather than re-read per fold. One capture keeps
    every fold in a sweep judged against one world, and — unlike the delete-only passes, which fetch
    the world per body — folding cannot afford a per-question fetch: it edits in post-order, so a
    fresh fetch after each child fold would rebuild the world flood on every index or member fold,
    of which a string-picking obfuscation has thousands. Holding is sound because the captured world
    reads its fail-closed pole once a fold advances the tree version, so a fold it can no longer
    vouch for is deferred, not mis-granted; the pipeline re-runs this pass to a fixpoint, and the
    deferred fold lands in the next sweep against a world rebuilt over the changed tree.
    """

    def __init__(self):
        super().__init__()
        self._world: Ps1WorldReach | None = None
        self._entry = False

    def visit(self, node: Node):
        if self._entry:
            return super().visit(node)
        self._entry = True
        try:
            self._world = model_cache(self, node).world_reach
            return super().visit(node)
        finally:
            self._entry = False

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        if len(node.elements) == 2:
            result = substituted(node, _pipeline_output(self._try_fold_regex_pipeline(node)))
            if result is not None:
                return result
        self.generic_visit(node)
        return None

    @staticmethod
    def _fold_regex_call_result(
        invoke: Ps1InvokeMember, member_lower: str,
    ) -> Expression | None:
        if member_lower == 'matches':
            matches = _compute_regex_matches(invoke)
            if matches is not None:
                elements: list[Expression] = [make_string_literal(s) for s in matches]
                return Ps1ArrayLiteral(elements=elements)
        elif member_lower == 'match':
            result = _compute_regex_match(invoke)
            if result is not None:
                return make_string_literal(result)
        return None

    def _try_fold_regex_pipeline(self, node: Ps1Pipeline) -> Expression | None:
        first = node.elements[0].expression
        second_expr = node.elements[1].expression
        if not isinstance(first, Ps1InvokeMember) or not _is_static_regex_call(first):
            return None
        member = get_member_name(first.member)
        if member is None:
            return None
        if second_expr is None:
            return None
        shadowed = model_cache(self, node).closed_world.shadowed_names
        sb = extract_foreach_scriptblock(second_expr, shadowed)
        if sb is None or not _foreach_extracts_value(sb):
            return None
        return self._fold_regex_call_result(first, member.lower())

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        member = get_member_name(node.member)
        if member is None:
            return None
        obj = node.object
        if obj is None:
            return None
        shaped = self._fold_shape_member(node, obj, member)
        if shaped is not None:
            return shaped
        owner = type_of(read(obj))
        if owner is not None and member.lower() == 'pstypenames':
            names = type_names(owner)
            if names is not None:
                return Ps1ArrayLiteral(elements=[make_string_literal(name) for name in names])
        if owner is not None and member_record(owner, member) is MemberLookup.ABSENT:
            return Ps1Variable(name='Null')
        result = self._try_fold_regex_member_access(node, member)
        if result is not None:
            return result
        return None

    def _fold_shape_member(
        self,
        node: Ps1MemberAccess,
        obj: Expression,
        member: str,
    ) -> Expression | None:
        """
        Fold a member whose value the receiver's shape decides. The array answers go through
        `_selected` because folding one discards the elements that built it, and whether that is
        safe is the selection's question rather than this one's.

        A scalar answers 1 to both `Length` and `Count` whatever it is, which is PowerShell's object
        adapter and not the type's own surface — `System.Char` carries neither member, and
        `([char]65).Length` is 1 all the same. So the receiver is read as a *value* rather than
        matched by spelling: `$null` is not one, and its `Count` is 0.

        A collection is a scalar to neither. A spelling this cannot take apart into element nodes —
        `@()`, `@(@(1, 2))`, `@((1, 2))` — still pins how many elements it holds when every one of
        them is a literal: `read` unrolls the array operator exactly as 5.1 does and hands back the
        elements, and both `Count` and `Length` of the `Object[]` those build are that many. The
        answer goes through `_selected` for the reason the array arm does, and the elements it
        discards are the pinned literals that let `read` answer at all, so it never refuses.
        """
        name = member.lower()
        if name not in _SHAPE_MEMBERS:
            return None
        array = unwrap_to_array_literal(obj)
        if array is not None:
            count = 1 if name == 'rank' else len(array.elements)
            return self._selected(node, _Selection(_integer(count), list(array.elements)))
        fact = read(obj)
        if name == 'rank':
            return None
        if fact is NULL:
            return None if self._strict_v2_may_be_in_force(node) else _integer(0)
        if not isinstance(fact, Ps1Constant):
            return None
        if isinstance(fact.payload, tuple):
            return self._selected(node, _Selection(_integer(len(fact.payload)), [obj]))
        text = text_of(fact)
        if name == 'length' and text is not None:
            return _integer(len(text))
        if self._strict_v2_may_be_in_force(node):
            return None
        return _integer(1)

    def _strict_v2_may_be_in_force(self, node: Node) -> bool:
        """
        Whether `Set-StrictMode -Version 2` may be armed at *node*, under which the `Count` and
        `Length` the object adapter fakes onto a scalar or `$null` raise rather than answer. Only
        those fakes ask this; a real member — a `String`'s `Length`, an array's `Count` — reads on
        under strict mode and is folded without it.
        """
        return model_cache(self, node).faults.strict_mode_v2_may_be_in_force()

    def _try_fold_regex_member_access(
        self, node: Ps1MemberAccess, member: str,
    ) -> Expression | None:
        chain: list[str] = [member]
        inner = node.object
        while isinstance(inner, Ps1MemberAccess):
            prop = get_member_name(inner.member)
            if prop is None:
                return None
            chain.append(prop)
            inner = inner.object
        chain.reverse()
        if not isinstance(inner, Ps1InvokeMember) or not _is_static_regex_call(inner):
            return None
        normalized = [c.lower() for c in chain]
        if normalized[-1] != 'value':
            return None
        for c in normalized[:-1]:
            if c not in ('groups', 'captures'):
                return None
        call_member = inner.member if isinstance(inner.member, str) else None
        if call_member is None:
            return None
        return self._fold_regex_call_result(inner, call_member.lower())

    @staticmethod
    def _try_join_regex_matches(operand: Expression) -> Expression | None:
        unwrapped = unwrap_parens(operand)
        if not isinstance(unwrapped, Ps1InvokeMember) or not _is_static_regex_call(unwrapped):
            return None
        member = unwrapped.member if isinstance(unwrapped.member, str) else None
        if member is None or member.lower() != 'matches':
            return None
        matches = _compute_regex_matches(unwrapped)
        if matches is None:
            return None
        return make_string_literal(''.join(matches))

    def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
        self.generic_visit(node)
        if node.operand is None:
            return None
        op = node.operator.lower()
        if op == '-join':
            return self._handle_unary_join(node)
        if op in ('-', '-bnot'):
            return _folded(apply_unary(op, read_operand(node.operand)))
        if op in ('-not', '!'):
            truth = is_truthy(node.operand)
            if truth is not None:
                return Ps1Variable(name='False' if truth else 'True')
        return None

    def _handle_unary_join(self, node: Ps1UnaryExpression) -> Expression | None:
        operand = node.operand
        if operand is None:
            return None
        result = self._try_join_regex_matches(operand)
        if result is not None:
            return result
        parts = collect_texts(operand)
        return None if parts is None else make_string_literal(''.join(parts))

    def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
        self.generic_visit(node)
        if isinstance(node.parent, Ps1RangeExpression):
            return None
        a = integer_of(read(node.start))
        b = integer_of(read(node.end))
        if a is None or b is None:
            return None
        step = 1 if b >= a else -1
        count = abs(b - a) + 1
        if count > _MAX_RANGES_EXPAND:
            return None
        if not is_fault_free(node):
            return None
        return Ps1ArrayLiteral(elements=[
            Ps1IntegerLiteral(raw=str(v)) for v in range(a, b + step, step)])

    def _selected(self, node: Node, selection: _Selection | None) -> Expression | None:
        """
        The expression a selection out of `node` folds to, or `None` when what it leaves behind is
        work the script would no longer do; see
        `refinery.lib.scripts.ps1.analysis.effects.may_be_dropped` for what that means.

        The world is the one this pass captured at entry (see the class docstring). This pass only
        folds, so a verdict taken before its own edits is the more open, and so the more
        conservative, of the two.

        A refused selection is released the way
        `refinery.lib.scripts.ps1.deobfuscation.substitution` releases one: a multi-index read has
        already built the array literal that carries the result, and building it adopted elements
        that are still standing under `node`.
        """
        if selection is None:
            return None
        if not all(may_be_dropped(part, self._world) for part in selection.dropped):
            reattach(node)
            return None
        return selection.carried

    def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
        self.generic_visit(node)
        if node.index is None or node.object is None:
            return None
        if isinstance(node.object, Ps1HashLiteral):
            return self._selected(node, _lookup_hashtable(node.object, node.index))
        indices = _resolve_index_values(node.index)
        if indices is None:
            return None
        obj_str = text_of(read(node.object))
        if obj_str is not None:
            return self._selected(node, _index_into_string(obj_str, indices))
        array = unwrap_to_array_literal(node.object)
        if array is not None:
            return self._selected(node, _index_into_array(array, indices))
        return None

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        lower = member_name.lower()
        return (
            self._try_fold_invoke_redirect(node, lower)
            or self._try_fold_instance_method(node, lower)
            or self._try_fold_static_method(node, lower)
        ) or None

    @staticmethod
    def _try_fold_invoke_redirect(
        node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if lower == 'invoke' and isinstance(node.object, Ps1MemberAccess):
            return Ps1InvokeMember(
                offset=node.offset,
                object=node.object.object,
                member=node.object.member,
                arguments=node.arguments,
                access=node.object.access,
            )
        return None

    @staticmethod
    def _try_fold_instance_method(
        node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        """
        A method called on a constant receiver, which folds only where the receiver's type really
        carries it.

        The gate is the collected member surface and not the shape of the value: `System.Char` has a
        `ToUpper` whose every overload is static, so `([char]65).ToUpper()` reports `MethodNotFound`
        on 5.1 while `[char]::ToUpper('a')` answers — and a receiver that used to reach here spelled
        as a one-character String had every String method instead of none of them.
        """
        receiver = read(node.object)
        owner = type_of(receiver)
        if owner is None:
            return None
        arguments = _method_arguments(owner, lower, node.arguments)
        if arguments is None:
            return None
        text = text_of(receiver)
        if text is None:
            return _value_to_string(receiver, lower, arguments)
        try:
            result = apply_string_method(text, lower, arguments)
        except StringMethodError:
            return None
        if isinstance(result, str):
            return make_string_literal(result)
        if isinstance(result, bool):
            return Ps1Variable(name='True' if result else 'False')
        if isinstance(result, int):
            return Ps1IntegerLiteral(raw=str(result))
        if isinstance(result, list):
            elements: list[Expression] = [make_string_literal(p) for p in result]
            return Ps1ArrayLiteral(elements=elements)
        return None

    def _try_fold_static_method(
        self, node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if is_static_type_call(node, 'system.convert'):
            return self._try_fold_convert(node, lower)
        encoding_name = detect_encoding_chain(node)
        if encoding_name is not None:
            if len(node.arguments) == 1:
                int_values = collect_integers(unwrap_single_paren(node.arguments[0]))
                if int_values is not None:
                    try:
                        raw_bytes = bytearray(int_values)
                    except (ValueError, OverflowError):
                        return None
                    encoding = ENCODING_MAP.get(
                        encoding_name.lower(), encoding_name)
                    try:
                        codecs.lookup(encoding)
                    except LookupError:
                        encoding = 'utf-8'
                    try:
                        decoded_str = raw_bytes.decode(encoding)
                    except Exception:
                        return None
                    return make_string_literal(decoded_str)
        if is_static_type_call(node, 'system.string'):
            if lower == 'concat' and len(node.arguments) >= 1:
                parts: list[str] = []
                for arg in node.arguments:
                    if (sv := coerced_text(read(arg))) is None:
                        break
                    parts.append(sv)
                else:
                    return make_string_literal(''.join(parts))
            if lower == 'join' and len(node.arguments) >= 2:
                separator = coerced_text(read(node.arguments[0]))
                if separator is not None:
                    joined: list[str] = []
                    for arg in node.arguments[1:]:
                        if (sv := coerced_text(read(arg))) is None:
                            break
                        joined.append(sv)
                    else:
                        return make_string_literal(separator.join(joined))
                    if len(node.arguments) == 2:
                        args = collect_texts(node.arguments[1])
                        if args is not None:
                            return make_string_literal(separator.join(args))
        if _is_static_regex_call(node) and lower == 'replace':
            return self._handle_regex_replace(node)
        if is_static_type_call(node, 'system.bitconverter') and lower == 'tostring':
            return self._try_fold_bitconverter_tostring(node)
        if (
            is_static_type_call(node, 'system.environment')
            and lower == 'getenvironmentvariable'
            and len(na := node.arguments) == 1
            and (_en := coerced_text(read(na[0]))) is not None
            and (_ev := PS1_ENV_CONSTANTS.get(_en.lower())) is not None
        ):
            return make_string_literal(_ev)
        return None

    #: The integer type each `[Convert]::To<T>` produces, which is what the result is spelled at.
    #: Measured: `[Convert]::ToByte('FF', 16)` is a Byte and `[Convert]::ToInt64(5)` an Int64, so a
    #: fold that wrote a bare numeral for either reported an Int32 the call never produced.
    _CONVERT_INT_METHODS: dict[str, Ps1TypeName] = {
        'tobyte'  : named_type('System.Byte'),
        'toint16' : named_type('System.Int16'),
        'toint32' : named_type('System.Int32'),
        'toint64' : named_type('System.Int64'),
        'tosbyte' : named_type('System.SByte'),
        'touint16': named_type('System.UInt16'),
        'touint32': named_type('System.UInt32'),
        'touint64': named_type('System.UInt64'),
    }

    def _try_fold_convert(
        self, node: Ps1InvokeMember, lower: str,
    ) -> Expression | None:
        if lower == 'frombase64string' and len(node.arguments) == 1:
            b64_str = coerced_text(read(node.arguments[0]))
            if b64_str is not None:
                try:
                    decoded = base64.b64decode(b64_str)
                except Exception:
                    return None
                elements: list[Expression] = [
                    Ps1IntegerLiteral(raw=F'0x{b:02X}') for b in decoded
                ]
                array = Ps1ArrayLiteral(elements=elements)
                return Ps1ArrayExpression(
                    body=[Ps1ExpressionStatement(expression=array)])
        target = self._CONVERT_INT_METHODS.get(lower)
        if target is not None:
            return self._fold_convert_int(node, target)
        if lower == 'tochar':
            return self._fold_convert_char(node)
        return None

    @staticmethod
    def _fold_convert_char(node: Ps1InvokeMember) -> Expression | None:
        """
        `[Convert]::ToChar(n)`, which produces a Char and is *not* the cast to one: measured,
        `[Convert]::ToChar(1.5)` throws where `[char]1.5` rounds, because .NET defines no conversion
        from a Double to a Char. So only an integer is read, and only inside the range.
        """
        if len(node.arguments) != 1:
            return None
        code = integer_of(read(node.arguments[0]))
        if code is None or not 0 <= code <= 0xFFFF:
            return None
        return render(Ps1Constant(_CHAR, chr(code)))

    @staticmethod
    def _fold_convert_int(node: Ps1InvokeMember, target: Ps1TypeName) -> Expression | None:
        """
        `[Convert]::To<integer>(...)`, whose String operand is read by an oracle that is neither the
        cast's nor Python's — a third one, measured, and this is where the difference is written.

        For every source but a String the call *is* the cast, so it asks `convert`:
        `[Convert]::ToInt32(1.5)` and `(2.5)` are both 2, which is the half-to-even a cast performs,
        and a Char, a `$true` and a `$null` each convert exactly as they do under one.

        A String is stricter than the cast in the one-argument form. `[Convert]::ToInt32('0x10')`
        throws where `[int]'0x10'` is 16, and so do `'7.5'`, `'1e3'`, `'1,000'`, `'1_0'`, `'0b1010'`
        and the empty String, each of which a cast or Python's own `int` reads as a number — which
        is what this used to do, so `[Convert]::ToInt32('0x10')` answered 16 for a script that stops.

        With an explicit base it is stricter still and it reads a *pattern*: `'FFFFFFFF'` at base
        sixteen is the Int32 -1 and `'80000000'` is -2147483648, where the digits read as a number
        are out of range and this refused to fold them at all.
        """
        arguments = node.arguments
        if len(arguments) == 1:
            fact = read(arguments[0])
            text = text_of(fact)
            if text is None:
                return _folded(convert(fact, target))
            digits = text.strip(_CONVERT_TRIM)
            if not _CONVERT_SIGNED.match(digits):
                return None
            return render(integer_at(target, int(digits)))
        if len(arguments) != 2:
            return None
        text = text_of(read(arguments[0]))
        base = integer_of(read(arguments[1]))
        if text is None or base is None:
            return None
        if base == 10:
            if not _CONVERT_SIGNED.match(text):
                return None
            return render(integer_at(target, int(text)))
        digits_of_base = _CONVERT_PATTERN.get(base)
        if digits_of_base is None or not digits_of_base.match(text):
            return None
        return render(pattern_at(target, int(text, base)))

    @staticmethod
    def _try_fold_bitconverter_tostring(node: Ps1InvokeMember) -> Expression | None:
        if not node.arguments:
            return None
        data = collect_byte_array(node.arguments[0])
        if data is None:
            return None
        offset = 0
        length = len(data)
        if len(node.arguments) >= 2:
            n = integer_of(read(node.arguments[1]))
            if n is None:
                return None
            offset = n
        if len(node.arguments) >= 3:
            n = integer_of(read(node.arguments[2]))
            if n is None:
                return None
            length = n
        if offset < 0 or length < 0 or offset + length > len(data):
            return None
        segment = data[offset:offset + length]
        return make_string_literal('-'.join(F'{b:02X}' for b in segment))

    def _handle_regex_replace(self, node: Ps1InvokeMember) -> Expression | None:
        if len(node.arguments) not in (3, 4):
            return None
        input_str = coerced_text(read(node.arguments[0]))
        pattern_str = coerced_text(read(node.arguments[1]))
        replacement_str = coerced_text(read(node.arguments[2]))
        if input_str is None or pattern_str is None or replacement_str is None:
            return None
        flags = 0
        if len(node.arguments) == 4:
            opts = _parse_regex_options(read(node.arguments[3]))
            if opts is None:
                return None
            flags, _ = opts
        try:
            result = dotnet_regex_replace(pattern_str, replacement_str, input_str, flags=flags)
        except re.error:
            return None
        return make_string_literal(result)

    def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
        self.generic_visit(node)
        op = node.operator.lower()
        return self._folded_operator(node, op) or self._spelled_as_text(node, op)

    def _folded_operator(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        if op == '-f':
            return self._handle_format(node)
        if op == '+':
            return self._handle_arithmetic(node, op) or self._handle_concat(node)
        if op == '*':
            return self._handle_string_multiply(node) or self._handle_arithmetic(node, op)
        if op == '-join':
            return self._handle_binary_join(node)
        if op in ('-replace', '-creplace', '-ireplace'):
            return self._handle_binary_replace(node, op)
        if op in ('-split', '-csplit', '-isplit'):
            return self._handle_binary_split(node, op)
        if op in ('-and', '-or', '-xor'):
            return self._handle_logical(node, op)
        if op in ('-is', '-isnot'):
            return self._handle_type_test(node, op)
        return self._handle_comparison(node, op) or self._handle_arithmetic(node, op)

    def _spelled_as_text(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Every constant operand of a text-only operator, written as the text it contributes.

        This runs only where the operator itself declined to fold, which means one of its operands
        is not constant. The others still stand as whatever they were written or folded to, and for
        a Char that spelling is `[char]12499` where what the operator reads is the character — so
        `-Match [char]12499` becomes `-Match 'ビ'`, which is the same operation said in the form
        that shows what it does. See `_TEXT_OPERATORS` for which operators read an operand this way
        and what was measured to establish it.
        """
        if op not in _TEXT_OPERATORS:
            return None
        changed = False
        for field in ('left', 'right'):
            spelled = _as_text(getattr(node, field))
            if spelled is not None and substitute_field(node, field, spelled):
                changed = True
        right = node.right
        if isinstance(right, Ps1ArrayLiteral):
            elements = [_as_text(element) or element for element in right.elements]
            if any(new is not old for new, old in zip(elements, right.elements)):
                changed = substitute_list(right, 'elements', elements) or changed
        if not changed:
            return None
        self.mark_changed()
        return node

    @staticmethod
    def _handle_arithmetic(node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold an arithmetic, bitwise or shift operator by asking the value domain what it produces.
        The result is spelled at the type the domain gives it, so a fold cannot quietly change one:
        `0xFFFFFFFF -bxor 0x5A` is Int32 -91 rather than the 4294967205 an operand read as an
        unsigned Python integer would give, and `2147483647 + 1` widens to a Double as a host does.
        """
        return _folded(apply(op, read_operand(node.left), read_operand(node.right)))

    @staticmethod
    def _handle_string_multiply(node: Ps1BinaryExpression) -> Expression | None:
        """
        Replication, which `*` performs when its *left* operand is a String and nothing else.

        A negative count is a throw and not an empty string. Measured: `'ab' * -1` terminates the
        script with an `ArgumentOutOfRangeException`, and so does `'ab' * 0xFFFFFFFF`, whose count
        is the Int32 -1. Clamping it to zero answered `''` for both, which is the direction that
        turns a script that stopped into one that carries on — and it only became reachable once
        the count was read as the number its spelling names.
        """
        s = text_of(read(node.left))
        count = integer_of(read(node.right))
        if s is None or count is None or count < 0:
            return None
        if len(s) * count > _MAX_STRING_EXPAND:
            return None
        return make_string_literal(s * count)

    @staticmethod
    def _bool_literal(result: bool) -> Ps1Variable:
        """
        Build the `$True`/`$False` variable node that represents a folded boolean value.
        """
        return Ps1Variable(name='True' if result else 'False')

    def _handle_comparison(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold a comparison, which the value domain answers alone: the equality of two texts, the case
        a spelling counts and what an absent operand compares as all live in
        `refinery.lib.scripts.ps1.analysis.values`, so that one reader decides what a comparison is
        and a measurement that moves it moves in one place.
        """
        return _folded(apply(op, read_operand(node.left), read_operand(node.right)))

    def _handle_type_test(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold `-is` and `-isnot`, whose right operand is a type rather than a value, so the value
        grid `_handle_comparison` reads does not answer them. The left operand's runtime type decides
        the test and the value domain answers it; a right operand that is not a type literal, or a
        left one whose type is not known, leaves the test standing. `-isnot` is the negation of the
        same answer.
        """
        named = node.right
        if not isinstance(named, Ps1TypeExpression):
            return None
        verdict = type_test(read_operand(node.left), named.name)
        if verdict is None:
            return None
        return self._bool_literal(verdict if op == '-is' else not verdict)

    def _handle_logical(self, node: Ps1BinaryExpression, op: str) -> Expression | None:
        """
        Fold the logical operators `-and`, `-or`, and `-xor` when both operands are constant.
        """
        left = is_truthy(node.left)
        right = is_truthy(node.right)
        if left is None or right is None:
            return None
        if op == '-and':
            result = left and right
        elif op == '-or':
            result = left or right
        else:
            result = left != right
        return self._bool_literal(result)

    def _handle_format(self, node: Ps1BinaryExpression) -> Expression | None:
        fmt_str = text_of(read(node.left))
        if fmt_str is None or node.right is None:
            return None
        args = collect_format_arguments(node.right)
        if args is None:
            return None
        result = apply_format_string(fmt_str, args)
        if result is None:
            return None
        return make_string_literal(result)

    def _handle_concat(self, node: Ps1BinaryExpression) -> Expression | None:
        """
        What `+` does that the value domain does not answer: appending to a literal collection, and
        rewriting a concatenation with an unknown operand into an expandable string.

        Two constants are not here at all — the domain answers them exactly, `apply` is asked first,
        and the branch that stood here answered `'a' + $null` with the expandable `"a${Null}"` where
        the value is the String `a`.

        Re-associating a chain is gone with it. `($x + 'a') + 'b'` was rewritten to `$x + 'ab'`,
        which is a different value wherever `$x` is not a String: measured, `@(1) + 'a' + 'b'` is a
        three-element array and `@(1) + 'ab'` a two-element one. It only ever fired for an inner
        left operand the domain cannot read, which is exactly the case it is wrong for.
        """
        appended = self._appended_to_array(node)
        if appended is not None:
            return appended
        is_inner_concat = (
            isinstance(node.parent, Ps1BinaryExpression)
            and node.parent.operator == '+'
            and node.parent.left is node
        )
        if is_inner_concat:
            return None
        # `'literal' + $var` is always string concatenation (the string-typed left operand governs
        # `+`), so it is safe to fold into an expandable string. `$var + 'literal'` depends on
        # $var's runtime type (array append / numeric add), so only fold it when the variable is
        # provably a string.
        left, right = node.left, node.right
        left_str = text_of(read(left))
        if isinstance(right, Ps1Variable) and left_str is not None:
            return _variable_string_to_expandable(right, left_str, var_first=False)
        right_str = text_of(read(right))
        if _is_string_typed_variable(left) and right_str is not None:
            return _variable_string_to_expandable(left, right_str, var_first=True)
        return None

    @staticmethod
    def _appended_to_array(node: Ps1BinaryExpression) -> Expression | None:
        """
        A constant appended to a literal collection, which keeps the value's own type rather than
        its text: measured, `@(1, 2) + [char]65` is a three-element array whose last element reports
        `System.Char`.
        """
        if not isinstance(node.left, Ps1ArrayLiteral) or node.right is None:
            return None
        appended = render(read(node.right))
        if appended is None:
            return None
        return Ps1ArrayLiteral(elements=[*node.left.elements, appended])

    def _handle_binary_join(self, node: Ps1BinaryExpression) -> Expression | None:
        separator = coerced_text(read(node.right))
        if separator is None:
            return None
        parts = collect_texts(node.left)
        return None if parts is None else make_string_literal(separator.join(parts))

    def _handle_binary_replace(
        self, node: Ps1BinaryExpression, op: str,
    ) -> Expression | None:
        haystack = coerced_text(read(node.left))
        if haystack is None or node.right is None:
            return None
        if not isinstance(node.right, Ps1ArrayLiteral) or len(node.right.elements) != 2:
            return None
        needle_str = coerced_text(read(node.right.elements[0]))
        insert_str = coerced_text(read(node.right.elements[1]))
        if needle_str is None or insert_str is None:
            return None
        flags = re.IGNORECASE if op != '-creplace' else 0
        try:
            result = dotnet_regex_replace(needle_str, insert_str, haystack, flags=flags)
        except re.error:
            return None
        return make_string_literal(result)

    def _handle_binary_split(
        self, node: Ps1BinaryExpression, op: str,
    ) -> Expression | None:
        pattern_str = coerced_text(read(node.right))
        if pattern_str is None:
            return None
        inputs = collect_texts(node.left)
        if inputs is None:
            return None
        flags = re.IGNORECASE if op != '-csplit' else 0
        try:
            parts: list[str] = []
            for s in inputs:
                parts.extend(re.split(pattern_str, s, flags=flags))
        except re.error:
            return None
        elements: list[Expression] = [make_string_literal(p) for p in parts]
        return Ps1ArrayLiteral(elements=elements)

Ancestors

Methods

def visit(self, node)
Expand source code Browse git
def visit(self, node: Node):
    if self._entry:
        return super().visit(node)
    self._entry = True
    try:
        self._world = model_cache(self, node).world_reach
        return super().visit(node)
    finally:
        self._entry = False
def visit_Ps1Pipeline(self, node)
Expand source code Browse git
def visit_Ps1Pipeline(self, node: Ps1Pipeline):
    if len(node.elements) == 2:
        result = substituted(node, _pipeline_output(self._try_fold_regex_pipeline(node)))
        if result is not None:
            return result
    self.generic_visit(node)
    return None
def visit_Ps1MemberAccess(self, node)
Expand source code Browse git
def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
    self.generic_visit(node)
    member = get_member_name(node.member)
    if member is None:
        return None
    obj = node.object
    if obj is None:
        return None
    shaped = self._fold_shape_member(node, obj, member)
    if shaped is not None:
        return shaped
    owner = type_of(read(obj))
    if owner is not None and member.lower() == 'pstypenames':
        names = type_names(owner)
        if names is not None:
            return Ps1ArrayLiteral(elements=[make_string_literal(name) for name in names])
    if owner is not None and member_record(owner, member) is MemberLookup.ABSENT:
        return Ps1Variable(name='Null')
    result = self._try_fold_regex_member_access(node, member)
    if result is not None:
        return result
    return None
def visit_Ps1UnaryExpression(self, node)
Expand source code Browse git
def visit_Ps1UnaryExpression(self, node: Ps1UnaryExpression):
    self.generic_visit(node)
    if node.operand is None:
        return None
    op = node.operator.lower()
    if op == '-join':
        return self._handle_unary_join(node)
    if op in ('-', '-bnot'):
        return _folded(apply_unary(op, read_operand(node.operand)))
    if op in ('-not', '!'):
        truth = is_truthy(node.operand)
        if truth is not None:
            return Ps1Variable(name='False' if truth else 'True')
    return None
def visit_Ps1RangeExpression(self, node)
Expand source code Browse git
def visit_Ps1RangeExpression(self, node: Ps1RangeExpression):
    self.generic_visit(node)
    if isinstance(node.parent, Ps1RangeExpression):
        return None
    a = integer_of(read(node.start))
    b = integer_of(read(node.end))
    if a is None or b is None:
        return None
    step = 1 if b >= a else -1
    count = abs(b - a) + 1
    if count > _MAX_RANGES_EXPAND:
        return None
    if not is_fault_free(node):
        return None
    return Ps1ArrayLiteral(elements=[
        Ps1IntegerLiteral(raw=str(v)) for v in range(a, b + step, step)])
def visit_Ps1IndexExpression(self, node)
Expand source code Browse git
def visit_Ps1IndexExpression(self, node: Ps1IndexExpression):
    self.generic_visit(node)
    if node.index is None or node.object is None:
        return None
    if isinstance(node.object, Ps1HashLiteral):
        return self._selected(node, _lookup_hashtable(node.object, node.index))
    indices = _resolve_index_values(node.index)
    if indices is None:
        return None
    obj_str = text_of(read(node.object))
    if obj_str is not None:
        return self._selected(node, _index_into_string(obj_str, indices))
    array = unwrap_to_array_literal(node.object)
    if array is not None:
        return self._selected(node, _index_into_array(array, indices))
    return None
def visit_Ps1InvokeMember(self, node)
Expand source code Browse git
def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
    self.generic_visit(node)
    member_name = get_member_name(node.member)
    if member_name is None:
        return None
    lower = member_name.lower()
    return (
        self._try_fold_invoke_redirect(node, lower)
        or self._try_fold_instance_method(node, lower)
        or self._try_fold_static_method(node, lower)
    ) or None
def visit_Ps1BinaryExpression(self, node)
Expand source code Browse git
def visit_Ps1BinaryExpression(self, node: Ps1BinaryExpression):
    self.generic_visit(node)
    op = node.operator.lower()
    return self._folded_operator(node, op) or self._spelled_as_text(node, op)

Inherited members