Module refinery.lib.scripts.ps1.deobfuscation.unused

Remove unused variable assignments and junk expression statements.

Expand source code Browse git
"""
Remove unused variable assignments and junk expression statements.
"""
from __future__ import annotations

from typing import NamedTuple

from refinery.lib.scripts import Node, Transformer
from refinery.lib.scripts.ps1.analysis.cache import model_cache
from refinery.lib.scripts.ps1.analysis.callgraph import Ps1CallGraph
from refinery.lib.scripts.ps1.analysis.effects import (
    OutputSink,
    StatementEffect,
    body_is_inert,
    is_fault_free,
    is_side_effect_free,
    opens_a_redirection_target,
    output_path,
    pruning_erases_body,
    statement_effect,
    unconsumed_statement,
)
from refinery.lib.scripts.ps1.analysis.model import Binding, Ps1SemanticModel, Scope
from refinery.lib.scripts.ps1.analysis.types import TypeOracle
from refinery.lib.scripts.ps1.ast import (
    assignment_of,
    assignment_target_is_all_variables,
    assignment_target_variables,
    get_body,
    normalize_command_name,
)
from refinery.lib.scripts.ps1.deobfuscation.constants import (
    _PS1_SKIP_VARIABLES,
    _find_removable_statement,
)
from refinery.lib.scripts.ps1.deobfuscation.helpers import store_dropped_to_value
from refinery.lib.scripts.ps1.deobfuscation.options import bare_output_is_preserved
from refinery.lib.scripts.ps1.deobfuscation.removal import Ps1RemovalPlan, Ps1RemovalPlans
from refinery.lib.scripts.ps1.model import (
    Expression,
    Ps1AssignmentExpression,
    Ps1ExpressionStatement,
    Ps1ForLoop,
    Ps1FunctionDefinition,
    Ps1ScopeModifier,
    Ps1Script,
    Ps1UnaryExpression,
    Ps1Variable,
)


def _writes_only_what_cannot_fault(stmt: Node) -> bool:
    """
    Whether a statement writes a value to the output stream and can do nothing else — not even
    raise. `refinery.lib.scripts.ps1.analysis.effects.StatementEffect.OUTPUT` says the first half
    and deliberately says nothing about the second, so a caller about to delete such a statement
    asks the fault question here.
    """
    return (
        isinstance(stmt, Ps1ExpressionStatement)
        and stmt.expression is not None
        and is_fault_free(stmt.expression)
    )


class _MutationEdit(NamedTuple):
    """
    One intended edit to a dead store: the statement to be removed, and the right-hand side that has
    to be kept as a discard because evaluating it does something, or `None` when it does not.
    """
    statement: Node
    keep_value: Expression | None


def _value_is_movable(rhs: Node) -> bool:
    """
    Whether a dead store's right-hand side can be kept as a statement of its own.

    A right-hand side that is a *statement* rather than an expression — `$x = if ($c) { ... }`, and
    the `switch`/`foreach`/`while`/`try` forms beside it, all legal assignment sources — has no
    expression-statement spelling to be moved into, so the whole assignment stays. Removing it
    instead would take the branch bodies with it.

    Both passes that drop a dead store share this, rather than each spelling the rule: the two
    outcomes are keep-the-value and keep-the-statement, and a pass that learned only one of them
    would delete what the other keeps.
    """
    return isinstance(rhs, Expression)


