Module refinery.lib.scripts.ps1.analysis.naming
The names a script addresses as strings rather than as variables.
Set-Variable X 'v' writes $X, Get-Variable X reads it, Remove-Variable X unbinds it, and
Get-Process -OutVariable p fills $p — none of which contains a Ps1Variable occurrence of the
name at all. Every layer above reasons about a name through its occurrences, so a name only ever
addressed this way has no binding, no reads and no writes, and a value is folded straight across the
command that changed it.
This module recognises those commands and reports what each does to which name, so the semantic model
can create the binding and file the occurrence. It is therefore a definition source consulted while
the model is built, not a decoration applied afterwards: there is nowhere to hang a decoration when
Get-Process -OutVariable x is the only mention of x in the script.
The session state's PSVariable addresses names the same way from a method call:
$ExecutionContext.SessionState.PSVariable.Set('x', 'v') writes $x. And several of these hand out
the variable itself rather than its value — Get-Variable x without -ValueOnly does — which a
store into its Value rebinds later, wherever the variable has been handed by then.
A command spelling this cannot resolve is not a command this may declare harmless — it is one whose
effect is unknown, and the conservative answer is to record that the enclosing scope writes a name
nobody can read. That is the opposite polarity from a grant table
such as refinery.lib.scripts.ps1.analysis.effects's purity allow-list, and the two must not be
confused: an allow-list that misses an entry withholds a rewrite, and a deny-list that misses one
performs a corruption.
Roles come from Ps1OccurrenceRole, the same vocabulary a
variable occurrence uses, so a consumer asks one question of both kinds of reference.
Expand source code Browse git
"""
The names a script addresses as *strings* rather than as variables.
`Set-Variable X 'v'` writes `$X`, `Get-Variable X` reads it, `Remove-Variable X` unbinds it, and
`Get-Process -OutVariable p` fills `$p` — none of which contains a `Ps1Variable` occurrence of the
name at all. Every layer above reasons about a name through its occurrences, so a name only ever
addressed this way has no binding, no reads and no writes, and a value is folded straight across the
command that changed it.
This module recognises those commands and reports what each does to which name, so the semantic model
can create the binding and file the occurrence. It is therefore a *definition source* consulted while
the model is built, not a decoration applied afterwards: there is nowhere to hang a decoration when
`Get-Process -OutVariable x` is the only mention of `x` in the script.
The session state's `PSVariable` addresses names the same way from a method call:
`$ExecutionContext.SessionState.PSVariable.Set('x', 'v')` writes `$x`. And several of these hand out
the *variable itself* rather than its value — `Get-Variable x` without `-ValueOnly` does — which a
store into its `Value` rebinds later, wherever the variable has been handed by then.
A command spelling this cannot resolve is not a command this may declare harmless — it is one whose
effect is unknown, and the conservative answer is to record that the enclosing scope writes a name
nobody can read. That is the opposite polarity from a grant table
such as `refinery.lib.scripts.ps1.analysis.effects`'s purity allow-list, and the two must not be
confused: an allow-list that misses an entry withholds a rewrite, and a deny-list that misses one
performs a corruption.
Roles come from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole`, the same vocabulary a
variable occurrence uses, so a consumer asks one question of both kinds of reference.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
from typing import Iterator
from refinery.lib.scripts import Node
from refinery.lib.scripts.ps1 import data
from refinery.lib.scripts.ps1.ast import (
argument_text,
assignment_of,
binding_key,
binds_parameter,
bound_argument_value,
free_positional_values,
get_member_name,
has_wildcard,
is_reference_cast,
resolve_command_name,
resolved_command_names,
string_value,
switch_setting,
unwrap_parens,
)
from refinery.lib.scripts.ps1.model import (
Expression,
Ps1AccessKind,
Ps1BinaryExpression,
Ps1CommandArgument,
Ps1CommandArgumentKind,
Ps1CommandInvocation,
Ps1ExpandableString,
Ps1ExpressionStatement,
Ps1InvokeMember,
Ps1MemberAccess,
Ps1ParenExpression,
Ps1Pipeline,
Ps1PipelineElement,
Ps1ScopeModifier,
Ps1SubExpression,
Ps1UnaryExpression,
Ps1Variable,
)
class Ps1NameRole(enum.Enum):
"""
What a command does to the name it addresses.
Kept apart from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole` by one member:
`UNBINDS` has no counterpart among variable occurrences, because no syntax removes a variable.
A consumer that only tracks values may read it as a replacing write — the value afterwards is
not the value before — but one that reasons about whether the name exists needs it apart.
"""
READS = enum.auto() # noqa
WRITES = enum.auto() # noqa
APPENDS = enum.auto() # noqa
UNBINDS = enum.auto() # noqa
class Ps1NameTarget(enum.Enum):
"""
Which scope the addressed name resolves in.
`LOCAL` is the scope the command itself is written in — measured, and the default: a bare
`Set-Variable x 'v'` inside a function writes that function's scope and leaves the caller's
binding alone. `SCRIPT` covers the explicitly script- or global-qualified forms. `UNREADABLE`
is every form this cannot place, of which `-Scope 1` is the one that matters: it writes the
*caller's* scope, which is not a lexical ancestor and which the scope chain cannot name.
"""
LOCAL = enum.auto() # noqa
SCRIPT = enum.auto() # noqa
UNREADABLE = enum.auto() # noqa
@dataclass(frozen=True)
class Ps1NamedReference:
"""
One command's reference to one name. `key` is keyed as
`refinery.lib.scripts.ps1.ast.binding_key` keys a variable occurrence, so a name reached both
ways lands on one binding. `node` is the command or the call performing it.
"""
key: str
role: Ps1NameRole
target: Ps1NameTarget
node: Node
#: Whether the reference also hands out the variable itself, and to something other than a
#: read of its `Value` where it stands. A store into that `Value` rebinds the name later, from
#: wherever the variable has been handed: measured, `$v = Get-Variable b; $v.Value = 'b'`
#: leaves `$b` holding `b`. Which binding of the name the variable is depends on the scopes
#: that stand around the reference when it runs.
hands_out: bool = False
#: Commands whose first argument is the *name* of a variable, mapped to what they do to it. Resolved
#: through `refinery.lib.scripts.ps1.ast.resolved_command_names`, so aliases (`sv`, `gv`, `rv`),
#: case variants and the bare noun `variable` all arrive here already canonical.
_VARIABLE_COMMANDS: dict[str, Ps1NameRole] = {
'clear-variable': Ps1NameRole.WRITES,
'get-variable': Ps1NameRole.READS,
'new-variable': Ps1NameRole.WRITES,
'remove-variable': Ps1NameRole.UNBINDS,
'set-variable': Ps1NameRole.WRITES,
}
#: Commands whose first argument is a *provider path*, which addresses a variable when it names the
#: `Variable:` or `Env:` drive. `Remove-Item Variable:x` is how `del variable:x` arrives.
_ITEM_COMMANDS: dict[str, Ps1NameRole] = {
'clear-item': Ps1NameRole.WRITES,
'get-childitem': Ps1NameRole.READS,
'get-item': Ps1NameRole.READS,
'new-item': Ps1NameRole.WRITES,
'remove-item': Ps1NameRole.UNBINDS,
'set-item': Ps1NameRole.WRITES,
}
#: The commands that write out the variable they address, on the `Variable:` drive for the item
#: commands, unless `Get-Variable` is told `-ValueOnly`.
_HANDING_OUT_COMMANDS = frozenset({
'get-childitem',
'get-item',
'get-variable',
'new-item',
})
#: The commands that write out the variable they address when told `-PassThru`.
_HANDING_OUT_WITH_PASSTHRU = frozenset({
'clear-variable',
'new-variable',
'set-item',
'set-variable',
})
#: The automatic variables whose `SessionState` holds the variable table of the scope that reads it.
_SESSION_STATE_HOLDERS = frozenset({
'executioncontext',
'pscmdlet',
})
#: The members of the holders that reach a variable table, lowercased: the session state, and
#: `InvokeProvider`, which reaches the `Variable:` drive.
_HOLDER_MEMBERS_WITH_VARIABLES = frozenset({
'invokeprovider',
'sessionstate',
})
#: The members of the session state that reach no variable, lowercased. Every other member may:
#: `PSVariable` addresses them by name, `InvokeProvider` reaches the `Variable:` drive, and `Module`
#: holds a session state of its own. `InvokeCommand` is the engine's command intrinsics, the very
#: object `$ExecutionContext.InvokeCommand` is: what it runs is code this analysis cannot read,
#: which the world model answers for, and it holds no variable table of its own.
_SESSION_STATE_MEMBERS_WITHOUT_VARIABLES = frozenset({
'applications',
'drive',
'invokecommand',
'languagemode',
'path',
'provider',
'scripts',
'usefulllanguagemodeindebugger',
})
#: The qualifiers under which a variable of the session state holders may be spelled. The engine
#: gives `$ExecutionContext` to every scope, so `$global:ExecutionContext` reaches it as surely as
#: the bare name does; only the provider namespaces that are not variables name something else.
_HOLDER_QUALIFIERS = frozenset({
Ps1ScopeModifier.NONE,
Ps1ScopeModifier.GLOBAL,
Ps1ScopeModifier.LOCAL,
Ps1ScopeModifier.PRIVATE,
Ps1ScopeModifier.SCRIPT,
Ps1ScopeModifier.USING,
Ps1ScopeModifier.VARIABLE,
})
#: The common parameters that keep what a command writes out in a variable of their own, with their
#: aliases: whatever `-OutVariable v` is handed leaves in `$v` whatever else is done with it.
_OUTPUT_KEEPING_PARAMETERS = frozenset(
spelling
for parameter in ('outvariable', 'pipelinevariable')
for spelling in (parameter, *data.COMMON_PARAMETERS[parameter])
)
#: What each method of the session state's `PSVariable` does to the name it is handed first.
_VARIABLE_INTRINSIC_METHODS: dict[str, Ps1NameRole] = {
'get': Ps1NameRole.READS,
'getvalue': Ps1NameRole.READS,
'remove': Ps1NameRole.UNBINDS,
'set': Ps1NameRole.WRITES,
}
#: Provider drives whose items are the names this reasons about, mapped to the prefix the key takes.
_NAME_DRIVES: dict[str, str] = {
'env': 'env:',
'variable': '',
}
#: Scope arguments that place the write somewhere the lexical chain can name. Anything else —
#: a number, an expression, a spelling not listed — is `Ps1NameTarget.UNREADABLE`.
_SCOPE_TARGETS: dict[str, Ps1NameTarget] = {
'global': Ps1NameTarget.SCRIPT,
'local': Ps1NameTarget.LOCAL,
'private': Ps1NameTarget.LOCAL,
'script': Ps1NameTarget.SCRIPT,
}
#: Name qualifiers written into the name string itself — `Set-Variable global:x` — which say the
#: same thing the `-Scope` argument does.
_QUALIFIER_TARGETS: dict[str, Ps1NameTarget] = {
Ps1ScopeModifier.GLOBAL.value: Ps1NameTarget.SCRIPT,
Ps1ScopeModifier.LOCAL.value: Ps1NameTarget.LOCAL,
Ps1ScopeModifier.PRIVATE.value: Ps1NameTarget.LOCAL,
Ps1ScopeModifier.SCRIPT.value: Ps1NameTarget.SCRIPT,
}
def named_references(node: Node) -> list[Ps1NamedReference]:
"""
Every reference *node* makes to a name addressed as a string, or an empty list when it makes
none. A single command may make several: `Get-Variable x -OutVariable y` reads one and writes
another. A call of the session state's `PSVariable` makes one, and so does a `[ref]` that hands
out the variable it is spelled around.
Both names a call may run are asked about, because `variable x` and `item variable:x` reach
`Get-Variable` and `Get-Item` through the implicit `Get-` retry and a table keyed on the bare
spelling misses them. At most one of the two is in either table, and reading a name a `function
variable` would have taken back only over-reports a read, which withholds a removal.
"""
if isinstance(node, Ps1Variable):
return list(_reference_references(node))
if isinstance(node, Ps1InvokeMember):
return list(_intrinsic_references(node))
if not isinstance(node, Ps1CommandInvocation):
return []
found: list[Ps1NamedReference] = []
for command in resolved_command_names(node):
found.extend(_subject_references(node, command))
found.extend(_out_variable_references(node))
return found
def unreadable_name_target(node: Node) -> Ps1NameTarget | None:
"""
Where *node* writes a variable whose name this cannot read, or `None` when it writes no such
name. The name is unknown, so *every* binding in the scope it lands in is in doubt, which is a
fact about that scope rather than about any one binding.
- `Set-Variable $n 'v'` computes the name, and `Remove-Variable x*` names every variable the
pattern matches; either lands where the command's `-Scope` says.
- `$ExecutionContext.SessionState.PSVariable.Set($n, 'v')` computes the name, and `Set($v)` is
handed a variable whose name is its own; either lands in the scope the call runs in.
- A variable handed out by a command that reads names nobody can attribute — `$v =
Get-Variable` — may be one of any scope the command could see, and a store into its `Value`
lands there later. So may a store through the session state itself, handed anywhere but to
one of the methods of its `PSVariable`. These are `Ps1NameTarget.UNREADABLE`.
A `Set-Item` whose path is computed might address the `Variable:` drive and might equally be
writing a file, and answering for every one of them would put most scripts permanently in doubt;
that is left as a known hole rather than paid for everywhere. A path that spells the drive ahead
of what it computes, `Set-Item "variable:$n" 'v'`, is no such doubt and is answered. A command
that only reads is not reported: not knowing which name was read changes no value.
The implicit `Get-` retry needs no reading here, unlike in `named_references`: it prefixes
`Get-`, so the only commands it can reach are readers, and a reader hands out a variable of a
name this cannot read only where the table names it.
"""
if isinstance(node, Ps1Variable):
return Ps1NameTarget.UNREADABLE if _leaks_the_variable_table(node) else None
if isinstance(node, Ps1InvokeMember):
return _intrinsic_unreadable_target(node)
if not isinstance(node, Ps1CommandInvocation):
return None
command = resolve_command_name(node)
if command is None:
return None
role = _VARIABLE_COMMANDS.get(command)
if role is not None:
if _subject_name(node, command, 'name') is not None and not _is_a_pattern(node, command):
return None
else:
role = _ITEM_COMMANDS.get(command)
if role is None or not _addresses_unreadable_variables(node, command):
return None
if role is not Ps1NameRole.READS:
return _declared_target(node)
if _hands_out(node, command):
return Ps1NameTarget.UNREADABLE
return None
def reads_unreadable_name(node: Node) -> bool:
"""
Whether *node* reads a variable whose name this cannot read: `Get-Variable` with no name, with
a pattern or with a name it computes, `Get-ChildItem variable:`, its patterns and a name it
computes after the drive, a `GetValue` or a `Get` of the session state's `PSVariable` handed a
name it computes, and the session state handed anywhere but to one of the methods of its
`PSVariable`. Such a read may observe every name, so no store is dead for want of a read of it.
"""
if isinstance(node, Ps1Variable):
return _leaks_the_variable_table(node)
if isinstance(node, Ps1InvokeMember):
method = _intrinsic_method(node)
if method is None:
return False
role = _VARIABLE_INTRINSIC_METHODS.get(method)
if role is None:
return True
if role is not Ps1NameRole.READS or _intrinsic_name(node) is not None:
return False
return method != 'get' or _left_by(node) is not _Left.NAMES
if not isinstance(node, Ps1CommandInvocation):
return False
for command in resolved_command_names(node):
if _VARIABLE_COMMANDS.get(command) is Ps1NameRole.READS:
if _subject_name(node, command, 'name') is not None:
if not _is_a_pattern(node, command):
continue
elif _ITEM_COMMANDS.get(command) is not Ps1NameRole.READS:
continue
elif not _addresses_unreadable_variables(node, command):
continue
if _reads_values(node, command):
return True
return False
def addresses_unreadable_name(cmd: Node) -> bool:
"""
Whether *cmd* writes a variable whose name this cannot read. `unreadable_name_target` says
where.
"""
return unreadable_name_target(cmd) is not None
def _subject_references(
cmd: Ps1CommandInvocation, command: str,
) -> Iterator[Ps1NamedReference]:
"""
The reference a variable or item command makes to the name it is *about*.
"""
role = _VARIABLE_COMMANDS.get(command)
if role is not None:
written = _subject_name(cmd, command, 'name')
if written is not None and not has_wildcard(written):
yield from _resolve(
cmd, written, role, _declared_target(cmd), _hands_out(cmd, command))
return
role = _ITEM_COMMANDS.get(command)
if role is None:
return
written = _subject_name(cmd, command, 'path')
if written is None:
return
drive, rest = _drive_path(written)
prefix = _NAME_DRIVES.get(drive)
if prefix is None or not rest or has_wildcard(rest):
return
yield Ps1NamedReference(
key=F'{prefix}{rest.lower()}',
role=role,
target=Ps1NameTarget.SCRIPT if prefix else _declared_target(cmd),
node=cmd,
hands_out=not prefix and _hands_out(cmd, command),
)
def _out_variable_references(cmd: Ps1CommandInvocation) -> Iterator[Ps1NamedReference]:
"""
The references a command's out-variable parameters make. `-OutVariable p` replaces `$p`;
`-OutVariable +p` keeps what was there and appends, which reads the name as well as writing it.
"""
for parameter in data.OUT_VARIABLE_PARAMETERS:
value = bound_argument_value(cmd, parameter)
if value is None:
continue
written = string_value(value)
if written is None:
continue
role = Ps1NameRole.WRITES
if written.startswith('+'):
role = Ps1NameRole.APPENDS
written = written[1:]
if written:
yield from _resolve(cmd, written, role, Ps1NameTarget.LOCAL)
def _subject_value(
cmd: Ps1CommandInvocation, command: str, parameter: str,
) -> Expression | None:
"""
The argument a command is about, written either as `-Name x` or as the first positional
argument, or `None` when it has none.
The positional fallback skips the arguments a preceding value-taking switch consumed, which is
what tells `Set-Variable -Scope Global x 5` — where the name is `x` — from a reading of the
argument list that would call it `Global`.
"""
explicit = bound_argument_value(cmd, parameter)
if explicit is not None:
return explicit
for value in free_positional_values(cmd, command):
return value
return None
def _subject_name(cmd: Ps1CommandInvocation, command: str, parameter: str) -> str | None:
"""
The literal name a command is about, or `None` when it has none or it is not a literal this can
read.
A name written as a number is a literal like any other and is read through
`refinery.lib.scripts.ps1.ast.argument_text`. Reading it with `string_value` answers `None`,
which says the name is *computed* and puts every binding in the enclosing scope in doubt over a
name that is sitting in the source.
"""
return argument_text(_subject_value(cmd, command, parameter))
def _declared_target(cmd: Ps1CommandInvocation) -> Ps1NameTarget:
"""
The scope a command's `-Scope` argument names, `Ps1NameTarget.LOCAL` when it has none — the
measured default — and `Ps1NameTarget.UNREADABLE` for a spelling the lexical chain cannot place,
of which `-Scope 1` is the one that occurs.
"""
declared = bound_argument_value(cmd, 'scope')
if declared is None:
return Ps1NameTarget.LOCAL
written = string_value(declared)
if written is None:
return Ps1NameTarget.UNREADABLE
return _SCOPE_TARGETS.get(written.lower(), Ps1NameTarget.UNREADABLE)
def _resolve(
node: Node,
written: str,
role: Ps1NameRole,
target: Ps1NameTarget,
hands_out: bool = False,
) -> Iterator[Ps1NamedReference]:
"""
One reference, with a qualifier written into the name string resolved against *target*.
"""
qualifier, _, rest = written.partition(':')
if rest:
lowered = qualifier.lower()
prefix = _NAME_DRIVES.get(lowered)
if prefix is not None:
yield Ps1NamedReference(
key=F'{prefix}{rest.lower()}',
role=role,
target=Ps1NameTarget.SCRIPT if prefix else target,
node=node,
hands_out=hands_out and not prefix,
)
return
placed = _QUALIFIER_TARGETS.get(lowered)
if placed is None:
return
yield Ps1NamedReference(
key=rest.lower(), role=role, target=placed, node=node, hands_out=hands_out)
return
yield Ps1NamedReference(
key=written.lower(), role=role, target=target, node=node, hands_out=hands_out)
def _is_a_pattern(cmd: Ps1CommandInvocation, command: str) -> bool:
"""
Whether the name a variable command is about is a wildcard pattern, which addresses every
variable it matches rather than one.
"""
written = _subject_name(cmd, command, 'name')
return written is not None and has_wildcard(written)
def _addresses_unreadable_variables(cmd: Ps1CommandInvocation, command: str) -> bool:
"""
Whether an item command addresses variables on the `Variable:` drive whose names this cannot
read: the whole drive, as `Get-ChildItem variable:` lists it, a pattern over it, or a name the
source computes after spelling the drive, as `Get-Item "variable:$n"` does.
"""
value = _subject_value(cmd, command, 'path')
if value is None:
return False
written = argument_text(value)
if written is None:
return _spelled_drive(value) == 'variable'
drive, rest = _drive_path(written)
if drive != 'variable':
return False
return not rest or has_wildcard(rest)
def _spelled_drive(value: Expression) -> str | None:
"""
The drive a path the source computes spells ahead of what it computes, lowercased, or `None`
where it spells none: `"variable:$n"`, `'variable:' + $n` and `'variable:{0}' -f $n` all name
the `Variable:` drive, whatever `$n` holds.
"""
head: Node = unwrap_parens(value)
while (
isinstance(head, Ps1BinaryExpression)
and head.operator == '+'
and head.left is not None
):
head = unwrap_parens(head.left)
if (
isinstance(head, Ps1BinaryExpression)
and head.operator == '-f'
and head.left is not None
):
pattern = string_value(unwrap_parens(head.left))
text = None if pattern is None else pattern.partition('{')[0]
elif isinstance(head, Ps1ExpandableString) and head.parts:
text = string_value(head.parts[0])
else:
text = string_value(head)
if text is None or ':' not in text:
return None
return _drive_path(text)[0]
def _drive_path(written: str) -> tuple[str, str]:
"""
The drive a provider path names, lowercased, and the path of the item on it without the
separator that roots it: `variable:\\x`, `variable:/x`, the provider-qualified `variable::x`
and `variable:x` all address `$x`, and `variable:\\` is the whole drive as `variable:` is.
"""
drive, _, rest = written.partition(':')
return drive.lower(), rest.lstrip('\\/:')
class _Left(enum.Enum):
"""
What is used of the variables a command or a call writes out, where it stands.
"""
#: Only their names are read, as `(Get-Variable '*mdr*').Name` reads them: no value is
#: observed and no variable is handed on.
NAMES = enum.auto()
#: Only their values are read, as `(Get-Variable b).Value` reads it.
VALUES = enum.auto()
#: The variables themselves leave: kept, piped, handed on, stored into or asked a method.
VARIABLES = enum.auto()
def _left_by(node: Node) -> _Left:
"""
What is used of the variables *node* writes out, where it stands. Only a property read where
the node stands is anything less than the variables leaving; a store into `Value` rebinds the
name, and a method of the variable is code this does not read.
"""
cursor: Node = node
parent = cursor.parent
if isinstance(parent, Ps1PipelineElement):
pipeline = parent.parent
if parent.redirections or not isinstance(pipeline, Ps1Pipeline):
return _Left.VARIABLES
if len(pipeline.elements) != 1:
return _Left.VARIABLES
cursor, parent = pipeline, pipeline.parent
if isinstance(parent, Ps1ExpressionStatement):
holder = parent.parent
if not isinstance(holder, Ps1SubExpression) or len(holder.body) != 1:
return _Left.VARIABLES
cursor, parent = holder, holder.parent
while isinstance(parent, Ps1ParenExpression):
cursor, parent = parent, parent.parent
if not isinstance(parent, Ps1MemberAccess) or parent.object is not cursor:
return _Left.VARIABLES
if parent.access is not Ps1AccessKind.INSTANCE or _is_stored_into(parent):
return _Left.VARIABLES
name = get_member_name(parent.member)
if name is None:
return _Left.VARIABLES
return {'name': _Left.NAMES, 'value': _Left.VALUES}.get(name.lower(), _Left.VARIABLES)
def _writes_out_variables(cmd: Ps1CommandInvocation, command: str) -> bool:
"""
Whether *cmd* may write out the variables it addresses rather than their values or nothing. A
switch bound to a value this cannot read may be either way, so it counts on the side where the
variables leave: `-ValueOnly:$v` may write them out and `-PassThru:$v` may too.
"""
if command in _HANDING_OUT_COMMANDS:
return command != 'get-variable' or switch_setting(cmd, 'valueonly') is not True
return command in _HANDING_OUT_WITH_PASSTHRU and switch_setting(cmd, 'passthru') is not False
def _hands_out(cmd: Ps1CommandInvocation, command: str) -> bool:
"""
Whether *cmd* hands the variables it addresses to something other than a read of a property
where it stands. What it writes out also leaves in the variable an `-OutVariable` or a
`-PipelineVariable` names, whatever is read of it where it stands.
"""
if not _writes_out_variables(cmd, command):
return False
return _left_by(cmd) is _Left.VARIABLES or any(
isinstance(argument, Ps1CommandArgument)
and argument.kind is not Ps1CommandArgumentKind.POSITIONAL
and any(binds_parameter(argument.name, kept) for kept in _OUTPUT_KEEPING_PARAMETERS)
for argument in cmd.arguments
)
def _reads_values(cmd: Ps1CommandInvocation, command: str) -> bool:
"""
Whether what *cmd* writes out may be used for the values of the variables it addresses: always
for values, and for the variables themselves unless only their names are read.
"""
return not _writes_out_variables(cmd, command) or _left_by(cmd) is not _Left.NAMES
def _is_stored_into(node: Node) -> bool:
"""
Whether *node* is what an assignment stores into — through parentheses and as one slot of a
multi-assignment, the way `refinery.lib.scripts.ps1.ast.assignment_of` reads a target — or
what an increment stores into.
"""
if assignment_of(node) is not None:
return True
cursor = node
parent = cursor.parent
while isinstance(parent, Ps1ParenExpression):
cursor, parent = parent, parent.parent
return (
isinstance(parent, Ps1UnaryExpression)
and parent.operator in ('++', '--')
and parent.operand is cursor
)
def _is_a_session_state_holder(node: Node | None) -> bool:
return (
isinstance(node, Ps1Variable)
and node.scope in _HOLDER_QUALIFIERS
and node.name.lower() in _SESSION_STATE_HOLDERS
)
def _member_of(node: Node | None, name: str) -> Node | None:
"""
What *node* reads the instance member *name* off, through parentheses, or `None` where it is no
such member access.
"""
if node is None:
return None
node = unwrap_parens(node)
if not isinstance(node, Ps1MemberAccess) or node.access is not Ps1AccessKind.INSTANCE:
return None
spelled = get_member_name(node.member)
if spelled is None or spelled.lower() != name or node.object is None:
return None
return unwrap_parens(node.object)
def _intrinsic_method(call: Ps1InvokeMember) -> str | None:
"""
The lowercased method *call* runs on `$ExecutionContext.SessionState.PSVariable`, or on the
same reached from `$PSCmdlet`, the empty string for one whose name the source does not spell,
and `None` where *call* is no call of it. `$PSCmdlet.GetVariableValue` reads a variable by name
the way `GetValue` does and is answered as `getvalue`.
"""
if call.access is not Ps1AccessKind.INSTANCE:
return None
name = get_member_name(call.member)
table = _member_of(_member_of(call.object, 'psvariable'), 'sessionstate')
if _is_a_session_state_holder(table):
return '' if name is None else name.lower()
holder = unwrap_parens(call.object) if call.object is not None else None
if (
name is not None
and name.lower() == 'getvariablevalue'
and _is_a_session_state_holder(holder)
and isinstance(holder, Ps1Variable)
and holder.name.lower() == 'pscmdlet'
):
return 'getvalue'
return None
def _intrinsic_name(call: Ps1InvokeMember) -> str | None:
"""
The name a call of the session state's `PSVariable` is handed, where the source spells it.
`Set` and `Remove` handed a single argument other than a name are handed the variable itself.
"""
if not call.arguments:
return None
return string_value(call.arguments[0])
def _intrinsic_references(call: Ps1InvokeMember) -> Iterator[Ps1NamedReference]:
"""
The reference a call of the session state's `PSVariable` makes to the name it is handed, which
it resolves the way the commands do: in the scope the call runs in unless the name is qualified.
`Get` hands out the variable itself.
"""
method = _intrinsic_method(call)
if not method:
return
role = _VARIABLE_INTRINSIC_METHODS.get(method)
written = _intrinsic_name(call)
if role is None or written is None or has_wildcard(written):
return
hands_out = method == 'get' and _left_by(call) is _Left.VARIABLES
yield from _resolve(call, written, role, Ps1NameTarget.LOCAL, hands_out)
def _reference_references(var: Ps1Variable) -> Iterator[Ps1NamedReference]:
"""
The variable a `[ref]` spelled around *var* hands out. `$r = [ref]$y` keeps a reference to the
variable `$y` itself, and a store into `$r.Value` rebinds `$y` wherever the reference has gone
by then. A reference handed straight to a .NET call as one of its arguments is the parameter
the call fills by reference, and it leaves with nothing once the call returns.
"""
cast = var.parent
if not is_reference_cast(cast):
return
cursor: Node = cast
parent = cursor.parent
while isinstance(parent, Ps1ParenExpression):
cursor, parent = parent, parent.parent
if isinstance(parent, Ps1InvokeMember) and any(
argument is cursor for argument in parent.arguments
):
return
yield Ps1NamedReference(
key=binding_key(var),
role=Ps1NameRole.READS,
target=Ps1NameTarget.LOCAL,
node=var,
hands_out=True,
)
def _intrinsic_unreadable_target(call: Ps1InvokeMember) -> Ps1NameTarget | None:
"""
Where a call of the session state's `PSVariable` writes a variable whose name this cannot read:
in the scope it runs in for a `Set` or a `Remove` handed no name it spells, and in any scope for
a `Get` whose variable leaves or a method this cannot name.
"""
method = _intrinsic_method(call)
if method is None:
return None
role = _VARIABLE_INTRINSIC_METHODS.get(method)
if role is None:
return Ps1NameTarget.UNREADABLE
written = _intrinsic_name(call)
if written is not None and not has_wildcard(written):
return None
if role is not Ps1NameRole.READS:
return Ps1NameTarget.LOCAL
if method == 'get' and _left_by(call) is _Left.VARIABLES:
return Ps1NameTarget.UNREADABLE
return None
def _leaks_the_variable_table(var: Ps1Variable) -> bool:
"""
Whether *var* is `$ExecutionContext` or `$PSCmdlet` used in a way that may reach a variable by a
name this cannot read. A member that reaches no variable — `$ExecutionContext.InvokeCommand`,
`$ExecutionContext.SessionState.Path` — leaks nothing, and neither does a call of one of the
methods of the session state's `PSVariable`, which `named_references` reads. Anything else —
the holder or its session state kept, handed on or asked for a member whose name the source
does not spell — is the whole variable table.
"""
if not _is_a_session_state_holder(var):
return False
state = _next_member(var)
if state is None:
return True
access, name = state
if name is None:
return True
if isinstance(access, Ps1InvokeMember) or name not in _HOLDER_MEMBERS_WITH_VARIABLES:
return False
if name == 'invokeprovider':
return True
table = _next_member(access)
if table is None:
return True
access, name = table
if name is None:
return True
if name in _SESSION_STATE_MEMBERS_WITHOUT_VARIABLES:
return isinstance(access, Ps1InvokeMember)
if name != 'psvariable' or isinstance(access, Ps1InvokeMember):
return True
call = access.parent
return not isinstance(call, Ps1InvokeMember) or call.object is not access
def _next_member(node: Node) -> tuple[Ps1MemberAccess | Ps1InvokeMember, str | None] | None:
"""
The member access or call made on *node*, through parentheses, with the lowercased name of its
member, or `None` where *node* is not the object of one.
"""
cursor = node
parent = cursor.parent
while isinstance(parent, Ps1ParenExpression):
cursor, parent = parent, parent.parent
if not isinstance(parent, (Ps1MemberAccess, Ps1InvokeMember)) or parent.object is not cursor:
return None
if parent.access is not Ps1AccessKind.INSTANCE:
return None
name = get_member_name(parent.member)
return parent, None if name is None else name.lower()
Functions
def named_references(node)-
Every reference node makes to a name addressed as a string, or an empty list when it makes none. A single command may make several:
Get-Variable x -OutVariable yreads one and writes another. A call of the session state'sPSVariablemakes one, and so does a[ref]that hands out the variable it is spelled around.Both names a call may run are asked about, because
variable xanditem variable:xreachGet-VariableandGet-Itemthrough the implicitGet-retry and a table keyed on the bare spelling misses them. At most one of the two is in either table, and reading a name afunction variablewould have taken back only over-reports a read, which withholds a removal.Expand source code Browse git
def named_references(node: Node) -> list[Ps1NamedReference]: """ Every reference *node* makes to a name addressed as a string, or an empty list when it makes none. A single command may make several: `Get-Variable x -OutVariable y` reads one and writes another. A call of the session state's `PSVariable` makes one, and so does a `[ref]` that hands out the variable it is spelled around. Both names a call may run are asked about, because `variable x` and `item variable:x` reach `Get-Variable` and `Get-Item` through the implicit `Get-` retry and a table keyed on the bare spelling misses them. At most one of the two is in either table, and reading a name a `function variable` would have taken back only over-reports a read, which withholds a removal. """ if isinstance(node, Ps1Variable): return list(_reference_references(node)) if isinstance(node, Ps1InvokeMember): return list(_intrinsic_references(node)) if not isinstance(node, Ps1CommandInvocation): return [] found: list[Ps1NamedReference] = [] for command in resolved_command_names(node): found.extend(_subject_references(node, command)) found.extend(_out_variable_references(node)) return found def unreadable_name_target(node)-
Where node writes a variable whose name this cannot read, or
Nonewhen it writes no such name. The name is unknown, so every binding in the scope it lands in is in doubt, which is a fact about that scope rather than about any one binding.Set-Variable $n 'v'computes the name, andRemove-Variable x*names every variable the pattern matches; either lands where the command's-Scopesays.$ExecutionContext.SessionState.PSVariable.Set($n, 'v')computes the name, andSet($v)is handed a variable whose name is its own; either lands in the scope the call runs in.- A variable handed out by a command that reads names nobody can attribute —
$v = Get-Variable— may be one of any scope the command could see, and a store into itsValuelands there later. So may a store through the session state itself, handed anywhere but to one of the methods of itsPSVariable. These arePs1NameTarget.UNREADABLE.
A
Set-Itemwhose path is computed might address theVariable:drive and might equally be writing a file, and answering for every one of them would put most scripts permanently in doubt; that is left as a known hole rather than paid for everywhere. A path that spells the drive ahead of what it computes,Set-Item "variable:$n" 'v', is no such doubt and is answered. A command that only reads is not reported: not knowing which name was read changes no value.The implicit
Get-retry needs no reading here, unlike innamed_references(): it prefixesGet-, so the only commands it can reach are readers, and a reader hands out a variable of a name this cannot read only where the table names it.Expand source code Browse git
def unreadable_name_target(node: Node) -> Ps1NameTarget | None: """ Where *node* writes a variable whose name this cannot read, or `None` when it writes no such name. The name is unknown, so *every* binding in the scope it lands in is in doubt, which is a fact about that scope rather than about any one binding. - `Set-Variable $n 'v'` computes the name, and `Remove-Variable x*` names every variable the pattern matches; either lands where the command's `-Scope` says. - `$ExecutionContext.SessionState.PSVariable.Set($n, 'v')` computes the name, and `Set($v)` is handed a variable whose name is its own; either lands in the scope the call runs in. - A variable handed out by a command that reads names nobody can attribute — `$v = Get-Variable` — may be one of any scope the command could see, and a store into its `Value` lands there later. So may a store through the session state itself, handed anywhere but to one of the methods of its `PSVariable`. These are `Ps1NameTarget.UNREADABLE`. A `Set-Item` whose path is computed might address the `Variable:` drive and might equally be writing a file, and answering for every one of them would put most scripts permanently in doubt; that is left as a known hole rather than paid for everywhere. A path that spells the drive ahead of what it computes, `Set-Item "variable:$n" 'v'`, is no such doubt and is answered. A command that only reads is not reported: not knowing which name was read changes no value. The implicit `Get-` retry needs no reading here, unlike in `named_references`: it prefixes `Get-`, so the only commands it can reach are readers, and a reader hands out a variable of a name this cannot read only where the table names it. """ if isinstance(node, Ps1Variable): return Ps1NameTarget.UNREADABLE if _leaks_the_variable_table(node) else None if isinstance(node, Ps1InvokeMember): return _intrinsic_unreadable_target(node) if not isinstance(node, Ps1CommandInvocation): return None command = resolve_command_name(node) if command is None: return None role = _VARIABLE_COMMANDS.get(command) if role is not None: if _subject_name(node, command, 'name') is not None and not _is_a_pattern(node, command): return None else: role = _ITEM_COMMANDS.get(command) if role is None or not _addresses_unreadable_variables(node, command): return None if role is not Ps1NameRole.READS: return _declared_target(node) if _hands_out(node, command): return Ps1NameTarget.UNREADABLE return None def reads_unreadable_name(node)-
Whether node reads a variable whose name this cannot read:
Get-Variablewith no name, with a pattern or with a name it computes,Get-ChildItem variable:, its patterns and a name it computes after the drive, aGetValueor aGetof the session state'sPSVariablehanded a name it computes, and the session state handed anywhere but to one of the methods of itsPSVariable. Such a read may observe every name, so no store is dead for want of a read of it.Expand source code Browse git
def reads_unreadable_name(node: Node) -> bool: """ Whether *node* reads a variable whose name this cannot read: `Get-Variable` with no name, with a pattern or with a name it computes, `Get-ChildItem variable:`, its patterns and a name it computes after the drive, a `GetValue` or a `Get` of the session state's `PSVariable` handed a name it computes, and the session state handed anywhere but to one of the methods of its `PSVariable`. Such a read may observe every name, so no store is dead for want of a read of it. """ if isinstance(node, Ps1Variable): return _leaks_the_variable_table(node) if isinstance(node, Ps1InvokeMember): method = _intrinsic_method(node) if method is None: return False role = _VARIABLE_INTRINSIC_METHODS.get(method) if role is None: return True if role is not Ps1NameRole.READS or _intrinsic_name(node) is not None: return False return method != 'get' or _left_by(node) is not _Left.NAMES if not isinstance(node, Ps1CommandInvocation): return False for command in resolved_command_names(node): if _VARIABLE_COMMANDS.get(command) is Ps1NameRole.READS: if _subject_name(node, command, 'name') is not None: if not _is_a_pattern(node, command): continue elif _ITEM_COMMANDS.get(command) is not Ps1NameRole.READS: continue elif not _addresses_unreadable_variables(node, command): continue if _reads_values(node, command): return True return False def addresses_unreadable_name(cmd)-
Whether cmd writes a variable whose name this cannot read.
unreadable_name_target()says where.Expand source code Browse git
def addresses_unreadable_name(cmd: Node) -> bool: """ Whether *cmd* writes a variable whose name this cannot read. `unreadable_name_target` says where. """ return unreadable_name_target(cmd) is not None
Classes
class Ps1NameRole (*args, **kwds)-
What a command does to the name it addresses.
Kept apart from
Ps1OccurrenceRoleby one member:UNBINDShas no counterpart among variable occurrences, because no syntax removes a variable. A consumer that only tracks values may read it as a replacing write — the value afterwards is not the value before — but one that reasons about whether the name exists needs it apart.Expand source code Browse git
class Ps1NameRole(enum.Enum): """ What a command does to the name it addresses. Kept apart from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole` by one member: `UNBINDS` has no counterpart among variable occurrences, because no syntax removes a variable. A consumer that only tracks values may read it as a replacing write — the value afterwards is not the value before — but one that reasons about whether the name exists needs it apart. """ READS = enum.auto() # noqa WRITES = enum.auto() # noqa APPENDS = enum.auto() # noqa UNBINDS = enum.auto() # noqaAncestors
- enum.Enum
Class variables
var READS-
The type of the None singleton.
var WRITES-
The type of the None singleton.
var APPENDS-
The type of the None singleton.
var UNBINDS-
The type of the None singleton.
class Ps1NameTarget (*args, **kwds)-
Which scope the addressed name resolves in.
LOCALis the scope the command itself is written in — measured, and the default: a bareSet-Variable x 'v'inside a function writes that function's scope and leaves the caller's binding alone.SCRIPTcovers the explicitly script- or global-qualified forms.UNREADABLEis every form this cannot place, of which-Scope 1is the one that matters: it writes the caller's scope, which is not a lexical ancestor and which the scope chain cannot name.Expand source code Browse git
class Ps1NameTarget(enum.Enum): """ Which scope the addressed name resolves in. `LOCAL` is the scope the command itself is written in — measured, and the default: a bare `Set-Variable x 'v'` inside a function writes that function's scope and leaves the caller's binding alone. `SCRIPT` covers the explicitly script- or global-qualified forms. `UNREADABLE` is every form this cannot place, of which `-Scope 1` is the one that matters: it writes the *caller's* scope, which is not a lexical ancestor and which the scope chain cannot name. """ LOCAL = enum.auto() # noqa SCRIPT = enum.auto() # noqa UNREADABLE = enum.auto() # noqaAncestors
- enum.Enum
Class variables
var LOCAL-
The type of the None singleton.
var SCRIPT-
The type of the None singleton.
var UNREADABLE-
The type of the None singleton.
class Ps1NamedReference (key, role, target, node, hands_out=False)-
One command's reference to one name.
keyis keyed asbinding_key()keys a variable occurrence, so a name reached both ways lands on one binding.nodeis the command or the call performing it.Expand source code Browse git
@dataclass(frozen=True) class Ps1NamedReference: """ One command's reference to one name. `key` is keyed as `refinery.lib.scripts.ps1.ast.binding_key` keys a variable occurrence, so a name reached both ways lands on one binding. `node` is the command or the call performing it. """ key: str role: Ps1NameRole target: Ps1NameTarget node: Node #: Whether the reference also hands out the variable itself, and to something other than a #: read of its `Value` where it stands. A store into that `Value` rebinds the name later, from #: wherever the variable has been handed: measured, `$v = Get-Variable b; $v.Value = 'b'` #: leaves `$b` holding `b`. Which binding of the name the variable is depends on the scopes #: that stand around the reference when it runs. hands_out: bool = FalseInstance variables
var key-
The type of the None singleton.
var role-
The type of the None singleton.
var target-
The type of the None singleton.
var node-
The type of the None singleton.
var hands_out-
Whether the reference also hands out the variable itself, and to something other than a read of its
Valuewhere it stands. A store into thatValuerebinds the name later, from wherever the variable has been handed: measured,$v = Get-Variable b; $v.Value = 'b'leaves$bholdingb. Which binding of the name the variable is depends on the scopes that stand around the reference when it runs.