Module refinery.lib.scripts.ps1.deobfuscation.wildcards

Resolve wildcard-based obfuscation patterns in PowerShell scripts.

Handles three categories of wildcard obfuscation commonly used in malware:

  1. Wildcard variable access via the Variable: drive (Get-Item Variable:E*t)
  2. Wildcard cmdlet resolution via GetCmdlets/Invoke with wildcard patterns
  3. Wildcard member/method filtering via Where-Object pipelines
Expand source code Browse git
"""
Resolve wildcard-based obfuscation patterns in PowerShell scripts.

Handles three categories of wildcard obfuscation commonly used in malware:

1. Wildcard variable access via the Variable: drive (Get-Item Variable:E*t)
2. Wildcard cmdlet resolution via GetCmdlets/Invoke with wildcard patterns
3. Wildcard member/method filtering via Where-Object pipelines
"""
from __future__ import annotations

import re

from fnmatch import translate as fnmatch_translate
from typing import Iterable

from refinery.lib.scripts.ps1.analysis.values import Ps1VariableTyping, resolve_expression_type
from refinery.lib.scripts.ps1.ast import (
    argument_text,
    binds_parameter,
    free_positional_values,
    get_command_name,
    get_member_name,
    bound_argument_value,
    resolve_command_name,
    string_value,
    unwrap_parens,
)
from refinery.lib.scripts.ps1.data import (
    GET_COMMAND_ALIASES,
    GET_MEMBER_ALIASES,
    KNOWN_CMDLETS,
    PS1_KNOWN_VARIABLES,
    member_names,
)
from refinery.lib.scripts.ps1.analysis.values import make_string_literal
from refinery.lib.scripts.ps1.deobfuscation.substitution import substituted
from refinery.lib.scripts.ps1.deobfuscation.typenames import VariableTypeAwareTransformer
from refinery.lib.scripts.ps1.model import (
    Expression,
    Ps1AssignmentExpression,
    Ps1BinaryExpression,
    Ps1CommandArgument,
    Ps1CommandArgumentKind,
    Ps1CommandInvocation,
    Ps1ExpressionStatement,
    Ps1InvokeMember,
    Ps1MemberAccess,
    Ps1ParenExpression,
    Ps1Pipeline,
    Ps1PipelineElement,
    Ps1ScopeModifier,
    Ps1ScriptBlock,
    Ps1StringLiteral,
    Ps1Variable,
)

_GET_ITEM_COMMANDS = frozenset({'get-item', 'gi', 'get-childitem', 'gci'})
_GET_VARIABLE_COMMANDS = frozenset({'get-variable', 'gv'})
_SET_ITEM_COMMANDS = frozenset({'set-item', 'si'})
_SET_VARIABLE_COMMANDS = frozenset({'set-variable', 'sv', 'set'})
_WHERE_OBJECT_ALIASES = frozenset({'?', 'where', 'where-object'})
_LIKE_OPERATORS = frozenset({'-like', '-ilike', '-clike'})


def _is_wildcard(pattern: str) -> bool:
    return '*' in pattern or '?' in pattern


def _wildcard_match_unique(
    pattern: str,
    candidates: Iterable[str],
) -> str | None:
    """
    Match a wildcard pattern case-insensitively against canonical names. Returns the name if one
    exact candidate matches, else `None`.
    """
    regex = re.compile(fnmatch_translate(pattern), re.IGNORECASE)
    matches = [name for name in candidates if regex.match(name)]
    if len(matches) == 1:
        return matches[0]
    return None


_KNOWN_CMDLET_LIST: list[str] = list(KNOWN_CMDLETS.values())


def _known_cmdlets() -> list[str]:
    return _KNOWN_CMDLET_LIST


def _is_psobject_member_access(
    expr: Expression,
    leaf_name: str,
) -> Ps1MemberAccess | None:
    """
    Check if expr is of the form `<something>.PSObject.<leaf_name>` and return the inner member
    access to `<something>.PSObject`, or `None`.
    """
    if not isinstance(expr, Ps1MemberAccess):
        return None
    name = get_member_name(expr.member)
    if name is None or name.lower() != leaf_name:
        return None
    inner = expr.object
    if not isinstance(inner, Ps1MemberAccess):
        return None
    ps_name = get_member_name(inner.member)
    if ps_name is None or ps_name.lower() != 'psobject':
        return None
    return inner