class Ps1UnusedVariableRemoval(Transformer):
    """
    Remove assignments to variables that are never read anywhere in the outer scope. Liveness comes
    from the shared `refinery.lib.scripts.ps1.analysis.model.Ps1SemanticModel`, so a read that
    reaches the assignment through a nested function, a captured scriptblock, or a scope qualifier
    keeps it alive. When the right-hand side of a removable assignment has side effects, the
    assignment wrapper is stripped but the expression is preserved as a standalone statement.
    """

    def visit(self, node: Node):
        """
        Plan every candidate edit, ask the batch what it would accept, and only then decide which
        bindings are dead.

        The order matters and used to run the other way. A binding is dead because its every read
        sits inside a right-hand side that is going away, so liveness is *restrictive* in the
        survivor set and has to be asked of what the batch will actually do — see
        `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan.accepted`. Two things keep a
        right-hand side standing, and neither is visible before the edits are planned: a value that
        does something survives as `$Null = <value>`, and a statement the fault veto declines to
        delete survives whole. Deciding first and planning afterwards read both as erased and
        deleted the assignment the surviving read needs.

        Planning up front means the replacements exist before the batch is known to land, which
        costs this pass nothing: registering one gives back whatever it adopted, so the tree the
        liveness question is asked of is the tree as written, and a batch that ends in `abandon` —
        or is simply dropped — owes it nothing.
        """
        cache = model_cache(self, node)
        model = cache.model
        oracle = cache.oracle
        candidates: dict[Binding, list[Node]] = {}
        for binding in model.script_scope.bindings.values():
            if binding.dynamic_or_qualified or binding.name in _PS1_SKIP_VARIABLES:
                continue
            mutations = self._removable_mutations(binding)
            if mutations:
                candidates[binding] = mutations
        if not candidates:
            return None
        plans = Ps1RemovalPlans()
        planned: dict[int, _MutationEdit] = {}
        installed: set[int] = set()
        claimed: set[int] = set()
        for mutations in candidates.values():
            for mutation in mutations:
                if id(mutation) in planned:
                    continue
                edit = self._plan_mutation(mutation, oracle)
                if edit is None or id(edit.statement) in claimed:
                    continue
                replacement = None
                if edit.keep_value is not None:
                    replacement = [store_dropped_to_value(edit.keep_value)]
                if plans.propose(edit.statement, replacement):
                    claimed.add(id(edit.statement))
                    planned[id(mutation)] = edit
                    if replacement is not None:
                        installed.update(id(stmt) for stmt in replacement)
        accepted = {id(statement) for statement in plans.accepted}
        dissolving = {
            key for key, edit in planned.items()
            if edit.keep_value is None and id(edit.statement) in accepted
        }
        dead = self._dead_bindings(candidates, dissolving)
        removable = self._removable_statements(candidates, dead, model) if dead else []
        keep = {id(mutation) for mutation in removable}
        for key, edit in planned.items():
            if key not in keep:
                plans.withdraw(edit.statement)
        if not removable:
            plans.abandon()
            return None
        if get_body(node) is not None and not self._leaves_anything(plans, node, installed):
            plans.abandon()
            return None
        if plans.commit():
            self.mark_changed()

    @staticmethod
    def _leaves_anything(plans: Ps1RemovalPlans, node: Node, installed: set[int]) -> bool:
        """
        Whether the script still says something of its own once this batch lands.

        A pass may not reduce a whole script to nothing, for the reason `_remove_inert_functions`
        spells out: what is left would be a module whose callers the walk never read, and the
        definitions that would prove it live are exactly what is out of reach. Definitions are
        therefore not survivors here.

        Neither is a `$Null = <value>` discard this pass is installing. It preserves a side effect
        and it is genuinely observable, but it is **this pass's own residue**, and a guard against
        erasure that counts its own output as proof it erased nothing answers itself. A script
        reduced to nothing but discards has had everything it said deleted; that the deletions left
        husks behind is not evidence to the contrary.
        """
        surviving = plans.survivors(node)
        return any(
            not isinstance(stmt, Ps1FunctionDefinition) and id(stmt) not in installed
            for stmt in surviving
        )

    @staticmethod
    def _removable_mutations(binding: Binding) -> list[Node]:
        """
        The removable mutation nodes that write `binding`: a bare (unqualified) or `$env:`
        assignment or a `++`/`--` update. A parameter or `foreach` loop variable writes the binding
        but is not a removable mutation, and a scope-qualified write (`$script:x = ...`) is never
        removed, so both are excluded.
        """
        mutations: list[Node] = []
        seen: set[int] = set()
        for write in binding.writes:
            var = write.node
            if not isinstance(var, Ps1Variable):
                continue
            if var.scope not in (Ps1ScopeModifier.NONE, Ps1ScopeModifier.ENV):
                continue
            mutation = Ps1UnusedVariableRemoval._mutation_of(var)
            if mutation is not None and id(mutation) not in seen:
                seen.add(id(mutation))
                mutations.append(mutation)
        return mutations

    @staticmethod
    def _mutation_of(var: Ps1Variable) -> Node | None:
        """
        The node this pass would remove to undo the write `var` performs, or `None` when the write
        is one it cannot remove.

        A `[ref]$x` is such a write and must stay `None`: the store happens inside a callee, so
        there is nothing here to delete, and the enclosing call is not this pass's to touch. That
        also makes the reference a use nothing discounts, which is what keeps the assignment it
        stores into alive.
        """
        assignment = assignment_of(var)
        if assignment is not None:
            return assignment
        parent = var.parent
        if isinstance(parent, Ps1UnaryExpression) and parent.operator in ('++', '--'):
            if parent.operand is var:
                return parent
        return None

    def _dead_bindings(
        self, candidates: dict[Binding, list[Node]], dissolving: set[int],
    ) -> list[Binding]:
        """
        From candidate bindings mapped to their removable mutations, return those that are dead. A
        binding is live if it has a use not contained in the right-hand side of any *dissolving*
        assignment — a use in live code, in a live function, or a captured scriptblock. Liveness
        propagates back along right-hand sides: if a live binding's assignment reads another
        candidate, that candidate is live too. The rest, whose every read sits inside the right-hand
        side of an assignment that is itself dead, are dead — removing those assignments removes the
        reads, so nothing observes the value.

        `dissolving` holds the mutations whose planned edit really does take their right-hand side
        with it. A right-hand side that survives — as the value of a `$Null = <value>` discard, or
        because the fault veto declined to delete the whole statement — still holds its reads, so
        the bindings they name stay live and their own assignments stay. Reading every candidate as
        dissolving is what left `$Null = Start-Process -FilePath $a` beside no `$a`.

        The veto is not the only thing that keeps an assignment standing, so this asks the same
        question `_all_targets_dead` asks. A target such as `$a, $arr[0]` names exactly one variable
        and is still never removable, because the slot beside it writes memory this pass reasons
        nothing about; a right-hand side that is only ever going away when that binding dies is a
        right-hand side that is never going away at all.

        The uses are `Binding.uses`, not `Binding.reads`: a write that observes the previous value
        reads the binding as surely as anything in `reads` and is filed among the writes. Such a use
        is discounted when the mutation performing it is itself dissolving, on the same reasoning as
        a read inside a dissolving right-hand side — the store consumes the read, so removing the
        store removes the read, and `$x = 1; $x += 2` that nothing else reads is dead whole. A
        `[ref]$x` has no mutation this pass can remove, so it is never discounted and always keeps
        its binding live.
        """
        bindings = list(candidates)
        rhs_owner: dict[int, Binding] = {}
        for binding, mutations in candidates.items():
            for mutation in mutations:
                if id(mutation) not in dissolving:
                    continue
                if not isinstance(mutation, Ps1AssignmentExpression) or mutation.value is None:
                    continue
                if not assignment_target_is_all_variables(mutation.target):
                    continue
                if len(assignment_target_variables(mutation.target)) == 1:
                    rhs_owner[id(mutation.value)] = binding
        readers: dict[Binding, set[Binding]] = {binding: set() for binding in bindings}
        live: set[Binding] = set()
        for binding in bindings:
            for use in binding.uses:
                mutation = self._mutation_of(use.node)
                if mutation is not None and id(mutation) in dissolving:
                    continue
                owner = self._covering_owner(use.node, rhs_owner)
                if owner is None or owner is binding:
                    live.add(binding)
                else:
                    readers[binding].add(owner)
        changed = True
        while changed:
            changed = False
            for binding in bindings:
                if binding not in live and readers[binding] & live:
                    live.add(binding)
                    changed = True
        return [binding for binding in bindings if binding not in live]

    @staticmethod
    def _covering_owner(node: Node, rhs_owner: dict[int, Binding]) -> Binding | None:
        """
        The candidate binding whose assignment right-hand side encloses *node*, taken at the
        outermost such right-hand side, or `None` when *node* lies outside every candidate
        right-hand side.
        """
        owner: Binding | None = None
        cursor: Node | None = node
        while cursor is not None:
            found = rhs_owner.get(id(cursor))
            if found is not None:
                owner = found
            cursor = cursor.parent
        return owner

    def _removable_statements(
        self, candidates: dict[Binding, list[Node]], dead: list[Binding], model: Ps1SemanticModel,
    ) -> list[Node]:
        dead_set = set(dead)
        removable: list[Node] = []
        seen: set[int] = set()
        for binding in dead:
            for mutation in candidates[binding]:
                if id(mutation) in seen:
                    continue
                if (
                    isinstance(mutation, Ps1AssignmentExpression)
                    and not self._all_targets_dead(mutation, model, dead_set)
                ):
                    continue
                seen.add(id(mutation))
                removable.append(mutation)
        return removable

    @staticmethod
    def _all_targets_dead(
        assign: Ps1AssignmentExpression, model: Ps1SemanticModel, dead: set[Binding],
    ) -> bool:
        """
        Whether every variable written by `assign` is dead. A multi-assignment such as
        `$a, $b = 1, 2` is only removable when all of its targets are dead; removing it while a
        co-target is still live would destroy that live write. A target that contains a non-variable
        slot (e.g. `$arr[0], $b`) writes to memory beyond the named variables and is never
        considered fully dead.
        """
        if not assignment_target_is_all_variables(assign.target):
            return False
        variables = assignment_target_variables(assign.target)
        if not variables:
            return False
        for var in variables:
            binding = model.binding_of(var)
            if binding is None or binding not in dead:
                return False
        return True

    @staticmethod
    def _plan_mutation(mutation: Node, oracle: TypeOracle) -> _MutationEdit | None:
        """
        What this pass intends to do with the statement holding `mutation`, decided without touching
        the tree: the statement to edit, and the right-hand side that has to survive the edit, or
        `None` where the store can go whole.

        Deciding before building matters because the caller may still abandon the batch. Whether the
        value can be moved is therefore answered here, by `_value_is_movable`, rather than by trying
        to build the replacement and reading the failure.
        """
        keep_value = None
        if isinstance(mutation, Ps1AssignmentExpression):
            rhs = mutation.value
            if rhs is not None and not is_side_effect_free(rhs, oracle):
                if not _value_is_movable(rhs):
                    return None
                keep_value = rhs
        elif not isinstance(mutation, Ps1UnaryExpression):
            return None
        stmt = _find_removable_statement(mutation)
        if stmt is None:
            return None
        return _MutationEdit(stmt, keep_value)


class Ps1JunkStatementRemoval(Transformer):
    """
    Remove standalone expression statements that produce no observable side effects (junk/noise
    injected for anti-analysis) and function definitions that are never called.
    """

    def visit(self, node: Node):
        """
        Decide the whole batch, then apply it once.

        **Two different sinks are read here and they answer two different questions.** Whether a
        body may be reasoned about at all is positional: `output_path` calls a stored closure or a
        `$( ... )` opaque, and this pass has never touched one. Whether a *write to the output
        stream* inside it may be deleted needs the destination that write reaches, which position
        cannot supply for a function body, so `Ps1OutputFlow` resolves it across the call graph.

        One outward walk answers both. The positional `Ps1OutputPath` is what the resolution reads,
        so asking the two questions separately walked every ancestor chain twice for one answer.

        Collapsing the two costs recall and did: a body the flow reports captured — a function whose
        result someone stores — still holds `$Null = 1` discards that write nothing at all, and
        skipping it over the output question left them standing.
        """
        cache = model_cache(self, node)
        oracle = cache.oracle
        flow = cache.output_flow
        called = cache.call_graph.reachable_names()
        plans = Ps1RemovalPlans()
        for parent in node.walk():
            body = get_body(parent)
            if body is None:
                continue
            path = output_path(parent)
            if path.sink is OutputSink.CAPTURED:
                continue
            removable = self._removable_in_body(parent, flow.resolved(path), called, oracle)
            for statement in body:
                if statement in removable:
                    plans.propose_in(parent, statement)
        if plans.commit():
            self.mark_changed()
        self._remove_inert_functions(node, cache.call_graph, oracle)

    def _remove_inert_functions(self, node: Node, graph: Ps1CallGraph, oracle: TypeOracle):
        """
        Remove top-level functions whose body carries no observable output or side effect together
        with the bare call statements that invoke them. After body pruning, an injected junk function
        such as `function j { $Null = 915 }` has an empty body; calling it is a no-op, so the
        definition and its call sites drop out as a unit. Only whole-statement, argument-free
        invocations count as call sites — a name referenced any other way is a name whose value
        could be observed.

        A name is inert only when *every* definition of it is, since the calls are attributed to the
        name rather than to one of its definitions: removing them because one definition is empty
        would silence a payload-bearing other one. `Ps1CallGraph` reports every definition, wherever
        it sits, because a `function` inside an `if` or a loop body is written into the enclosing
        scope just the same — while only a top-level definition is itself removable, since a nested
        one is not this pass's to reason about.

        "Every definition" can only mean every definition standing in this tree, so the tree has to
        be the whole story. That is the graph's own `is_readable`, which this pass no longer answers
        for itself: an open world, an opaque dispatch, an identity-namespace assignment and an
        export all bind a name from somewhere the walk cannot read, and the empty body standing here
        is then not the body the call reaches.

        The graph is built in source order, because removal is by identity scan over the containing
        list: taking the reverse order `Node.walk` yields would delete from the back and make every
        scan traverse the whole body.

        Removals are collected before any is applied so that `pruning_erases_body` sees the whole
        set at once. A script of nothing but inert definitions is inert by every measure this pass
        has and still may not be emptied — it is a module whose functions are dot-sourced from a
        caller the walk never read, and the call sites that would prove them live are exactly what
        is out of reach. The same invariant already governs `_removable_in_body`; enforcing it there
        and not here left one removal site able to erase what the other refuses to.

        Only the definitions are weighed against that invariant, because only they are what a
        dot-sourcing caller would come for. A script holding a call site of its own is not the
        module the guard protects — it uses the function here — so `function j { $Null = 1 }` beside
        a bare `j` reduces to nothing, while the same definition standing alone survives.

        `fault_is_observed` is applied for the same reason it is applied when a body is pruned:
        emptying a `try` body beside a handler that does something is what makes the handler read as
        unreachable. Both removal sites have to answer that question the same way, and this one
        did not.

        A definition and the calls to it are one edit, and they routinely land in *different* plans,
        so no single plan's `all_or_nothing` can hold them together. The batch is therefore planned
        whole and then asked what it would accept: a name any part of which is declined is withdrawn
        entirely, because half the edit is not a smaller edit. Deleting `function j` while a veto
        keeps `try { j }` leaves the script calling a function it no longer defines, and deleting
        the call while the definition is kept leaves a definition the emitted script never reaches.
        The withdrawals are read back to a fixpoint rather than applied once, so a group broken by
        another group's withdrawal is caught too; it terminates because the batch only shrinks.
        `pruning_erases_body` is asked *before* any of this, against the full set, because it is
        permissive in the survivors and a withdrawal is a veto by another name.
        """
        if not graph.is_readable:
            return
        groups: dict[str, list[Node]] = {}
        removable_definitions: set[Node] = set()
        for key in graph.defined_names:
            definitions = graph.definitions(key)
            if not all(body_is_inert(d.body, oracle) for d in definitions):
                continue
            removable_here = [d for d in definitions if d.parent is node]
            if not removable_here:
                continue
            call_statements = self._inert_call_statements(graph, key)
            if call_statements is None:
                continue
            groups[key] = [*call_statements, *removable_here]
            removable_definitions.update(removable_here)
        if not groups:
            return
        if get_body(node) is not None:
            survivors = self._survivors(get_body(node), removable_definitions)
            if pruning_erases_body(node, survivors):
                return
        plans = Ps1RemovalPlans()
        for group in groups.values():
            for statement in group:
                plans.propose(statement)
        while True:
            accepted = {id(statement) for statement in plans.accepted}
            broken = [
                key for key, group in groups.items()
                if any(id(statement) not in accepted for statement in group)
            ]
            if not broken:
                break
            for key in broken:
                for statement in groups.pop(key):
                    plans.withdraw(statement)
        if plans.commit():
            self.mark_changed()

    @staticmethod
    def _inert_call_statements(graph: Ps1CallGraph, key: str) -> list[Node] | None:
        """
        The statements that call `key` and nothing else, or `None` when some reference to the name
        is not one — which means the name and every definition of it stay.

        A call site qualifies only when it is argument-free, its value is what the enclosing
        statement yields, and nothing on the way to that statement opens a file. The two redirection
        questions are separate and both have to be asked: `j > out.txt` sends the value somewhere
        the body cannot see it, which `unconsumed_statement` answers, and `j 2> err.txt` sends
        nothing anywhere while still creating the file, which only `opens_a_redirection_target`
        catches. The call is inert either way; the statement around it is not.
        """
        statements: list[Node] = []
        for site in graph.call_sites(key):
            statement = unconsumed_statement(site.invocation)
            if statement is None or site.invocation.arguments:
                return None
            cursor: Node | None = site.invocation
            while cursor is not None and cursor is not statement:
                if opens_a_redirection_target(cursor):
                    return None
                cursor = cursor.parent
            statements.append(statement)
        return statements

    def _removable_in_body(
        self, parent: Node, sink: OutputSink, called: frozenset[str], oracle: TypeOracle,
    ) -> set[Node]:
        """
        What this pass would drop from the statement list `parent` owns, where `sink` is the
        *resolved* destination of what that body writes. Both set-level guards are answered here,
        against the **pre-veto** survivors, which is the polarity they require — see
        `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan`.

        A `DISCARD` emits nothing and is always safe to drop wherever the body's output goes, even
        when it empties the body; that is what turns a junk function inert. An `OUTPUT` writes a
        value someone could see, and dropping it needs three separate things to hold, none of which
        implies another:

        - the caller asks for it, which is `bare_output_is_preserved` read the other way round;
        - the value provably reaches the host and nothing else, which is `sink`. A body whose writes
          land anywhere else keeps every one of them — that is the whole of what the resolution
          across the call graph buys, and the reason this is not the positional answer;
        - evaluating it cannot raise, which is `is_fault_free`. `[Int]'abc'` and `1/0` are `OUTPUT`
          like `42` is, and both terminate the script where they stand, so deleting one resumes
          execution that had stopped — and does it across a function boundary, where the `try` veto
          in `Ps1RemovalPlan` cannot see.
        """
        body = get_body(parent)
        removable: set[Node] = set()
        strip_bare_output = sink is OutputSink.HOST and not bare_output_is_preserved(self.options)
        for stmt in body:
            if isinstance(stmt, Ps1FunctionDefinition):
                # `called` is every call site standing in this tree, which is only the whole story
                # while the tree is the whole script. When the world is open, a dot-sourced file, an
                # imported module or an `iex` holds call sites the walk never read, so a definition
                # with none here is not unreachable — it is reachable from somewhere unreadable.
                if (
                    isinstance(parent, Ps1Script)
                    and oracle.world_closed_at(stmt)
                    and normalize_command_name(stmt.name) not in called
                ):
                    removable.add(stmt)
                continue
            effect = statement_effect(stmt, oracle)
            if effect is StatementEffect.DISCARD:
                removable.add(stmt)
            elif effect is StatementEffect.OUTPUT and strip_bare_output:
                if _writes_only_what_cannot_fault(stmt):
                    removable.add(stmt)
        if not removable:
            return set()
        if pruning_erases_body(parent, self._survivors(body, removable)):
            return set()
        return removable

    @staticmethod
    def _survivors(body: list, removable: set[Node]) -> list[Node]:
        return [stmt for stmt in body if stmt not in removable]