def _determine_where_object_candidates(
    elements: list,
    type_of_variable: Ps1VariableTyping | None = None,
) -> Iterable[str] | None:
    """
    Examine the pipeline elements preceding `Where-Object` to determine which candidates the
    wildcard should match against. Returns canonical names to match against, or `None` if the source
    is unrecognized.
    """
    for elem in elements:
        if not isinstance(elem, Ps1PipelineElement):
            continue
        expr = elem.expression
        while isinstance(expr, Ps1ParenExpression) and expr.expression is not None:
            inner = expr.expression
            if isinstance(inner, Ps1Pipeline):
                result = _determine_where_object_candidates(
                    inner.elements, type_of_variable,
                )
                if result is not None:
                    return result
                break
            expr = inner

        if isinstance(expr, Ps1CommandInvocation):
            cmd_name = get_command_name(expr)
            if cmd_name is not None:
                cmd_lower = cmd_name.lower()
                if cmd_lower in GET_COMMAND_ALIASES:
                    return _known_cmdlets()
                if cmd_lower in GET_MEMBER_ALIASES:
                    return _candidates_from_get_member(
                        elements, elem, type_of_variable,
                    )

        if isinstance(expr, Ps1MemberAccess):
            pso = _is_psobject_member_access(expr, 'methods')
            if pso is not None:
                return _candidates_from_type(pso.object, type_of_variable)
            pso = _is_psobject_member_access(expr, 'properties')
            if pso is not None:
                return _candidates_from_type(pso.object, type_of_variable)

    return None


def _candidates_from_get_member(
    elements: list,
    gm_element: Ps1PipelineElement,
    type_of_variable: Ps1VariableTyping | None = None,
) -> list[str] | None:
    """
    For a pipeline like `expr | Get-Member | Where-Object ...`, resolve the type of the expression
    piped into `Get-Member`.
    """
    idx = None
    for i, elem in enumerate(elements):
        if elem is gm_element:
            idx = i
            break
    if idx is None or idx == 0:
        return None
    prev = elements[idx - 1]
    if not isinstance(prev, Ps1PipelineElement):
        return None
    return _candidates_from_type(prev.expression, type_of_variable)


def _candidates_from_type(
    expr: Expression | None,
    type_of_variable: Ps1VariableTyping | None = None,
) -> list[str] | None:
    if expr is None:
        return None
    type_name = resolve_expression_type(expr, type_of_variable)
    if type_name is None:
        return None
    return member_names(type_name)


def _concat_expressions(exprs: list[Expression]) -> Expression:
    """
    Build a left-associative `+` chain from a list of expressions.
    """
    result = exprs[0]
    for expr in exprs[1:]:
        result = Ps1BinaryExpression(
            offset=expr.offset,
            left=result,
            operator='+',
            right=expr,
        )
    return result


def _has_valueonly_switch(cmd: Ps1CommandInvocation) -> bool:
    """
    Check if a command has a switch that is any unambiguous abbreviation of `-ValueOnly`; this is
    true even for just `-v` since no other flag starts with `v`.
    """
    for arg in cmd.arguments:
        if not isinstance(arg, Ps1CommandArgument):
            continue
        if arg.kind != Ps1CommandArgumentKind.SWITCH:
            continue
        if arg.name.lower().startswith('-v'):
            return True
    return False


#: The `-Scope` arguments a variable reference can express, mapped to the qualifier that expresses
#: them. `Local` needs none, since an unqualified reference means the scope it stands in. Every
#: other spelling — `-Scope 1`, which names the *caller's* scope, an unrecognised word, a computed
#: expression — names a scope no qualifier reaches, and the rewrite is declined rather than
#: approximated.
_EXPRESSIBLE_SCOPES: dict[str, Ps1ScopeModifier] = {
    'global': Ps1ScopeModifier.GLOBAL,
    'local': Ps1ScopeModifier.NONE,
    'private': Ps1ScopeModifier.PRIVATE,
    'script': Ps1ScopeModifier.SCRIPT,
}