class Ps1DeadStoreElimination(Transformer):
    """
    Remove assignments to a variable that are provably overwritten before their next read within the
    same linear scope. This targets the pattern left behind by tier-2 empty-for rewriting: dozens of
    `$i = N` statements followed by a for-loop whose initializer `$i = 0` overwrites them all. Reads
    come from the shared `refinery.lib.scripts.ps1.analysis.model.Ps1SemanticModel`, so a store read
    only through a nested scriptblock is correctly seen as live rather than skipped.
    """

    def __init__(self):
        super().__init__()
        self._oracle: TypeOracle | None = None

    def visit(self, node: Node):
        cache = model_cache(self, node)
        model = cache.model
        # The model is re-read per body, because removing a store changes what the next body's scope
        # says; the world behind the oracle is not, because it is a whole-script fact and this pass
        # only removes stores. Re-reading it would rebuild the whole-tree walk after every removal
        # to reach a verdict that can only have become *more* closed, and the captured one — taken
        # before those removals — is the more open, and so more conservative, of the two.
        if self._oracle is None:
            self._oracle = cache.oracle
        oracle = self._oracle
        body = get_body(node)
        scope = model.scope_of(node)
        if body is None or scope is None:
            self.generic_visit(node)
            return None
        pending: dict[str, list[Ps1ExpressionStatement]] = {}
        dead: list[Ps1ExpressionStatement] = []
        has_read: set[str] = set()
        for stmt in body:
            reads, kills, writes = self._classify_statement(stmt, scope, model)
            for var in kills:
                if var in pending:
                    dead.extend(pending.pop(var))
                has_read.add(var)
            for var in reads:
                pending.pop(var, None)
                has_read.add(var)
            for var, store in writes:
                if var in pending:
                    dead.extend(pending[var])
                pending[var] = [store]
        if isinstance(node, Ps1Script):
            for var, stores in pending.items():
                if var in has_read:
                    dead.extend(stores)
        if not dead:
            self.generic_visit(node)
            return None
        plan = Ps1RemovalPlan(node)
        for stmt in dead:
            if not isinstance(stmt, Ps1ExpressionStatement):
                continue
            rhs = stmt.expression
            if isinstance(rhs, Ps1AssignmentExpression):
                rhs = rhs.value
            if rhs is None or is_side_effect_free(rhs, oracle):
                plan.propose(stmt)
            elif _value_is_movable(rhs):
                plan.propose(stmt, [store_dropped_to_value(rhs)])
        if plan.commit():
            self.mark_changed()
        self.generic_visit(node)

    def _classify_statement(
        self, stmt, scope: Scope, model: Ps1SemanticModel,
    ) -> tuple[set[str], set[str], list[tuple[str, Ps1ExpressionStatement]]]:
        """
        Return `(reads, kills, writes)` for a top-level statement:
        - `reads`: variable keys read by the statement (flush: pending writes are needed).
        - `kills`: variable keys whose previous pending writes are dead (overwritten before read).
        - `writes`: list of `(key, statement)` pairs for pure assignments to register as pending.

        A for-loop's initializer kills previous stores without itself becoming a pending write
        (the for-loop is not a removable expression statement). Other control-flow constructs
        contribute all internal variable references as reads and produce no kills/writes.
        """
        reads: set[str] = set()
        kills: set[str] = set()
        writes: list[tuple[str, Ps1ExpressionStatement]] = []
        if isinstance(stmt, Ps1ExpressionStatement) and isinstance(
            stmt.expression, Ps1AssignmentExpression
        ):
            assign = stmt.expression
            if (
                assign.operator == '='
                and isinstance(assign.target, Ps1Variable)
                and assign.target.scope is Ps1ScopeModifier.NONE
            ):
                key = assign.target.name.lower()
                if key not in _PS1_SKIP_VARIABLES:
                    if assign.value is not None:
                        reads |= model.reads_in_scope(assign.value, scope)
                    writes.append((key, stmt))
                    return reads, kills, writes
        if isinstance(stmt, Ps1ForLoop):
            if (
                isinstance(stmt.initializer, Ps1AssignmentExpression)
                and stmt.initializer.operator == '='
                and isinstance(stmt.initializer.target, Ps1Variable)
                and stmt.initializer.target.scope is Ps1ScopeModifier.NONE
            ):
                key = stmt.initializer.target.name.lower()
                if key not in _PS1_SKIP_VARIABLES:
                    init_rhs_reads: set[str] = set()
                    if stmt.initializer.value is not None:
                        init_rhs_reads = model.reads_in_scope(stmt.initializer.value, scope)
                    if key in init_rhs_reads:
                        reads.update(init_rhs_reads)
                    else:
                        kills.add(key)
        reads |= model.variables_in_scope(stmt, scope)
        return reads, kills, writes

Classes

class Ps1UnusedVariableRemoval

Remove assignments to variables that are never read anywhere in the outer scope. Liveness comes from the shared Ps1SemanticModel, so a read that reaches the assignment through a nested function, a captured scriptblock, or a scope qualifier keeps it alive. When the right-hand side of a removable assignment has side effects, the assignment wrapper is stripped but the expression is preserved as a standalone statement.