#: Parameters whose effect an assignment cannot carry, so binding one declines the rewrite.
#:
#: Measured: after `New-Variable x -Option ReadOnly` a later `$x = …` raises
#: `SessionStateUnauthorizedAccessException`, where after `$x = …` it succeeds — so the option is
#: not decoration, it decides what every later store in the script does. `-PassThru` makes the
#: command emit the variable object, which an assignment does not emit, and `-Force` is what lets a
#: write land on a name an option protects.
#:
#: `-Description` is deliberately absent: it is metadata no read or write observes. Anything not
#: listed is *allowed*, which is the wrong polarity for a corruption and the right one for a
#: rewrite — see `refinery.lib.scripts.ps1.analysis.naming` on why the two tables differ.
_INEXPRESSIBLE_PARAMETERS = (
    'force',
    'option',
    'passthru',
    'visibility',
)


def _expresses_every_effect(cmd: Ps1CommandInvocation) -> bool:
    """
    Whether an assignment carries everything *cmd* does, or drops an effect on the floor.
    """
    for argument in cmd.arguments:
        if not isinstance(argument, Ps1CommandArgument):
            continue
        if any(binds_parameter(argument.name, name) for name in _INEXPRESSIBLE_PARAMETERS):
            return False
    return True


def _subject_argument_value(
    cmd: Ps1CommandInvocation,
    command: str,
    parameter: str,
) -> str | None:
    """
    The literal name *cmd* is about, written either as `-Name x` / `-Path x` or as the first
    argument it binds by position, or `None` when it is not a literal this can read. The same
    reading `refinery.lib.scripts.ps1.analysis.naming` takes, down to
    `refinery.lib.scripts.ps1.ast.argument_text`, so the two layers agree on which argument a
    variable command names *and* on what it is called.
    """
    explicit = bound_argument_value(cmd, parameter)
    if explicit is not None:
        return argument_text(explicit)
    for value in free_positional_values(cmd, command):
        return argument_text(value)
    return None


def _expressible_scope(
    node: Ps1CommandInvocation,
    var_name: str,
) -> Ps1ScopeModifier | None:
    """
    The qualifier the variable reference replacing *node* must carry, or `None` when no reference
    can stand for the command. A name that already carries a qualifier of its own is declined
    whenever a `-Scope` argument is present too, since the two would have to be rendered one inside
    the other.
    """
    declared = bound_argument_value(node, 'scope')
    if declared is None:
        return Ps1ScopeModifier.NONE
    if ':' in var_name:
        return None
    written = string_value(declared)
    if written is None:
        return None
    return _EXPRESSIBLE_SCOPES.get(written.lower())


def _resolve_variable_name(
    pattern: str,
) -> str | None:
    """
    Resolve a variable name pattern (possibly wildcard) to a canonical name. Returns the resolved
    name, or the pattern itself for non-wildcard names.
    """
    if _is_wildcard(pattern):
        return _wildcard_match_unique(pattern, PS1_KNOWN_VARIABLES.values())
    pattern_lower = pattern.lower()
    return next(
        (v for v in PS1_KNOWN_VARIABLES.values() if v.lower() == pattern_lower),
        pattern,
    )


def _extract_where_object_wildcard(
    cmd: Ps1CommandInvocation,
) -> str | None:
    """
    Detect Where-Object with a scriptblock body of the form:

        $_.Name -ilike 'pattern'

    Returns the pattern string, or `None`.
    """
    name = get_command_name(cmd)
    if name is None or name.lower() not in _WHERE_OBJECT_ALIASES:
        return None
    if len(cmd.arguments) != 1:
        return None
    arg = cmd.arguments[0]
    if isinstance(arg, Ps1CommandArgument):
        if arg.kind != Ps1CommandArgumentKind.POSITIONAL:
            return None
        arg = arg.value
    if not isinstance(arg, Ps1ScriptBlock):
        return None
    body = arg.body
    if len(body) != 1:
        return None
    stmt = body[0]
    expr = None
    if isinstance(stmt, Ps1ExpressionStatement):
        expr = stmt.expression
    elif isinstance(stmt, Ps1Pipeline):
        if len(stmt.elements) == 1:
            elem = stmt.elements[0]
            if isinstance(elem, Ps1PipelineElement):
                expr = elem.expression
    if not isinstance(expr, Ps1BinaryExpression):
        return None
    if expr.operator.lower() not in _LIKE_OPERATORS:
        return None
    left = expr.left
    if not isinstance(left, Ps1MemberAccess):
        return None
    if not isinstance(left.object, Ps1Variable):
        return None
    if left.object.name.lower() not in ('_', 'psitem'):
        return None
    member = left.member
    if isinstance(member, str):
        member_name = member
    elif isinstance(member, Ps1StringLiteral):
        member_name = member.value
    else:
        return None
    if member_name.lower() != 'name':
        return None
    if expr.right is None:
        return None
    return string_value(expr.right)