Expand source code Browse git
class Ps1UnusedVariableRemoval(Transformer):
    """
    Remove assignments to variables that are never read anywhere in the outer scope. Liveness comes
    from the shared `refinery.lib.scripts.ps1.analysis.model.Ps1SemanticModel`, so a read that
    reaches the assignment through a nested function, a captured scriptblock, or a scope qualifier
    keeps it alive. When the right-hand side of a removable assignment has side effects, the
    assignment wrapper is stripped but the expression is preserved as a standalone statement.
    """

    def visit(self, node: Node):
        """
        Plan every candidate edit, ask the batch what it would accept, and only then decide which
        bindings are dead.

        The order matters and used to run the other way. A binding is dead because its every read
        sits inside a right-hand side that is going away, so liveness is *restrictive* in the
        survivor set and has to be asked of what the batch will actually do — see
        `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan.accepted`. Two things keep a
        right-hand side standing, and neither is visible before the edits are planned: a value that
        does something survives as `$Null = <value>`, and a statement the fault veto declines to
        delete survives whole. Deciding first and planning afterwards read both as erased and
        deleted the assignment the surviving read needs.

        Planning up front means the replacements exist before the batch is known to land, which
        costs this pass nothing: registering one gives back whatever it adopted, so the tree the
        liveness question is asked of is the tree as written, and a batch that ends in `abandon` —
        or is simply dropped — owes it nothing.
        """
        cache = model_cache(self, node)
        model = cache.model
        oracle = cache.oracle
        candidates: dict[Binding, list[Node]] = {}
        for binding in model.script_scope.bindings.values():
            if binding.dynamic_or_qualified or binding.name in _PS1_SKIP_VARIABLES:
                continue
            mutations = self._removable_mutations(binding)
            if mutations:
                candidates[binding] = mutations
        if not candidates:
            return None
        plans = Ps1RemovalPlans()
        planned: dict[int, _MutationEdit] = {}
        installed: set[int] = set()
        claimed: set[int] = set()
        for mutations in candidates.values():
            for mutation in mutations:
                if id(mutation) in planned:
                    continue
                edit = self._plan_mutation(mutation, oracle)
                if edit is None or id(edit.statement) in claimed:
                    continue
                replacement = None
                if edit.keep_value is not None:
                    replacement = [store_dropped_to_value(edit.keep_value)]
                if plans.propose(edit.statement, replacement):
                    claimed.add(id(edit.statement))
                    planned[id(mutation)] = edit
                    if replacement is not None:
                        installed.update(id(stmt) for stmt in replacement)
        accepted = {id(statement) for statement in plans.accepted}
        dissolving = {
            key for key, edit in planned.items()
            if edit.keep_value is None and id(edit.statement) in accepted
        }
        dead = self._dead_bindings(candidates, dissolving)
        removable = self._removable_statements(candidates, dead, model) if dead else []
        keep = {id(mutation) for mutation in removable}
        for key, edit in planned.items():
            if key not in keep:
                plans.withdraw(edit.statement)
        if not removable:
            plans.abandon()
            return None
        if get_body(node) is not None and not self._leaves_anything(plans, node, installed):
            plans.abandon()
            return None
        if plans.commit():
            self.mark_changed()

    @staticmethod
    def _leaves_anything(plans: Ps1RemovalPlans, node: Node, installed: set[int]) -> bool:
        """
        Whether the script still says something of its own once this batch lands.

        A pass may not reduce a whole script to nothing, for the reason `_remove_inert_functions`
        spells out: what is left would be a module whose callers the walk never read, and the
        definitions that would prove it live are exactly what is out of reach. Definitions are
        therefore not survivors here.

        Neither is a `$Null = <value>` discard this pass is installing. It preserves a side effect
        and it is genuinely observable, but it is **this pass's own residue**, and a guard against
        erasure that counts its own output as proof it erased nothing answers itself. A script
        reduced to nothing but discards has had everything it said deleted; that the deletions left
        husks behind is not evidence to the contrary.
        """
        surviving = plans.survivors(node)
        return any(
            not isinstance(stmt, Ps1FunctionDefinition) and id(stmt) not in installed
            for stmt in surviving
        )

    @staticmethod
    def _removable_mutations(binding: Binding) -> list[Node]:
        """
        The removable mutation nodes that write `binding`: a bare (unqualified) or `$env:`
        assignment or a `++`/`--` update. A parameter or `foreach` loop variable writes the binding
        but is not a removable mutation, and a scope-qualified write (`$script:x = ...`) is never
        removed, so both are excluded.
        """
        mutations: list[Node] = []
        seen: set[int] = set()
        for write in binding.writes:
            var = write.node
            if not isinstance(var, Ps1Variable):
                continue
            if var.scope not in (Ps1ScopeModifier.NONE, Ps1ScopeModifier.ENV):
                continue
            mutation = Ps1UnusedVariableRemoval._mutation_of(var)
            if mutation is not None and id(mutation) not in seen:
                seen.add(id(mutation))
                mutations.append(mutation)
        return mutations

    @staticmethod
    def _mutation_of(var: Ps1Variable) -> Node | None:
        """
        The node this pass would remove to undo the write `var` performs, or `None` when the write
        is one it cannot remove.

        A `[ref]$x` is such a write and must stay `None`: the store happens inside a callee, so
        there is nothing here to delete, and the enclosing call is not this pass's to touch. That
        also makes the reference a use nothing discounts, which is what keeps the assignment it
        stores into alive.
        """
        assignment = assignment_of(var)
        if assignment is not None:
            return assignment
        parent = var.parent
        if isinstance(parent, Ps1UnaryExpression) and parent.operator in ('++', '--'):
            if parent.operand is var:
                return parent
        return None

    def _dead_bindings(
        self, candidates: dict[Binding, list[Node]], dissolving: set[int],
    ) -> list[Binding]:
        """
        From candidate bindings mapped to their removable mutations, return those that are dead. A
        binding is live if it has a use not contained in the right-hand side of any *dissolving*
        assignment — a use in live code, in a live function, or a captured scriptblock. Liveness
        propagates back along right-hand sides: if a live binding's assignment reads another
        candidate, that candidate is live too. The rest, whose every read sits inside the right-hand
        side of an assignment that is itself dead, are dead — removing those assignments removes the
        reads, so nothing observes the value.

        `dissolving` holds the mutations whose planned edit really does take their right-hand side
        with it. A right-hand side that survives — as the value of a `$Null = <value>` discard, or
        because the fault veto declined to delete the whole statement — still holds its reads, so
        the bindings they name stay live and their own assignments stay. Reading every candidate as
        dissolving is what left `$Null = Start-Process -FilePath $a` beside no `$a`.

        The veto is not the only thing that keeps an assignment standing, so this asks the same
        question `_all_targets_dead` asks. A target such as `$a, $arr[0]` names exactly one variable
        and is still never removable, because the slot beside it writes memory this pass reasons
        nothing about; a right-hand side that is only ever going away when that binding dies is a
        right-hand side that is never going away at all.

        The uses are `Binding.uses`, not `Binding.reads`: a write that observes the previous value
        reads the binding as surely as anything in `reads` and is filed among the writes. Such a use
        is discounted when the mutation performing it is itself dissolving, on the same reasoning as
        a read inside a dissolving right-hand side — the store consumes the read, so removing the
        store removes the read, and `$x = 1; $x += 2` that nothing else reads is dead whole. A
        `[ref]$x` has no mutation this pass can remove, so it is never discounted and always keeps
        its binding live.
        """
        bindings = list(candidates)
        rhs_owner: dict[int, Binding] = {}
        for binding, mutations in candidates.items():
            for mutation in mutations:
                if id(mutation) not in dissolving:
                    continue
                if not isinstance(mutation, Ps1AssignmentExpression) or mutation.value is None:
                    continue
                if not assignment_target_is_all_variables(mutation.target):
                    continue
                if len(assignment_target_variables(mutation.target)) == 1:
                    rhs_owner[id(mutation.value)] = binding
        readers: dict[Binding, set[Binding]] = {binding: set() for binding in bindings}
        live: set[Binding] = set()
        for binding in bindings:
            for use in binding.uses:
                mutation = self._mutation_of(use.node)
                if mutation is not None and id(mutation) in dissolving:
                    continue
                owner = self._covering_owner(use.node, rhs_owner)
                if owner is None or owner is binding:
                    live.add(binding)
                else:
                    readers[binding].add(owner)
        changed = True
        while changed:
            changed = False
            for binding in bindings:
                if binding not in live and readers[binding] & live:
                    live.add(binding)
                    changed = True
        return [binding for binding in bindings if binding not in live]

    @staticmethod
    def _covering_owner(node: Node, rhs_owner: dict[int, Binding]) -> Binding | None:
        """
        The candidate binding whose assignment right-hand side encloses *node*, taken at the
        outermost such right-hand side, or `None` when *node* lies outside every candidate
        right-hand side.
        """
        owner: Binding | None = None
        cursor: Node | None = node
        while cursor is not None:
            found = rhs_owner.get(id(cursor))
            if found is not None:
                owner = found
            cursor = cursor.parent
        return owner

    def _removable_statements(
        self, candidates: dict[Binding, list[Node]], dead: list[Binding], model: Ps1SemanticModel,
    ) -> list[Node]:
        dead_set = set(dead)
        removable: list[Node] = []
        seen: set[int] = set()
        for binding in dead:
            for mutation in candidates[binding]:
                if id(mutation) in seen:
                    continue
                if (
                    isinstance(mutation, Ps1AssignmentExpression)
                    and not self._all_targets_dead(mutation, model, dead_set)
                ):
                    continue
                seen.add(id(mutation))
                removable.append(mutation)
        return removable

    @staticmethod
    def _all_targets_dead(
        assign: Ps1AssignmentExpression, model: Ps1SemanticModel, dead: set[Binding],
    ) -> bool:
        """
        Whether every variable written by `assign` is dead. A multi-assignment such as
        `$a, $b = 1, 2` is only removable when all of its targets are dead; removing it while a
        co-target is still live would destroy that live write. A target that contains a non-variable
        slot (e.g. `$arr[0], $b`) writes to memory beyond the named variables and is never
        considered fully dead.
        """
        if not assignment_target_is_all_variables(assign.target):
            return False
        variables = assignment_target_variables(assign.target)
        if not variables:
            return False
        for var in variables:
            binding = model.binding_of(var)
            if binding is None or binding not in dead:
                return False
        return True

    @staticmethod
    def _plan_mutation(mutation: Node, oracle: TypeOracle) -> _MutationEdit | None:
        """
        What this pass intends to do with the statement holding `mutation`, decided without touching
        the tree: the statement to edit, and the right-hand side that has to survive the edit, or
        `None` where the store can go whole.

        Deciding before building matters because the caller may still abandon the batch. Whether the
        value can be moved is therefore answered here, by `_value_is_movable`, rather than by trying
        to build the replacement and reading the failure.
        """
        keep_value = None
        if isinstance(mutation, Ps1AssignmentExpression):
            rhs = mutation.value
            if rhs is not None and not is_side_effect_free(rhs, oracle):
                if not _value_is_movable(rhs):
                    return None
                keep_value = rhs
        elif not isinstance(mutation, Ps1UnaryExpression):
            return None
        stmt = _find_removable_statement(mutation)
        if stmt is None:
            return None
        return _MutationEdit(stmt, keep_value)

Ancestors

Methods

def visit(self, node)

Plan every candidate edit, ask the batch what it would accept, and only then decide which bindings are dead.

The order matters and used to run the other way. A binding is dead because its every read sits inside a right-hand side that is going away, so liveness is restrictive in the survivor set and has to be asked of what the batch will actually do — see Ps1RemovalPlan.accepted. Two things keep a right-hand side standing, and neither is visible before the edits are planned: a value that does something survives as $Null = <value>, and a statement the fault veto declines to delete survives whole. Deciding first and planning afterwards read both as erased and deleted the assignment the surviving read needs.

Planning up front means the replacements exist before the batch is known to land, which costs this pass nothing: registering one gives back whatever it adopted, so the tree the liveness question is asked of is the tree as written, and a batch that ends in abandon — or is simply dropped — owes it nothing.

Expand source code Browse git
def visit(self, node: Node):
    """
    Plan every candidate edit, ask the batch what it would accept, and only then decide which
    bindings are dead.

    The order matters and used to run the other way. A binding is dead because its every read
    sits inside a right-hand side that is going away, so liveness is *restrictive* in the
    survivor set and has to be asked of what the batch will actually do — see
    `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan.accepted`. Two things keep a
    right-hand side standing, and neither is visible before the edits are planned: a value that
    does something survives as `$Null = <value>`, and a statement the fault veto declines to
    delete survives whole. Deciding first and planning afterwards read both as erased and
    deleted the assignment the surviving read needs.

    Planning up front means the replacements exist before the batch is known to land, which
    costs this pass nothing: registering one gives back whatever it adopted, so the tree the
    liveness question is asked of is the tree as written, and a batch that ends in `abandon` —
    or is simply dropped — owes it nothing.
    """
    cache = model_cache(self, node)
    model = cache.model
    oracle = cache.oracle
    candidates: dict[Binding, list[Node]] = {}
    for binding in model.script_scope.bindings.values():
        if binding.dynamic_or_qualified or binding.name in _PS1_SKIP_VARIABLES:
            continue
        mutations = self._removable_mutations(binding)
        if mutations:
            candidates[binding] = mutations
    if not candidates:
        return None
    plans = Ps1RemovalPlans()
    planned: dict[int, _MutationEdit] = {}
    installed: set[int] = set()
    claimed: set[int] = set()
    for mutations in candidates.values():
        for mutation in mutations:
            if id(mutation) in planned:
                continue
            edit = self._plan_mutation(mutation, oracle)
            if edit is None or id(edit.statement) in claimed:
                continue
            replacement = None
            if edit.keep_value is not None:
                replacement = [store_dropped_to_value(edit.keep_value)]
            if plans.propose(edit.statement, replacement):
                claimed.add(id(edit.statement))
                planned[id(mutation)] = edit
                if replacement is not None:
                    installed.update(id(stmt) for stmt in replacement)
    accepted = {id(statement) for statement in plans.accepted}
    dissolving = {
        key for key, edit in planned.items()
        if edit.keep_value is None and id(edit.statement) in accepted
    }
    dead = self._dead_bindings(candidates, dissolving)
    removable = self._removable_statements(candidates, dead, model) if dead else []
    keep = {id(mutation) for mutation in removable}
    for key, edit in planned.items():
        if key not in keep:
            plans.withdraw(edit.statement)
    if not removable:
        plans.abandon()
        return None
    if get_body(node) is not None and not self._leaves_anything(plans, node, installed):
        plans.abandon()
        return None
    if plans.commit():
        self.mark_changed()

Inherited members

class Ps1JunkStatementRemoval

Remove standalone expression statements that produce no observable side effects (junk/noise injected for anti-analysis) and function definitions that are never called.