class Ps1WildcardResolution(VariableTypeAwareTransformer):

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_variable_value(node))

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_cmdlet_method(node))

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_where_object_wildcard(node))

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        self.generic_visit(node)
        replacement = self._try_resolve_get_variable_value_only(node)
        if replacement is None:
            replacement = self._try_resolve_set_variable(node)
        return substituted(node, replacement)

    def _try_resolve_variable_value(
        self,
        node: Ps1MemberAccess,
    ) -> Expression | None:
        """
        Resolve property access on `Get-Item Variable:X` or `Get-Variable X`:

        - `.Value` resolves to `$X` (the variable's value)
        - `.Name` resolves to `'X'` as a string literal (the variable's name)

        `Get-Item Variable:X` and `Get-Variable X` return a `PSVariable` wrapper object whose
        `.Value` property gives the actual variable content and `.Name` property gives its name.
        """
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        member_lower = member_name.lower()
        if member_lower not in ('value', 'name'):
            return None
        if node.object is None:
            return None
        found = self._resolve_get_variable_pattern(node.object)
        if found is None:
            return None
        resolved, scope = found
        if member_lower == 'value':
            if scope is None:
                return None
            return Ps1Variable(
                offset=node.offset,
                name=resolved,
                scope=scope,
            )
        return make_string_literal(resolved)

    @staticmethod
    def _resolve_get_variable_pattern(
        expr: Expression,
    ) -> tuple[str, Ps1ScopeModifier | None] | None:
        """
        Given the object expression of a member access, check if it is a `Get-Item Variable:X` or
        `Get-Variable X` invocation and resolve the variable name (supporting wildcards) together
        with the qualifier a reference replacing it must carry.

        The qualifier is `None` for a `-Scope` no reference can name, which only the `.Value`
        rewrite has to decline: the name a command is about is that name whichever scope it reads.
        """
        inner = unwrap_parens(expr)
        if not isinstance(inner, Ps1CommandInvocation):
            return None
        name = get_command_name(inner)
        if name is None:
            return None
        name_lower = name.lower()
        if name_lower not in _GET_ITEM_COMMANDS and name_lower not in _GET_VARIABLE_COMMANDS:
            return None
        command = resolve_command_name(inner)
        if command is None:
            return None
        subject = 'path' if name_lower in _GET_ITEM_COMMANDS else 'name'
        arg_value = _subject_argument_value(inner, command, subject)
        if arg_value is None:
            return None
        if name_lower in _GET_ITEM_COMMANDS:
            prefix = 'variable:'
            if not arg_value.lower().startswith(prefix):
                return None
            pattern = arg_value[len(prefix):]
            pattern = pattern.lstrip('/\\')
        else:
            pattern = arg_value
        resolved = _resolve_variable_name(pattern)
        if resolved is None:
            return None
        return resolved, _expressible_scope(inner, resolved)

    def _try_resolve_get_variable_value_only(
        self,
        node: Ps1CommandInvocation,
    ) -> Expression | None:
        """
        Resolve `Get-Variable X -ValueOnly` to `$X`.
        """
        cmd_name = get_command_name(node)
        if cmd_name is None or cmd_name.lower() not in _GET_VARIABLE_COMMANDS:
            return None
        if not _has_valueonly_switch(node):
            return None
        command = resolve_command_name(node)
        if command is None:
            return None
        name_expr = bound_argument_value(node, 'name')
        if name_expr is None:
            positionals = free_positional_values(node, command)
            if not positionals:
                return None
            name_expr = positionals[0]
        arg_value = argument_text(name_expr)
        if arg_value is None:
            return None
        resolved = _resolve_variable_name(arg_value)
        if resolved is None:
            return None
        scope = _expressible_scope(node, resolved)
        if scope is None:
            return None
        return Ps1Variable(
            offset=node.offset,
            name=resolved,
            scope=scope,
        )

    def _try_resolve_cmdlet_method(
        self,
        node: Ps1InvokeMember,
    ) -> Expression | None:
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        member_lower = member_name.lower()
        is_getcmdlets = member_lower in ('getcmdlets', 'getcmdlet')
        is_getcommand = member_lower in ('getcommandname', 'getcommand')
        is_invoke = member_lower == 'invoke'
        if not is_getcmdlets and not is_getcommand and not is_invoke:
            return None
        if len(node.arguments) < 1:
            return None
        pattern = string_value(node.arguments[0])
        if pattern is None:
            return None
        if is_invoke and '-' not in pattern:
            return None
        cmdlets = _known_cmdlets()
        if _is_wildcard(pattern):
            resolved = _wildcard_match_unique(pattern, cmdlets)
        else:
            resolved = next(
                (c for c in cmdlets if c.lower() == pattern.lower()), None)
        if resolved is None:
            return None
        return make_string_literal(resolved)

    def _try_resolve_where_object_wildcard(
        self,
        node: Ps1Pipeline,
    ) -> Expression | None:
        if len(node.elements) < 2:
            return None
        last_elem = node.elements[-1]
        if not isinstance(last_elem, Ps1PipelineElement):
            return None
        cmd = last_elem.expression
        if not isinstance(cmd, Ps1CommandInvocation):
            return None
        pattern = _extract_where_object_wildcard(cmd)
        if pattern is None or not _is_wildcard(pattern):
            return None
        preceding = node.elements[:-1]
        candidates = _determine_where_object_candidates(
            preceding, self._type_of_variable,
        )
        if candidates is None:
            return None
        resolved = _wildcard_match_unique(pattern, candidates)
        if resolved is None:
            return None
        return make_string_literal(resolved)

    def _try_resolve_set_variable(
        self,
        node: Ps1CommandInvocation,
    ) -> Expression | None:
        """
        Resolve Set-Item Variable:X value or Set-Variable X value to $X = value.
        """
        cmd_name = get_command_name(node)
        if cmd_name is None:
            return None
        command = resolve_command_name(node)
        if command is None:
            return None
        cmd_lower = cmd_name.lower()
        if cmd_lower in _SET_ITEM_COMMANDS:
            return self._handle_set_item_variable(node, command)
        if cmd_lower in _SET_VARIABLE_COMMANDS:
            return self._handle_set_variable(node, command)
        return None

    def _handle_set_item_variable(
        self,
        node: Ps1CommandInvocation,
        command: str,
    ) -> Expression | None:
        """
        Set-Item Variable:/X val1 val2 → $X = val1 + val2

        `-Path` and `-Value` name the same two arguments the positional spelling does, and a command
        binding either of them by name leaves nothing free for the positional reading to find.
        """
        positionals = free_positional_values(node, command)
        path_expr = bound_argument_value(node, 'path')
        if path_expr is None:
            if not positionals:
                return None
            path_expr, positionals = positionals[0], positionals[1:]
        named_value = bound_argument_value(node, 'value')
        values = [named_value] if named_value is not None else positionals
        if not values:
            return None
        path_str = string_value(path_expr)
        if path_str is None:
            return None
        prefix = 'variable:'
        if not path_str.lower().startswith(prefix):
            return None
        var_name = path_str[len(prefix):].lstrip('/\\')
        resolved = _resolve_variable_name(var_name)
        if resolved is None:
            return None
        return self._build_assignment(node.offset, resolved, values, Ps1ScopeModifier.NONE)

    def _handle_set_variable(
        self,
        node: Ps1CommandInvocation,
        command: str,
    ) -> Expression | None:
        """
        Set-Variable X val or Set-Variable -Name X -Value val → $X = val
        """
        if not _expresses_every_effect(node):
            return None
        named_value = bound_argument_value(node, 'value')
        positionals = free_positional_values(node, command)
        name_expr = bound_argument_value(node, 'name')
        if name_expr is not None:
            var_name = argument_text(name_expr)
        elif positionals:
            var_name = argument_text(positionals[0])
            positionals = positionals[1:]
        else:
            return None
        if var_name is None:
            return None
        resolved = _resolve_variable_name(var_name)
        if resolved is None:
            return None
        scope = _expressible_scope(node, resolved)
        if scope is None:
            return None
        if named_value is not None:
            values = [named_value]
        elif positionals:
            values = positionals
        else:
            return None
        return self._build_assignment(node.offset, resolved, values, scope)

    @staticmethod
    def _build_assignment(
        offset: int,
        var_name: str,
        values: list[Expression],
        scope: Ps1ScopeModifier,
    ) -> Ps1AssignmentExpression:
        target = Ps1Variable(
            offset=offset,
            name=var_name,
            scope=scope,
        )
        if len(values) == 1:
            value = values[0]
        else:
            value = _concat_expressions(values)
        return Ps1AssignmentExpression(
            offset=offset,
            target=target,
            operator='=',
            value=value,
        )