Expand source code Browse git
class Ps1JunkStatementRemoval(Transformer):
    """
    Remove standalone expression statements that produce no observable side effects (junk/noise
    injected for anti-analysis) and function definitions that are never called.
    """

    def visit(self, node: Node):
        """
        Decide the whole batch, then apply it once.

        **Two different sinks are read here and they answer two different questions.** Whether a
        body may be reasoned about at all is positional: `output_path` calls a stored closure or a
        `$( ... )` opaque, and this pass has never touched one. Whether a *write to the output
        stream* inside it may be deleted needs the destination that write reaches, which position
        cannot supply for a function body, so `Ps1OutputFlow` resolves it across the call graph.

        One outward walk answers both. The positional `Ps1OutputPath` is what the resolution reads,
        so asking the two questions separately walked every ancestor chain twice for one answer.

        Collapsing the two costs recall and did: a body the flow reports captured — a function whose
        result someone stores — still holds `$Null = 1` discards that write nothing at all, and
        skipping it over the output question left them standing.
        """
        cache = model_cache(self, node)
        oracle = cache.oracle
        flow = cache.output_flow
        called = cache.call_graph.reachable_names()
        plans = Ps1RemovalPlans()
        for parent in node.walk():
            body = get_body(parent)
            if body is None:
                continue
            path = output_path(parent)
            if path.sink is OutputSink.CAPTURED:
                continue
            removable = self._removable_in_body(parent, flow.resolved(path), called, oracle)
            for statement in body:
                if statement in removable:
                    plans.propose_in(parent, statement)
        if plans.commit():
            self.mark_changed()
        self._remove_inert_functions(node, cache.call_graph, oracle)

    def _remove_inert_functions(self, node: Node, graph: Ps1CallGraph, oracle: TypeOracle):
        """
        Remove top-level functions whose body carries no observable output or side effect together
        with the bare call statements that invoke them. After body pruning, an injected junk function
        such as `function j { $Null = 915 }` has an empty body; calling it is a no-op, so the
        definition and its call sites drop out as a unit. Only whole-statement, argument-free
        invocations count as call sites — a name referenced any other way is a name whose value
        could be observed.

        A name is inert only when *every* definition of it is, since the calls are attributed to the
        name rather than to one of its definitions: removing them because one definition is empty
        would silence a payload-bearing other one. `Ps1CallGraph` reports every definition, wherever
        it sits, because a `function` inside an `if` or a loop body is written into the enclosing
        scope just the same — while only a top-level definition is itself removable, since a nested
        one is not this pass's to reason about.

        "Every definition" can only mean every definition standing in this tree, so the tree has to
        be the whole story. That is the graph's own `is_readable`, which this pass no longer answers
        for itself: an open world, an opaque dispatch, an identity-namespace assignment and an
        export all bind a name from somewhere the walk cannot read, and the empty body standing here
        is then not the body the call reaches.

        The graph is built in source order, because removal is by identity scan over the containing
        list: taking the reverse order `Node.walk` yields would delete from the back and make every
        scan traverse the whole body.

        Removals are collected before any is applied so that `pruning_erases_body` sees the whole
        set at once. A script of nothing but inert definitions is inert by every measure this pass
        has and still may not be emptied — it is a module whose functions are dot-sourced from a
        caller the walk never read, and the call sites that would prove them live are exactly what
        is out of reach. The same invariant already governs `_removable_in_body`; enforcing it there
        and not here left one removal site able to erase what the other refuses to.

        Only the definitions are weighed against that invariant, because only they are what a
        dot-sourcing caller would come for. A script holding a call site of its own is not the
        module the guard protects — it uses the function here — so `function j { $Null = 1 }` beside
        a bare `j` reduces to nothing, while the same definition standing alone survives.

        `fault_is_observed` is applied for the same reason it is applied when a body is pruned:
        emptying a `try` body beside a handler that does something is what makes the handler read as
        unreachable. Both removal sites have to answer that question the same way, and this one
        did not.

        A definition and the calls to it are one edit, and they routinely land in *different* plans,
        so no single plan's `all_or_nothing` can hold them together. The batch is therefore planned
        whole and then asked what it would accept: a name any part of which is declined is withdrawn
        entirely, because half the edit is not a smaller edit. Deleting `function j` while a veto
        keeps `try { j }` leaves the script calling a function it no longer defines, and deleting
        the call while the definition is kept leaves a definition the emitted script never reaches.
        The withdrawals are read back to a fixpoint rather than applied once, so a group broken by
        another group's withdrawal is caught too; it terminates because the batch only shrinks.
        `pruning_erases_body` is asked *before* any of this, against the full set, because it is
        permissive in the survivors and a withdrawal is a veto by another name.
        """
        if not graph.is_readable:
            return
        groups: dict[str, list[Node]] = {}
        removable_definitions: set[Node] = set()
        for key in graph.defined_names:
            definitions = graph.definitions(key)
            if not all(body_is_inert(d.body, oracle) for d in definitions):
                continue
            removable_here = [d for d in definitions if d.parent is node]
            if not removable_here:
                continue
            call_statements = self._inert_call_statements(graph, key)
            if call_statements is None:
                continue
            groups[key] = [*call_statements, *removable_here]
            removable_definitions.update(removable_here)
        if not groups:
            return
        if get_body(node) is not None:
            survivors = self._survivors(get_body(node), removable_definitions)
            if pruning_erases_body(node, survivors):
                return
        plans = Ps1RemovalPlans()
        for group in groups.values():
            for statement in group:
                plans.propose(statement)
        while True:
            accepted = {id(statement) for statement in plans.accepted}
            broken = [
                key for key, group in groups.items()
                if any(id(statement) not in accepted for statement in group)
            ]
            if not broken:
                break
            for key in broken:
                for statement in groups.pop(key):
                    plans.withdraw(statement)
        if plans.commit():
            self.mark_changed()

    @staticmethod
    def _inert_call_statements(graph: Ps1CallGraph, key: str) -> list[Node] | None:
        """
        The statements that call `key` and nothing else, or `None` when some reference to the name
        is not one — which means the name and every definition of it stay.

        A call site qualifies only when it is argument-free, its value is what the enclosing
        statement yields, and nothing on the way to that statement opens a file. The two redirection
        questions are separate and both have to be asked: `j > out.txt` sends the value somewhere
        the body cannot see it, which `unconsumed_statement` answers, and `j 2> err.txt` sends
        nothing anywhere while still creating the file, which only `opens_a_redirection_target`
        catches. The call is inert either way; the statement around it is not.
        """
        statements: list[Node] = []
        for site in graph.call_sites(key):
            statement = unconsumed_statement(site.invocation)
            if statement is None or site.invocation.arguments:
                return None
            cursor: Node | None = site.invocation
            while cursor is not None and cursor is not statement:
                if opens_a_redirection_target(cursor):
                    return None
                cursor = cursor.parent
            statements.append(statement)
        return statements

    def _removable_in_body(
        self, parent: Node, sink: OutputSink, called: frozenset[str], oracle: TypeOracle,
    ) -> set[Node]:
        """
        What this pass would drop from the statement list `parent` owns, where `sink` is the
        *resolved* destination of what that body writes. Both set-level guards are answered here,
        against the **pre-veto** survivors, which is the polarity they require — see
        `refinery.lib.scripts.ps1.deobfuscation.removal.Ps1RemovalPlan`.

        A `DISCARD` emits nothing and is always safe to drop wherever the body's output goes, even
        when it empties the body; that is what turns a junk function inert. An `OUTPUT` writes a
        value someone could see, and dropping it needs three separate things to hold, none of which
        implies another:

        - the caller asks for it, which is `bare_output_is_preserved` read the other way round;
        - the value provably reaches the host and nothing else, which is `sink`. A body whose writes
          land anywhere else keeps every one of them — that is the whole of what the resolution
          across the call graph buys, and the reason this is not the positional answer;
        - evaluating it cannot raise, which is `is_fault_free`. `[Int]'abc'` and `1/0` are `OUTPUT`
          like `42` is, and both terminate the script where they stand, so deleting one resumes
          execution that had stopped — and does it across a function boundary, where the `try` veto
          in `Ps1RemovalPlan` cannot see.
        """
        body = get_body(parent)
        removable: set[Node] = set()
        strip_bare_output = sink is OutputSink.HOST and not bare_output_is_preserved(self.options)
        for stmt in body:
            if isinstance(stmt, Ps1FunctionDefinition):
                # `called` is every call site standing in this tree, which is only the whole story
                # while the tree is the whole script. When the world is open, a dot-sourced file, an
                # imported module or an `iex` holds call sites the walk never read, so a definition
                # with none here is not unreachable — it is reachable from somewhere unreadable.
                if (
                    isinstance(parent, Ps1Script)
                    and oracle.world_closed_at(stmt)
                    and normalize_command_name(stmt.name) not in called
                ):
                    removable.add(stmt)
                continue
            effect = statement_effect(stmt, oracle)
            if effect is StatementEffect.DISCARD:
                removable.add(stmt)
            elif effect is StatementEffect.OUTPUT and strip_bare_output:
                if _writes_only_what_cannot_fault(stmt):
                    removable.add(stmt)
        if not removable:
            return set()
        if pruning_erases_body(parent, self._survivors(body, removable)):
            return set()
        return removable

    @staticmethod
    def _survivors(body: list, removable: set[Node]) -> list[Node]:
        return [stmt for stmt in body if stmt not in removable]

Ancestors

Methods

def visit(self, node)

Decide the whole batch, then apply it once.

Two different sinks are read here and they answer two different questions. Whether a body may be reasoned about at all is positional: output_path calls a stored closure or a $( ... ) opaque, and this pass has never touched one. Whether a write to the output stream inside it may be deleted needs the destination that write reaches, which position cannot supply for a function body, so Ps1OutputFlow resolves it across the call graph.

One outward walk answers both. The positional Ps1OutputPath is what the resolution reads, so asking the two questions separately walked every ancestor chain twice for one answer.

Collapsing the two costs recall and did: a body the flow reports captured — a function whose result someone stores — still holds $Null = 1 discards that write nothing at all, and skipping it over the output question left them standing.

Expand source code Browse git
def visit(self, node: Node):
    """
    Decide the whole batch, then apply it once.

    **Two different sinks are read here and they answer two different questions.** Whether a
    body may be reasoned about at all is positional: `output_path` calls a stored closure or a
    `$( ... )` opaque, and this pass has never touched one. Whether a *write to the output
    stream* inside it may be deleted needs the destination that write reaches, which position
    cannot supply for a function body, so `Ps1OutputFlow` resolves it across the call graph.

    One outward walk answers both. The positional `Ps1OutputPath` is what the resolution reads,
    so asking the two questions separately walked every ancestor chain twice for one answer.

    Collapsing the two costs recall and did: a body the flow reports captured — a function whose
    result someone stores — still holds `$Null = 1` discards that write nothing at all, and
    skipping it over the output question left them standing.
    """
    cache = model_cache(self, node)
    oracle = cache.oracle
    flow = cache.output_flow
    called = cache.call_graph.reachable_names()
    plans = Ps1RemovalPlans()
    for parent in node.walk():
        body = get_body(parent)
        if body is None:
            continue
        path = output_path(parent)
        if path.sink is OutputSink.CAPTURED:
            continue
        removable = self._removable_in_body(parent, flow.resolved(path), called, oracle)
        for statement in body:
            if statement in removable:
                plans.propose_in(parent, statement)
    if plans.commit():
        self.mark_changed()
    self._remove_inert_functions(node, cache.call_graph, oracle)

Inherited members

class Ps1DeadStoreElimination

Remove assignments to a variable that are provably overwritten before their next read within the same linear scope. This targets the pattern left behind by tier-2 empty-for rewriting: dozens of $i = N statements followed by a for-loop whose initializer $i = 0 overwrites them all. Reads come from the shared Ps1SemanticModel, so a store read only through a nested scriptblock is correctly seen as live rather than skipped.

Expand source code Browse git
class Ps1DeadStoreElimination(Transformer):
    """
    Remove assignments to a variable that are provably overwritten before their next read within the
    same linear scope. This targets the pattern left behind by tier-2 empty-for rewriting: dozens of
    `$i = N` statements followed by a for-loop whose initializer `$i = 0` overwrites them all. Reads
    come from the shared `refinery.lib.scripts.ps1.analysis.model.Ps1SemanticModel`, so a store read
    only through a nested scriptblock is correctly seen as live rather than skipped.
    """

    def __init__(self):
        super().__init__()
        self._oracle: TypeOracle | None = None

    def visit(self, node: Node):
        cache = model_cache(self, node)
        model = cache.model
        # The model is re-read per body, because removing a store changes what the next body's scope
        # says; the world behind the oracle is not, because it is a whole-script fact and this pass
        # only removes stores. Re-reading it would rebuild the whole-tree walk after every removal
        # to reach a verdict that can only have become *more* closed, and the captured one — taken
        # before those removals — is the more open, and so more conservative, of the two.
        if self._oracle is None:
            self._oracle = cache.oracle
        oracle = self._oracle
        body = get_body(node)
        scope = model.scope_of(node)
        if body is None or scope is None:
            self.generic_visit(node)
            return None
        pending: dict[str, list[Ps1ExpressionStatement]] = {}
        dead: list[Ps1ExpressionStatement] = []
        has_read: set[str] = set()
        for stmt in body:
            reads, kills, writes = self._classify_statement(stmt, scope, model)
            for var in kills:
                if var in pending:
                    dead.extend(pending.pop(var))
                has_read.add(var)
            for var in reads:
                pending.pop(var, None)
                has_read.add(var)
            for var, store in writes:
                if var in pending:
                    dead.extend(pending[var])
                pending[var] = [store]
        if isinstance(node, Ps1Script):
            for var, stores in pending.items():
                if var in has_read:
                    dead.extend(stores)
        if not dead:
            self.generic_visit(node)
            return None
        plan = Ps1RemovalPlan(node)
        for stmt in dead:
            if not isinstance(stmt, Ps1ExpressionStatement):
                continue
            rhs = stmt.expression
            if isinstance(rhs, Ps1AssignmentExpression):
                rhs = rhs.value
            if rhs is None or is_side_effect_free(rhs, oracle):
                plan.propose(stmt)
            elif _value_is_movable(rhs):
                plan.propose(stmt, [store_dropped_to_value(rhs)])
        if plan.commit():
            self.mark_changed()
        self.generic_visit(node)

    def _classify_statement(
        self, stmt, scope: Scope, model: Ps1SemanticModel,
    ) -> tuple[set[str], set[str], list[tuple[str, Ps1ExpressionStatement]]]:
        """
        Return `(reads, kills, writes)` for a top-level statement:
        - `reads`: variable keys read by the statement (flush: pending writes are needed).
        - `kills`: variable keys whose previous pending writes are dead (overwritten before read).
        - `writes`: list of `(key, statement)` pairs for pure assignments to register as pending.

        A for-loop's initializer kills previous stores without itself becoming a pending write
        (the for-loop is not a removable expression statement). Other control-flow constructs
        contribute all internal variable references as reads and produce no kills/writes.
        """
        reads: set[str] = set()
        kills: set[str] = set()
        writes: list[tuple[str, Ps1ExpressionStatement]] = []
        if isinstance(stmt, Ps1ExpressionStatement) and isinstance(
            stmt.expression, Ps1AssignmentExpression
        ):
            assign = stmt.expression
            if (
                assign.operator == '='
                and isinstance(assign.target, Ps1Variable)
                and assign.target.scope is Ps1ScopeModifier.NONE
            ):
                key = assign.target.name.lower()
                if key not in _PS1_SKIP_VARIABLES:
                    if assign.value is not None:
                        reads |= model.reads_in_scope(assign.value, scope)
                    writes.append((key, stmt))
                    return reads, kills, writes
        if isinstance(stmt, Ps1ForLoop):
            if (
                isinstance(stmt.initializer, Ps1AssignmentExpression)
                and stmt.initializer.operator == '='
                and isinstance(stmt.initializer.target, Ps1Variable)
                and stmt.initializer.target.scope is Ps1ScopeModifier.NONE
            ):
                key = stmt.initializer.target.name.lower()
                if key not in _PS1_SKIP_VARIABLES:
                    init_rhs_reads: set[str] = set()
                    if stmt.initializer.value is not None:
                        init_rhs_reads = model.reads_in_scope(stmt.initializer.value, scope)
                    if key in init_rhs_reads:
                        reads.update(init_rhs_reads)
                    else:
                        kills.add(key)
        reads |= model.variables_in_scope(stmt, scope)
        return reads, kills, writes

Ancestors

Methods

def visit(self, node)
Expand source code Browse git
def visit(self, node: Node):
    cache = model_cache(self, node)
    model = cache.model
    # The model is re-read per body, because removing a store changes what the next body's scope
    # says; the world behind the oracle is not, because it is a whole-script fact and this pass
    # only removes stores. Re-reading it would rebuild the whole-tree walk after every removal
    # to reach a verdict that can only have become *more* closed, and the captured one — taken
    # before those removals — is the more open, and so more conservative, of the two.
    if self._oracle is None:
        self._oracle = cache.oracle
    oracle = self._oracle
    body = get_body(node)
    scope = model.scope_of(node)
    if body is None or scope is None:
        self.generic_visit(node)
        return None
    pending: dict[str, list[Ps1ExpressionStatement]] = {}
    dead: list[Ps1ExpressionStatement] = []
    has_read: set[str] = set()
    for stmt in body:
        reads, kills, writes = self._classify_statement(stmt, scope, model)
        for var in kills:
            if var in pending:
                dead.extend(pending.pop(var))
            has_read.add(var)
        for var in reads:
            pending.pop(var, None)
            has_read.add(var)
        for var, store in writes:
            if var in pending:
                dead.extend(pending[var])
            pending[var] = [store]
    if isinstance(node, Ps1Script):
        for var, stores in pending.items():
            if var in has_read:
                dead.extend(stores)
    if not dead:
        self.generic_visit(node)
        return None
    plan = Ps1RemovalPlan(node)
    for stmt in dead:
        if not isinstance(stmt, Ps1ExpressionStatement):
            continue
        rhs = stmt.expression
        if isinstance(rhs, Ps1AssignmentExpression):
            rhs = rhs.value
        if rhs is None or is_side_effect_free(rhs, oracle):
            plan.propose(stmt)
        elif _value_is_movable(rhs):
            plan.propose(stmt, [store_dropped_to_value(rhs)])
    if plan.commit():
        self.mark_changed()
    self.generic_visit(node)

Inherited members