Classes

class Ps1WildcardResolution

A pass that asks type_at() what a variable holds where it is read, rather than carrying a table of names it built itself.

The flow model is captured once at the root, for the reason Ps1TypeCasts.visit() gives at greater length: every fold below marks the pass changed, which drops the shared cache, so a per-site lookup would rebuild the control-flow graphs of the whole script once per fold. Neither pass adds or removes a statement, so the graphs it would rebuild are the graphs it already holds.

The model is dropped again when the walk it was captured for ends, so that a second walk over a tree the first one rewrote cannot be answered from the first one's graphs. The answers are held for as long as the model is, keyed on the occurrence they were asked about: type_at walks every write of a binding, and a member chain, two passes and every iteration of the normalize group ask about the same occurrences again. The occurrence is kept beside its answer because id alone identifies a node only while that node is alive, and this walk frees the ones it replaces.

Expand source code Browse git
class Ps1WildcardResolution(VariableTypeAwareTransformer):

    def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_variable_value(node))

    def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_cmdlet_method(node))

    def visit_Ps1Pipeline(self, node: Ps1Pipeline):
        self.generic_visit(node)
        return substituted(node, self._try_resolve_where_object_wildcard(node))

    def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
        self.generic_visit(node)
        replacement = self._try_resolve_get_variable_value_only(node)
        if replacement is None:
            replacement = self._try_resolve_set_variable(node)
        return substituted(node, replacement)

    def _try_resolve_variable_value(
        self,
        node: Ps1MemberAccess,
    ) -> Expression | None:
        """
        Resolve property access on `Get-Item Variable:X` or `Get-Variable X`:

        - `.Value` resolves to `$X` (the variable's value)
        - `.Name` resolves to `'X'` as a string literal (the variable's name)

        `Get-Item Variable:X` and `Get-Variable X` return a `PSVariable` wrapper object whose
        `.Value` property gives the actual variable content and `.Name` property gives its name.
        """
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        member_lower = member_name.lower()
        if member_lower not in ('value', 'name'):
            return None
        if node.object is None:
            return None
        found = self._resolve_get_variable_pattern(node.object)
        if found is None:
            return None
        resolved, scope = found
        if member_lower == 'value':
            if scope is None:
                return None
            return Ps1Variable(
                offset=node.offset,
                name=resolved,
                scope=scope,
            )
        return make_string_literal(resolved)

    @staticmethod
    def _resolve_get_variable_pattern(
        expr: Expression,
    ) -> tuple[str, Ps1ScopeModifier | None] | None:
        """
        Given the object expression of a member access, check if it is a `Get-Item Variable:X` or
        `Get-Variable X` invocation and resolve the variable name (supporting wildcards) together
        with the qualifier a reference replacing it must carry.

        The qualifier is `None` for a `-Scope` no reference can name, which only the `.Value`
        rewrite has to decline: the name a command is about is that name whichever scope it reads.
        """
        inner = unwrap_parens(expr)
        if not isinstance(inner, Ps1CommandInvocation):
            return None
        name = get_command_name(inner)
        if name is None:
            return None
        name_lower = name.lower()
        if name_lower not in _GET_ITEM_COMMANDS and name_lower not in _GET_VARIABLE_COMMANDS:
            return None
        command = resolve_command_name(inner)
        if command is None:
            return None
        subject = 'path' if name_lower in _GET_ITEM_COMMANDS else 'name'
        arg_value = _subject_argument_value(inner, command, subject)
        if arg_value is None:
            return None
        if name_lower in _GET_ITEM_COMMANDS:
            prefix = 'variable:'
            if not arg_value.lower().startswith(prefix):
                return None
            pattern = arg_value[len(prefix):]
            pattern = pattern.lstrip('/\\')
        else:
            pattern = arg_value
        resolved = _resolve_variable_name(pattern)
        if resolved is None:
            return None
        return resolved, _expressible_scope(inner, resolved)

    def _try_resolve_get_variable_value_only(
        self,
        node: Ps1CommandInvocation,
    ) -> Expression | None:
        """
        Resolve `Get-Variable X -ValueOnly` to `$X`.
        """
        cmd_name = get_command_name(node)
        if cmd_name is None or cmd_name.lower() not in _GET_VARIABLE_COMMANDS:
            return None
        if not _has_valueonly_switch(node):
            return None
        command = resolve_command_name(node)
        if command is None:
            return None
        name_expr = bound_argument_value(node, 'name')
        if name_expr is None:
            positionals = free_positional_values(node, command)
            if not positionals:
                return None
            name_expr = positionals[0]
        arg_value = argument_text(name_expr)
        if arg_value is None:
            return None
        resolved = _resolve_variable_name(arg_value)
        if resolved is None:
            return None
        scope = _expressible_scope(node, resolved)
        if scope is None:
            return None
        return Ps1Variable(
            offset=node.offset,
            name=resolved,
            scope=scope,
        )

    def _try_resolve_cmdlet_method(
        self,
        node: Ps1InvokeMember,
    ) -> Expression | None:
        member_name = get_member_name(node.member)
        if member_name is None:
            return None
        member_lower = member_name.lower()
        is_getcmdlets = member_lower in ('getcmdlets', 'getcmdlet')
        is_getcommand = member_lower in ('getcommandname', 'getcommand')
        is_invoke = member_lower == 'invoke'
        if not is_getcmdlets and not is_getcommand and not is_invoke:
            return None
        if len(node.arguments) < 1:
            return None
        pattern = string_value(node.arguments[0])
        if pattern is None:
            return None
        if is_invoke and '-' not in pattern:
            return None
        cmdlets = _known_cmdlets()
        if _is_wildcard(pattern):
            resolved = _wildcard_match_unique(pattern, cmdlets)
        else:
            resolved = next(
                (c for c in cmdlets if c.lower() == pattern.lower()), None)
        if resolved is None:
            return None
        return make_string_literal(resolved)

    def _try_resolve_where_object_wildcard(
        self,
        node: Ps1Pipeline,
    ) -> Expression | None:
        if len(node.elements) < 2:
            return None
        last_elem = node.elements[-1]
        if not isinstance(last_elem, Ps1PipelineElement):
            return None
        cmd = last_elem.expression
        if not isinstance(cmd, Ps1CommandInvocation):
            return None
        pattern = _extract_where_object_wildcard(cmd)
        if pattern is None or not _is_wildcard(pattern):
            return None
        preceding = node.elements[:-1]
        candidates = _determine_where_object_candidates(
            preceding, self._type_of_variable,
        )
        if candidates is None:
            return None
        resolved = _wildcard_match_unique(pattern, candidates)
        if resolved is None:
            return None
        return make_string_literal(resolved)

    def _try_resolve_set_variable(
        self,
        node: Ps1CommandInvocation,
    ) -> Expression | None:
        """
        Resolve Set-Item Variable:X value or Set-Variable X value to $X = value.
        """
        cmd_name = get_command_name(node)
        if cmd_name is None:
            return None
        command = resolve_command_name(node)
        if command is None:
            return None
        cmd_lower = cmd_name.lower()
        if cmd_lower in _SET_ITEM_COMMANDS:
            return self._handle_set_item_variable(node, command)
        if cmd_lower in _SET_VARIABLE_COMMANDS:
            return self._handle_set_variable(node, command)
        return None

    def _handle_set_item_variable(
        self,
        node: Ps1CommandInvocation,
        command: str,
    ) -> Expression | None:
        """
        Set-Item Variable:/X val1 val2 → $X = val1 + val2

        `-Path` and `-Value` name the same two arguments the positional spelling does, and a command
        binding either of them by name leaves nothing free for the positional reading to find.
        """
        positionals = free_positional_values(node, command)
        path_expr = bound_argument_value(node, 'path')
        if path_expr is None:
            if not positionals:
                return None
            path_expr, positionals = positionals[0], positionals[1:]
        named_value = bound_argument_value(node, 'value')
        values = [named_value] if named_value is not None else positionals
        if not values:
            return None
        path_str = string_value(path_expr)
        if path_str is None:
            return None
        prefix = 'variable:'
        if not path_str.lower().startswith(prefix):
            return None
        var_name = path_str[len(prefix):].lstrip('/\\')
        resolved = _resolve_variable_name(var_name)
        if resolved is None:
            return None
        return self._build_assignment(node.offset, resolved, values, Ps1ScopeModifier.NONE)

    def _handle_set_variable(
        self,
        node: Ps1CommandInvocation,
        command: str,
    ) -> Expression | None:
        """
        Set-Variable X val or Set-Variable -Name X -Value val → $X = val
        """
        if not _expresses_every_effect(node):
            return None
        named_value = bound_argument_value(node, 'value')
        positionals = free_positional_values(node, command)
        name_expr = bound_argument_value(node, 'name')
        if name_expr is not None:
            var_name = argument_text(name_expr)
        elif positionals:
            var_name = argument_text(positionals[0])
            positionals = positionals[1:]
        else:
            return None
        if var_name is None:
            return None
        resolved = _resolve_variable_name(var_name)
        if resolved is None:
            return None
        scope = _expressible_scope(node, resolved)
        if scope is None:
            return None
        if named_value is not None:
            values = [named_value]
        elif positionals:
            values = positionals
        else:
            return None
        return self._build_assignment(node.offset, resolved, values, scope)

    @staticmethod
    def _build_assignment(
        offset: int,
        var_name: str,
        values: list[Expression],
        scope: Ps1ScopeModifier,
    ) -> Ps1AssignmentExpression:
        target = Ps1Variable(
            offset=offset,
            name=var_name,
            scope=scope,
        )
        if len(values) == 1:
            value = values[0]
        else:
            value = _concat_expressions(values)
        return Ps1AssignmentExpression(
            offset=offset,
            target=target,
            operator='=',
            value=value,
        )

Ancestors

Methods

def visit_Ps1MemberAccess(self, node)
Expand source code Browse git
def visit_Ps1MemberAccess(self, node: Ps1MemberAccess):
    self.generic_visit(node)
    return substituted(node, self._try_resolve_variable_value(node))
def visit_Ps1InvokeMember(self, node)
Expand source code Browse git
def visit_Ps1InvokeMember(self, node: Ps1InvokeMember):
    self.generic_visit(node)
    return substituted(node, self._try_resolve_cmdlet_method(node))
def visit_Ps1Pipeline(self, node)
Expand source code Browse git
def visit_Ps1Pipeline(self, node: Ps1Pipeline):
    self.generic_visit(node)
    return substituted(node, self._try_resolve_where_object_wildcard(node))
def visit_Ps1CommandInvocation(self, node)
Expand source code Browse git
def visit_Ps1CommandInvocation(self, node: Ps1CommandInvocation):
    self.generic_visit(node)
    replacement = self._try_resolve_get_variable_value_only(node)
    if replacement is None:
        replacement = self._try_resolve_set_variable(node)
    return substituted(node, replacement)

Inherited members