diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index fff8fc2..c16473b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -256,8 +256,10 @@ priorities. roles, ancestry, completeness, nullable decoded spans, compatibility operators, and exact shared `Clause` identity. The strict DTO rejects unknown fields and always requires unparseable projections to be empty. - The PowerShell manifest now owns all 361 entries and round-trips exactly; - explicit false/null assertions remain opt-in and generator-preserved. + The PowerShell manifest owns the first 361 entries and round-trips them + exactly; isolated-state v0.3 entries 362-372 remain explicitly curated + until the generator accepts a case-specific initial-state mode. Explicit + false/null assertions remain opt-in and generator-preserved. - [x] Deliver the first Bash `$()` substitution slice for supported simple-command arguments and redirect targets. Direct tests and corpus entries pin multiple and nested ordering, exact ancestry/spans, isolated @@ -303,7 +305,7 @@ priorities. transfers, and occurrence-specific redirect values remain fail closed. Next add the Netclaw approval matrix before calling the Bash consumer integration complete. -- [ ] Complete PowerShell `foreach` value and state analysis and add the +- [ ] Complete PowerShell `foreach` integration and add the Netclaw approval-matrix cases. The structural slice now preserves literal scalar/array and executable iterator forms, recursively parses bodies, projects iterator and loop-body ancestry, survives decoded wrappers, and @@ -326,15 +328,29 @@ priorities. guards a pinned documented preference inventory plus fresh-host built-ins with a live PowerShell oracle, composes case-insensitive distinct nested bindings, and leaves pipeline - objects, null, overflow, wrappers, redirects, same-name overwrites, and - post-loop state conservative. Task 7.4 must add ordered persistent scope - and location transfers without restoring parser-frame bindings. Expand the - design corpus for cardinality, mutation, separators, wrappers, redirects, - and transition caps before tasks 7.4-7.7. The simple-command slice is + objects, null, overflow, wrappers, and redirects conservative. The + PowerShell-specific state pass now owns ordered persistent bindings, + same-name overwrites, empty and zero-or-more execution, occurrence joins, + failure-aware `Set-Location`, current-runspace subexpressions, child-host + isolation, and the shared 4096-transition budget. Parser-frame location + attribution is cloned so empty bodies do not leak and possibly reached + mutations cannot leave a false exact cwd; outcome projection rebases exact + failure continuations and sanitizes unknown joins. The mutation inventory + inspects both verbs and parameter binding: common variable writers, + PowerShell 7 command-specific writers, accepted abbreviations and inline + values, and opaque splats invalidate later observing proofs, including + both outcome partitions of specialized `Set-Location` analysis. Alternate + binder dashes and unsupported module-qualified cmdlets now fail closed + consistently inside structural regions. Computed `Invoke-Expression` + invalidates later current-runspace binding, command-resolution, and cwd + proofs and fails atomically as an unmodeled loop transfer. Expand the design corpus for + aliases, cmdlets/native commands, pipelines, wrappers, redirects, and the + remaining adversarial/oracle matrix before tasks 7.5-7.7. The + simple-command slice is delivered for ordinary, adjacent, quoted, here-string, redirect, standalone, call-operator, dynamic-identity, and host-wrapper positions, with current-scope state propagation and bounded expression rejection pinned - by the 361-entry executable corpus. + by the 372-entry executable corpus. - [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two language-specific vertical slices, then extract only the shared analysis proven by both implementations. diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 5e62909..197a35a 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -452,6 +452,25 @@ Recognized variable, alias, function, or module mutation invalidates later proofs in every observing scope; cwd-only mutation retains the independent initial-state assertion. +Mutation is recognized from the effective parameter vector as well as the +verb. Common parameter writers `-OutVariable` / `-ov`, `-PipelineVariable` / +`-pv`, `-ErrorVariable` / `-ev`, `-WarningVariable` / `-wv`, and +`-InformationVariable` / `-iv` invalidate later observing proofs, including +accepted unambiguous prefixes such as `-OutV` and `-PipelineV` and inline forms +such as `-ov:name`. Command-specific writers include `Tee-Object -Variable`, +`Import-LocalizedData -BindingVariable` / `-Variable`, +`Invoke-RestMethod -SessionVariable` / `-SV`, `-ResponseHeadersVariable` / +`-RHV`, and `-StatusCodeVariable`, plus `Invoke-WebRequest -SessionVariable` / +`-SV`; their accepted unambiguous prefixes have the same effect. An opaque +splat may supply any such parameter and is therefore a possible mutation. +Because command type and custom advanced-function metadata are runtime facts, +an otherwise unclassified command carrying one of the common writer forms is +treated conservatively rather than assumed native. +When `Set-Location` carries a recognized writer, the writer effect composes +with both its success and failure cwd outcomes. In particular, a failure-gated +continuation after `-ErrorVariable` cannot retain a proved prior value merely +because location analysis selected the failure partition. + A completely delimited `$()` used as an ordinary word, dynamic command identity after `&`, redirect value, foreach expression, double-quoted interpolation, or expandable here-string is recursively parsed as a command substitution. Its @@ -484,6 +503,35 @@ does not isolate location. Branch exits retain an exact cwd only when every supported alternative agrees; disagreement becomes `Unknown`. Loop exits include the zero-iteration state. The parser does not publish a finite cwd set. +`foreach` assignments use a case-insensitive persistent binding map rather than +lexical push/pop restoration. A proved nonempty ordered iterable leaves its +last assigned value after the loop; a same-name nested loop overwrites that +value. A proved empty iterable performs no body transition and preserves the +incoming binding and location. A zero-or-more iterable joins the zero path with +all reachable iteration exits. Repeated visits join facts for each authored +occurrence, and all concrete or fixed-point visits share the parse-wide 4096 +transition budget. + +`Set-Location` has separate success and failure transfers: success takes the +proved filesystem target location, while failure retains the incoming location. +A successful non-filesystem or unproved target invalidates binding and +command-resolution proofs in addition to making cwd unknown. `&&` +continues from success, `||` from failure, and statement sequence consumes +their join. `$()` and parenthesized groups share current-runspace state; +decoded child hosts isolate their exit state. Unsupported directory-stack or +state/command-resolution mutations, including `Import-Alias`, +`Import-PSSession`, and `New-Module`, remain fail +closed. Provider-capable item +mutators invalidate binding proofs when their target provider is not proved; +dynamic values alone do not invalidate a proved filesystem target. Loop parsing uses cloned +compatibility attribution so an unreachable body cannot leak a parse-time +location, and a possibly reached mutation cannot leave a false exact path. +Outcome projection also rebases cwd-dependent compatibility arguments, clause +elements, redirects, and attribution to an exact occurrence cwd. Unknown joins +clear those resolutions and retain the `` marker. Decoded child +hosts retain inherited invocation-cwd attribution on their compatibility +leaves while isolating child exit state. + Stable v0.3 continues to defer `do`, `switch`, functions, definitions, class/type bodies, and arbitrary execution-bearing expressions outside the bounded forms above. @@ -775,6 +823,14 @@ the values those parameters consume do **not** advance the index. So in The colon form `-Name:value` (§5) always binds — `-Name` is value-binding, `value` its value — regardless of the tables below. +PowerShell accepts U+2013 EN DASH, U+2014 EM DASH, and U+2015 HORIZONTAL BAR +in place of the leading ASCII parameter dash. Stable v0.3 deliberately fails +those forms atomically. The retained v0.2 `Arg.IsFlag` member derives from an +ASCII `-` in verbatim `Arg.Raw`; treating an alternate dash as a positional +literal is unsafe, while normalizing `Raw` would violate source provenance. +Support therefore requires a later additive representation that can preserve +both facts. + #### 6.5.2 The binding tables Two case-insensitive static tables drive the decision, keyed by @@ -1126,8 +1182,12 @@ interprets the block. PowerShell `$()` runs in the current runspace scope. A `Set-Location` inside a subexpression affects later inner commands, the containing command after value evaluation, and following outer commands. Unknown location mutations propagate -as unknown. This differs from Bash command substitution, whose state is -isolated from the containing shell. +as unknown. Because `Set-Location` can fail, an ungated statement sequence also +joins the prior location; a success-gated `&&` continuation may use the proved +new location. The v0.2 compatibility leaf remains authored evidence, while the +v0.3 occurrence analysis is the failure-aware security fact and sanitizes stale +exact compatibility attribution. This differs from Bash command substitution, +whose state is isolated from the containing shell. `OpaqueRegionScanner` is grammar-agnostic but escapes on backslash; the PowerShell script-block, array, and hash paths give it a backtick-escape mode @@ -1247,12 +1307,17 @@ missing payloads, and ambiguous parameter binding set `ParsedCommand.IsUnparseable = true`; an incoming pipeline is dynamic even when an explicit literal argument also appears. These rules prevent a clean, persistently approvable `Invoke-Expression` clause from hiding runtime code. -Because computed code can call `Set-Location` in the current scope, a direct -dynamic payload also makes location attribution dynamic for every following -relative path. +Because computed code can mutate variables, aliases, functions, modules, and +location in the current scope, a direct dynamic payload invalidates every +following binding and command-resolution proof and makes location attribution +dynamic for every following relative path. The same rule applies to `iex`, a +static call-operator spelling, and the supported module-qualified spelling. The dot-source invocation operator and unsupported module-qualified cmdlets -are unparseable rather than being exposed under a misleading raw verb. The +are unparseable rather than being exposed under a misleading raw verb. This +validation applies independently to every simple command inside structural +lists, pipelines, loops, groups, and substitutions, including built-in +cmdlets such as `Tee-Object` whose verb is not in the approved-verb table. The one supported module-qualified wrapper remains `Microsoft.PowerShell.Utility\Invoke-Expression`. A quoted string is a command identity only when preceded by the call operator `&`; otherwise it is an diff --git a/SPEC.md b/SPEC.md index 1a73493..3673aa4 100644 --- a/SPEC.md +++ b/SPEC.md @@ -318,7 +318,35 @@ proves the complete constrained-host environment, not merely `-NoProfile`. Recognized mutation of variables, aliases, functions, or modules invalidates later proofs wherever PowerShell scope rules make the mutation observable. Cwd-only state changes retain the -independent initial-runspace assertion. +independent initial-runspace assertion. A computed `Invoke-Expression` payload +can mutate every one of those facts in the current runspace; it therefore +invalidates later binding and command-resolution proofs and makes later cwd +attribution unknown. + +Variable mutation recognition includes argument-vector binding, not only the +invoked verb. The PowerShell common parameters `-OutVariable` / `-ov`, +`-PipelineVariable` / `-pv`, `-ErrorVariable` / `-ev`, `-WarningVariable` / +`-wv`, and `-InformationVariable` / `-iv`, including accepted unambiguous +prefixes and inline `:` values, invalidate later observing proofs. The same +rule covers PowerShell 7 variable-writing parameters on `Tee-Object`, +`Import-LocalizedData`, `Invoke-RestMethod`, and `Invoke-WebRequest`. An opaque +splat can supply any of those parameter keys and therefore also invalidates +later proofs. A recognized writer on `Set-Location` composes with its +success/failure cwd transfer and invalidates bindings on both reachable +outcomes; location analysis does not bypass argument-vector mutation. This +check is conservative for a command whose runtime command +type is unavailable; treating a possible native argument as a writer can +cause a prompt, but ignoring an advanced-function writer can authorize a stale +value. + +PowerShell also accepts U+2013 EN DASH, U+2014 EM DASH, and U+2015 HORIZONTAL +BAR as parameter prefixes. Stable v0.3 fails a token beginning with one of +those alternate dashes atomically rather than exposing it as a literal +positional argument: the retained v0.2 `Arg.IsFlag` contract recognizes only +an ASCII `-`, so normalizing the authored `Raw` spelling would either lose +provenance or require a breaking API change. Unsupported module-qualified +cmdlets are likewise rejected inside structural regions, not only in a flat +command; the existing module-qualified `Invoke-Expression` exception remains. For a successful result, every authored simple command appears once in `Syntax`, once in `Commands`, and once in `Clauses`, with all three projections @@ -696,6 +724,53 @@ its static resolution. In particular, compatibility projection may not retain the configured `$HOME` resolution after a loop binds `HOME`, even though that binding is outside the v0.3 supported-name boundary. +#### PowerShell bounded `foreach` state + +PowerShell `foreach` analysis owns a case-insensitive persistent binding map; +the structural parser does not push and restore lexical loop bindings. A proved +nonempty ordered plan leaves the final assigned value after the loop, including +when a nested loop reuses the same name. A proved empty plan performs no body +transition and preserves the incoming binding and cwd. A zero-or-more plan +joins its zero-iteration entry with every reachable iteration exit. Repeated +visits to one authored occurrence join effective argument and cwd facts rather +than selecting a representative visit. + +Concrete and fixed-point PowerShell loop visits share the parse-wide 4096 +transition budget. Overflow makes the complete parse unparseable and publishes +no partial command or compatibility projection. + +`Set-Location` is modeled from the complete effective argument vector. Its +success exit takes the proved filesystem target cwd and its failure exit retains +the incoming cwd. A successful non-filesystem or unproved target also +invalidates binding and command-resolution proofs because relative provider +operations may mutate that state. `&&` consumes only success, `||` only failure, and statement +sequence consumes their join. The analyzer publishes no finite cwd set, so any +disagreement becomes `Unknown`. Unsupported location-stack operations, +state/command-resolution mutation, and dynamic dispatch remain fail closed. +For provider-capable item mutators, a target that cannot be proved outside the +Alias, Function, Variable, and Environment providers invalidates binding +proofs; a dynamic value with a proved filesystem target does not. + +PowerShell `$()` and parenthesized groups propagate supported state in the +current runspace. Decoded child hosts isolate their exit state and do not +inherit the isolated initial-state assertion unless their own invocation proves +it. Compatibility parse-time location attribution is cloned for loop iterator +and body parsing: a proved empty body cannot leak a location change, while a +possibly reached loop mutation poisons any stale exact compatibility +attribution rather than choosing one execution path. + +After outcome analysis, cwd-dependent compatibility arguments, clause +elements, redirects, and cwd attribution are rebased to the occurrence's exact +cwd. When the occurrence cwd is unknown, cwd-dependent resolutions are cleared +and attribution uses the existing `` marker. A success-path parse +location therefore cannot leak into an exact failure continuation. Decoded +child-host leaves retain their inherited invocation-cwd attribution for this +projection even though child exit state remains isolated. + +Correcting a v0.2 compatibility `Redirect` does not by itself mark the v0.3 +occurrence complete. Occurrence-level redirect value completeness remains +governed by the explicit redirect analysis contract below. + ### Explicit redirect analysis (v0.3) Occurrence-specific redirect analysis is additive. The existing `Redirect` diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 8d04965..834a517 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -452,7 +452,10 @@ state. A decoded child `pwsh` host starts at `Unknown` unless its own invocation independently proves the complete constrained-host contract. Recognized variable, alias, function, or module mutation invalidates every later observing proof; a cwd-only -transfer preserves the independent initial-runspace assertion. +transfer preserves the independent initial-runspace assertion. A computed +`Invoke-Expression` can perform any of those mutations in the current runspace, +so it invalidates later bindings and command resolution and makes cwd unknown; +inside an unmodeled loop transfer it fails the complete region atomically. Effective values are shell facts, not executable semantics. The analysis must preserve both the authored shell classification and each proved effective @@ -549,6 +552,70 @@ same-name inner loop overwrites rather than restoring the outer value. The first v0.3 pass may keep active same-name nesting unparseable, but it cannot use push/pop shadowing or unconditional parser-time persistence. +PowerShell uses a separate shell-specific state implementation with the same +abstract-state invariants. Its binding keys are case-insensitive; an ordered +nonempty `foreach` leaves the final assigned value, a proved empty loop performs +no body transition, and a same-name nested loop overwrites rather than restores +the outer binding. `Set-Location` partitions success at the proved target from +failure at the incoming cwd, so `&&`, `||`, and statement sequencing remain +failure-aware. A successful non-filesystem or unproved location invalidates +binding and command-resolution state because relative provider operations can +mutate aliases, functions, variables, or environment state; its failure +partition retains the incoming state. PowerShell concrete and fixed-point visits consume the same +parse-wide 4096-transition budget as Bash without sharing lexer or scope code. +Provider-capable item mutators invalidate binding proofs when a scalar, array, +subexpression, or variable target cannot be proved outside a mutable state +provider. Target-position tracking avoids treating a dynamic value as a state +target when the filesystem path itself is proved. +An independent observed-mutation bit invalidates command identity for every +later ordinary or loop occurrence. It is not inferred merely from the default +ambient-state mode, preserving compatibility for an ordinary command when no +mutation was observed. +The PowerShell 7 mutation inventory includes `Import-Alias` and +`Import-PSSession` because they can clobber existing command names, and +`New-Module` because it can immediately export functions into the current session. Legacy PSSnapin +commands are not added to the PowerShell 7 contract when the runtime does not +expose them. + +Mutation recognition also consumes the authored argument vector. PowerShell +common parameters `OutVariable`, `PipelineVariable`, `ErrorVariable`, +`WarningVariable`, and `InformationVariable`, their documented aliases, their +accepted unambiguous prefixes, and inline values can overwrite a proved +binding even when the verb itself is nonmutating. The PowerShell 7 inventory +also includes the variable-writing parameters of `Tee-Object`, +`Import-LocalizedData`, `Invoke-RestMethod`, and `Invoke-WebRequest`. An opaque +splat can provide those keys and is conservatively a possible mutation. The +analysis does not assume an unclassified command is native because a custom +advanced function can accept the common parameters; a false-negative stale +binding would be an approval-scope error, while the conservative false +positive is recoverable by prompting. +The variable-writer transfer is composed after `Set-Location` partitions its +success and failure cwd outcomes, preventing the specialized location transfer +from bypassing argument-vector mutation on either continuation. + +PowerShell's binder also accepts U+2013, U+2014, and U+2015 as leading +parameter dashes. Stable v0.3 rejects those tokens atomically. The locked v0.2 +`Arg.IsFlag` derives from an ASCII-leading verbatim `Raw`, so silently treating +an alternate dash as positional is unsafe and normalizing it would destroy +authored provenance. The structural coordinator also reapplies the existing +module-qualified-cmdlet prohibition to every simple-command segment; the +module-qualified `Invoke-Expression` wrapper remains the sole specified +exception. + +The PowerShell structural parser clones its compatibility location-attribution +context while parsing a loop iterator and body. This prevents a structurally +present but unreachable body from changing following v0.2 leaves. If the body +may execute and mutate location, the compatibility context is made dynamic; +the occurrence analyzer then supplies the authoritative exact-or-unknown cwd +facts. Outcome projection rebases cwd-dependent compatibility arguments, +elements, redirects, and attribution when that occurrence cwd is exact. An +unknown occurrence cwd clears those resolutions and retains the dynamic-cwd +marker, so a parse location taken from the success partition cannot leak into +an exact failure continuation. Decoded child-host compatibility leaves carry +the inherited invocation-cwd attribution needed by that projection, while the +child's exit state remains isolated. General extraction of state primitives +waits until both language passes are complete and compared under task 8.1. + Parser-owned side facts retain each argument's complete `ShellValue` fragment sequence. For every concrete visit, the analyzer re-evaluates all arguments from the current binding map, accumulates effective domains by authored element @@ -601,6 +668,10 @@ signal merely because no exact cwd can be published. This is a security correction allowed by the compatibility contract, not an invitation to rewrite authored operands with analyzed loop values. +An exact-cwd correction to the v0.2 compatibility `Redirect` does not imply +that occurrence-level redirect analysis is complete. That independent fact +remains conservative until its redirect provenance is published and joined. + ### Model redirect operation and target independently The new redirect facts separate: diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md index f59d4fb..8c1b246 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md @@ -201,6 +201,61 @@ Recognized variable, alias, function, or module mutation SHALL invalidate later every observing scope; cwd-only mutation SHALL retain the independent initial-state assertion. +#### Scenario: Computed Invoke-Expression invalidates current-runspace state +- **WHEN** isolated-mode PowerShell parses `foreach ($f in 'safe.txt') { }; Invoke-Expression $code; git $f` +- **THEN** the computed payload remains an incomplete occurrence +- **THEN** the later `git` occurrence has Unknown working directory and effective `$f` value and is incomplete because the payload can mutate location, variables, aliases, functions, or modules +- **THEN** the canonical alias, static call-operator spelling, and supported module-qualified spelling have the same effect +- **WHEN** a computed `Invoke-Expression` occurs inside a bounded `foreach` region whose transfer cannot be modeled +- **THEN** the complete parse fails atomically rather than retaining stale loop state + +#### Scenario: Unproved item-provider target invalidates binding proofs +- **WHEN** isolated-mode PowerShell parses `Set-Item -Path @('Alias:\\foo') -Value Remove-Item; foreach ($f in 'x') { foo $f }` +- **THEN** the array target is not treated as a proved filesystem path +- **THEN** the later command identity and loop value are incomplete +- **WHEN** the target is a proved filesystem path and only `-Value` is dynamic +- **THEN** the value alone does not invalidate independent binding proofs + +#### Scenario: Observed alias mutation invalidates an ordinary continuation +- **WHEN** isolated-mode PowerShell parses `Set-Item Alias:git Remove-Item; git child.txt` +- **THEN** the second command occurrence is incomplete because authored identity `git` is no longer proved +- **THEN** this invalidation applies without requiring the command to be inside or after a loop +- **THEN** default ambient-state uncertainty alone does not retroactively make every v0.2 ordinary command incomplete + +#### Scenario: Imported session proxies invalidate command identity +- **WHEN** PowerShell parses `Import-PSSession $session -CommandName git -AllowClobber; git child.txt` +- **THEN** the later `git` occurrence is incomplete +- **THEN** current-runspace substitutions propagate that invalidation +- **THEN** a decoded child host isolates it from the parent continuation +- **WHEN** PowerShell uses `New-Module` to export a function into the current session +- **THEN** the same invalidation and scope-isolation rules apply +- **WHEN** PowerShell uses `Import-Alias` to load aliases into the current session +- **THEN** the same invalidation and scope-isolation rules apply + +#### Scenario: Variable-writing parameters invalidate a proved binding +- **WHEN** isolated-mode PowerShell parses `foreach ($f in 'safe.txt') { }; Write-Output C:/sensitive.txt -OutVariable f; Remove-Item $f` +- **THEN** the final occurrence is incomplete and its effective `$f` value is Unknown rather than `safe.txt` +- **THEN** common-parameter aliases, accepted unambiguous prefixes, inline values, command-specific PowerShell 7 variable writers, and opaque splats have the same conservative effect +- **WHEN** `-PipelineVariable f` may affect a downstream pipeline stage before pipeline state propagation is fully modeled +- **THEN** the pipeline fails atomically rather than exposing a stale exact value +- **WHEN** the writer executes in a current-runspace substitution +- **THEN** its invalidation propagates to the outer continuation +- **WHEN** the writer executes in a decoded child host +- **THEN** its exit mutation does not escape into the parent continuation +- **WHEN** `Set-Location` carries a recognized writer such as `-ErrorVariable f` +- **THEN** writer invalidation composes with both reachable location outcomes, including a failure-gated continuation + +#### Scenario: Alternate parameter dashes fail closed +- **WHEN** PowerShell source uses U+2013, U+2014, or U+2015 before `OutVariable`, `Path`, or another parameter name +- **THEN** the complete parse is unparseable with empty authorization projections +- **THEN** cwd, provider mutation, and variable-binding analysis never treats the runtime parameter as a literal positional argument + +#### Scenario: Module-qualified mutation inside structured source fails closed +- **WHEN** a loop continuation or pipeline invokes an unsupported module-qualified cmdlet such as `Microsoft.PowerShell.Utility\Tee-Object -Variable f` +- **THEN** the complete parse is unparseable with empty authorization projections +- **THEN** quoted call-operator spelling and built-in cmdlets with unapproved verbs cannot bypass the same rule +- **THEN** `Microsoft.PowerShell.Utility\Invoke-Expression` remains the one separately modeled module-qualified wrapper + #### Scenario: Unknown ambient PowerShell state withholds a finite proof - **WHEN** default-mode PowerShell parses `foreach ($f in @('a','b')) { Remove-Item -LiteralPath $f }` - **THEN** the loop structure and body command may remain visible @@ -444,6 +499,18 @@ partition merely to publish exact continuation facts. - **THEN** the internal iteration plan retains `a`, `b`, `a` in that order - **THEN** the following use of `f` has exact effective value `a` +#### Scenario: PowerShell duplicate values leave the final assignment +- **WHEN** isolated-mode PowerShell parses `foreach ($f in @('a','b','a')) { Write-Output $f }; Write-Output $f` +- **THEN** the body occurrence joins effective values `a` and `b` +- **THEN** the following use of `f` has exact effective value `a` +- **THEN** binding-name comparison is case-insensitive + +#### Scenario: Empty PowerShell loop performs no state transition +- **WHEN** isolated-mode PowerShell parses `foreach ($f in @()) { Set-Location C:\\tmp }; Get-Location` +- **THEN** the body remains structurally visible with conservative occurrence facts +- **THEN** no body state transfer occurs +- **THEN** the following command retains the exact incoming location and binding state + #### Scenario: Ordered cap counts visits rather than distinct values - **WHEN** an isolated-mode Bash loop authors the same literal candidate 33 times - **THEN** the internal plan exceeds the concrete-iteration cap @@ -453,6 +520,7 @@ partition merely to publish exact continuation facts. - **WHEN** nested concrete loops require more than 4096 total body transitions - **THEN** the complete parse is unparseable - **THEN** no partial occurrence or compatibility projection is published +- **THEN** the same parse-wide limit applies independently to Bash and PowerShell analysis #### Scenario: Loop-derived cd option is rebound from effective argv - **WHEN** isolated-mode Bash analyzes `for f in -P /tmp; do cd "$f"; done` @@ -513,10 +581,33 @@ partition merely to publish exact continuation facts. - **THEN** the decoded child receives no exact effective `f` from the outer loop binding - **THEN** a parenthesized subshell remains distinct because it inherits shell bindings while isolating exit state -#### Scenario: PowerShell subexpression cwd propagates -- **WHEN** PowerShell parses `Write-Output $(Set-Location /tmp; Get-Location); Get-Item relative.txt` -- **THEN** `Get-Location`, `Write-Output`, and `Get-Item` use `/tmp` -- **THEN** the analyzer does not restore the pre-subexpression cwd +#### Scenario: PowerShell subexpression cwd propagation is failure aware +- **WHEN** PowerShell parses `Write-Output $(Set-Location /tmp && Get-Location); Get-Item relative.txt` +- **THEN** `Get-Location` uses `/tmp` on the success-only continuation +- **THEN** `Write-Output` and `Get-Item` have unknown cwd because the subexpression may exit with the prior location when `Set-Location` fails +- **THEN** the analyzer does not select either the pre-subexpression or successful cwd as a fallback + +#### Scenario: PowerShell foreach location exit joins success and failure +- **WHEN** isolated-mode PowerShell parses `foreach ($d in @('C:\\a','C:\\b')) { Set-Location $d }; Get-Item relative.txt` +- **THEN** each successful visit uses its effective target location +- **THEN** each failure visit retains its incoming location +- **THEN** the post-loop occurrence has unknown cwd and no false exact relative-path resolution + +#### Scenario: PowerShell provider location changes relative mutation semantics +- **WHEN** isolated-mode PowerShell parses `Set-Location Alias:; New-Item -Name foo -Value Remove-Item; foreach ($f in 'x') { foo $f }` +- **THEN** the successful provider-location transfer invalidates binding and command-resolution proofs +- **THEN** the later loop command is incomplete +- **WHEN** a command is reached only through `Set-Location Alias: || ...` +- **THEN** that failure-only occurrence retains the incoming filesystem cwd and state + +#### Scenario: PowerShell exact failure continuation rebases compatibility paths +- **WHEN** PowerShell parses `Set-Location C:\\target || Get-Item child.txt` from `C:\\work` +- **THEN** the `Get-Item` occurrence has exact cwd `C:\\work` +- **THEN** its cwd-dependent compatibility argument, clause element, and attribution are rebased to `C:\\work` +- **THEN** no resolution derived from `C:\\target` survives on the failure continuation +- **THEN** the same rules apply inside a decoded child host while child exit state remains isolated +- **THEN** an unquoted comma-separated argument remains incomplete rather than being collapsed into one path +- **THEN** a fully quoted comma filename may still be promoted as one literal value #### Scenario: Unknown PowerShell subexpression mutation propagates - **WHEN** a PowerShell subexpression changes location to an unknown value diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 6557343..8e09771 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -145,10 +145,32 @@ live PowerShell 7.x oracle guards the fresh-host inventory. Decoded child hosts, current-runspace wrappers, redirect values, same-name nested overwrites, and post-loop state remain conservative for tasks 7.4-7.6. -- [ ] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics. +- [x] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics. + - The PowerShell-specific abstract-state pass now owns case-insensitive + persistent bindings, ordered/empty/zero-or-more execution, occurrence joins, + failure-aware `Set-Location`, current-runspace `$()` propagation, child-host + isolation, target-aware fail-closed provider mutation, and the shared + 4096-transition budget. Common and PowerShell 7 command-specific + variable-writing parameters, accepted abbreviations and inline values, and + opaque splats invalidate later observing proofs; pipeline writers fail + atomically until pipeline state propagation is modeled, and specialized + `Set-Location` success/failure transfers compose rather than bypass those + writer effects. Alternate + PowerShell parameter dashes and unsupported module-qualified cmdlets fail + structured parses atomically. Computed `Invoke-Expression` payloads poison + later current-runspace binding, command-resolution, and cwd facts, and fail + atomically when their loop transfer cannot be modeled. Loop parser attribution is + cloned so unreachable bodies do not leak and possibly reached location + mutation cannot retain a false exact compatibility cwd. Outcome projection + rebases exact failure continuations, including decoded child hosts, and + sanitizes unknown joins. Broader wrapper, + pipeline, alias/cmdlet/native, redirect, and adversarial matrices remain in + tasks 7.5-7.7. - [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries. - [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow. - [ ] 7.7 Add PowerShell corpus entries, live `pwsh` oracle coverage, and Netclaw integration cases. + - Add case-specific `PwshInitialStateMode` support to `PwshCorpusTool` before + folding isolated-state entries 362+ into its generated manifest. ## 8. Proven Shared Analysis Extraction diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index 7d78450..dc55d9b 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -195,6 +195,18 @@ internal static IReadOnlyList Tokenize(string input) } // ---- parameter -Name ---- + if (IsAlternateParameterDash(c) && IsParameterStart(src, i)) + { + tokens.Add(new PwshToken( + PwshTokenKind.UnparseableSentinel, + src.Slice(i).ToString(), + null, + i, + src.Length - i, + $"PowerShell parameter dash U+{(int)c:X4} is not supported at position {i}")); + return tokens; + } + if (c == '-' && IsParameterStart(src, i)) { i = ReadParameter(src, i, tokens); @@ -1299,6 +1311,9 @@ private static bool IsParameterStart(ReadOnlySpan src, int i) return IsAsciiLetter(c) || c == '_' || c == '?'; } + private static bool IsAlternateParameterDash(char c) => + c is '\u2013' or '\u2014' or '\u2015'; + private static int ReadParameter( ReadOnlySpan src, int start, List tokens) { diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 2ee93f1..ad36c11 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -254,8 +254,14 @@ private static bool IsUnsupportedModuleQualifiedCmdlet(string command) } var separator = command.LastIndexOf('\\'); - return separator > 0 && separator + 1 < command.Length - && PwshApprovedVerbs.IsCmdletShaped(command.Substring(separator + 1)); + if (separator <= 0 || separator + 1 >= command.Length) + { + return false; + } + + var commandName = command.Substring(separator + 1); + return PwshApprovedVerbs.IsCmdletShaped(commandName) || + PwshAliases.IsKnownCanonical(commandName); } private static bool TryDetectKeywordAnomaly(IReadOnlyList tokens, out string? reason) @@ -533,7 +539,8 @@ private static BuildResult BuildSegment( var recursion = TryRecurseIntoPwsh( body, start, classified, source, effectiveOptions, workingDirectoryUnknown, recursionDepth, - structuralDepth, segment, markWrapped, out var recursionResult); + structuralDepth, segment, markWrapped, attribution, + out var recursionResult); if (recursion) { return recursionResult; @@ -1962,6 +1969,7 @@ private static bool TryRecurseIntoPwsh( int structuralDepth, Segment segment, bool markWrapped, + PwshSetLocationContext attribution, out BuildResult result) { result = default; @@ -2090,6 +2098,15 @@ private static bool TryRecurseIntoPwsh( childLocation = new PwshSetLocationContext(); childLocation.SetDynamic(); } + else if (attribution.HasAttribution && + redirectOptions.WorkingDirectory is not null) + { + // The child inherits the invocation cwd even though its exit + // state is isolated. Keep that parse provenance so outcome + // projection can correct a failure-only continuation. + childLocation = new PwshSetLocationContext(); + childLocation.SetLiteral(redirectOptions.WorkingDirectory); + } var innerParsed = ParseInternal( inner, childOptions, recursionDepth + 1, structuralDepth + 1, markWrapped: true, diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs index 857b721..412468e 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs @@ -145,12 +145,18 @@ private bool TryParseForEach( SourceLength = bodyToken.SourceStart + bodyToken.SourceLength - start.SourceStart, }; - _forEachPlans.Add( - forEach, - PwshForEachValueAnalysis.CapturePlan( - bindingName, - iterableTokens, - isLiteralIterable)); + var plan = PwshForEachValueAnalysis.CapturePlan( + bindingName, + iterableTokens, + isLiteralIterable); + _forEachPlans.Add(forEach, plan); + if (ContainsSetLocation(iteratorCommands) || + plan.Cardinality != PwshIterationCardinality.Never && + ContainsSetLocation(body)) + { + _attribution.SetDynamic(); + } + command = forEach; error = null; return true; @@ -278,7 +284,7 @@ private bool TryParseForEachIterator( _recursionDepth, _structuralDepth + _groupDepth + 1, _markWrapped, - _attribution, + _attribution.Clone(), sourceStart, sourceLength, CompoundOperator.None, @@ -335,7 +341,7 @@ private bool TryParseForEachBody( _recursionDepth, _structuralDepth + _groupDepth + 1, _markWrapped, - _attribution, + _attribution.Clone(), sourceStart, sourceLength, CompoundOperator.None, @@ -429,46 +435,16 @@ private static bool ContainsUnsupportedForEachStateTransfer(ShellSyntaxNode node } private static bool IsUnsupportedForEachStateVerb(string verb) => - verb.Equals("Set-Location", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Push-Location", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Pop-Location", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Set-Variable", StringComparison.OrdinalIgnoreCase) || - verb.Equals("New-Variable", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Remove-Variable", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Clear-Variable", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Set-Alias", StringComparison.OrdinalIgnoreCase) || - verb.Equals("New-Alias", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Remove-Alias", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Import-Module", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Remove-Module", StringComparison.OrdinalIgnoreCase) || - verb.Equals("New-PSDrive", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Remove-PSDrive", StringComparison.OrdinalIgnoreCase) || - verb.Equals("Set-StrictMode", StringComparison.OrdinalIgnoreCase); + PwshPersistentStateMutation.IsUnsupportedForEachStateVerb(verb); - private static bool IsProviderStateMutation(string verb, Clause clause) + private static bool ContainsSetLocation(ShellSyntaxNode node) { - if (!verb.Equals("Set-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("New-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Remove-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Rename-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Move-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Copy-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Clear-Item", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Set-Content", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Add-Content", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Clear-Content", StringComparison.OrdinalIgnoreCase) && - !verb.Equals("Remove-Content", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - foreach (var element in clause.Elements) + foreach (var clause in EnumerateClauses(node)) { - if (element.Role == ClauseElementRole.Argument && - (IsMutableStateProviderPath(element.Value) || - IsMutableStateProviderPath(element.Raw) || - element.Resolved is not null && - IsMutableStateProviderPath(element.Resolved))) + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + if (verb is not null && + verb.Equals("Set-Location", StringComparison.OrdinalIgnoreCase)) { return true; } @@ -477,16 +453,8 @@ element.Resolved is not null && return false; } - private static bool IsMutableStateProviderPath(string value) - { - var providerStart = value.LastIndexOf('\\') + 1; - var providerPath = value.Substring(providerStart); - return providerPath.StartsWith("Alias:", StringComparison.OrdinalIgnoreCase) || - providerPath.StartsWith("Function:", StringComparison.OrdinalIgnoreCase) || - providerPath.StartsWith("Variable:", StringComparison.OrdinalIgnoreCase) || - providerPath.StartsWith("Environment:", StringComparison.OrdinalIgnoreCase) || - providerPath.StartsWith("Env:", StringComparison.OrdinalIgnoreCase); - } + private static bool IsProviderStateMutation(string verb, Clause clause) + => PwshPersistentStateMutation.IsProviderStateMutation(verb, clause); private static IEnumerable EnumerateClauses(ShellSyntaxNode node) { diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 2ed91f6..d7a991f 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -358,14 +358,447 @@ private static void SkipWhitespace(string raw, ref int index, int end) } } +internal static class PwshPersistentStateMutation +{ + internal static bool TryGetEffect( + Clause clause, + IReadOnlyList effectiveArguments, + out bool unknownCwd) + { + unknownCwd = false; + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + if (verb is null) + { + return false; + } + + if (HasVariableWritingArgument(verb, clause)) + { + return true; + } + + if (IsProviderStateMutation( + verb, + clause, + effectiveArguments, + failOnUnproved: true)) + { + return true; + } + + if (!IsUnsupportedForEachStateVerb(verb)) + { + return false; + } + + unknownCwd = verb.Equals("Push-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Pop-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-PSDrive", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-PSDrive", StringComparison.OrdinalIgnoreCase) || + IsInvokeExpression(verb); + return true; + } + + private static bool HasVariableWritingArgument(string verb, Clause clause) + { + foreach (var element in clause.Elements) + { + if (element.Role != ClauseElementRole.Argument) + { + continue; + } + + if (IsOpaqueSplat(element)) + { + return true; + } + + if (!TryGetParameterName(element, out var parameter)) + { + continue; + } + + if (IsCommonVariableWriter(parameter) || + IsCommandSpecificVariableWriter(verb, parameter)) + { + return true; + } + } + + return false; + } + + private static bool IsOpaqueSplat(ClauseElement element) => + element.Kind == ArgKind.DynamicSkip && + element.Raw.Length > 1 && + element.Raw[0] == '@' && + element.Raw[1] is not ('(' or '{' or '\'' or '"'); + + private static bool TryGetParameterName( + ClauseElement element, + out string parameter) + { + parameter = string.Empty; + if (!element.IsFlag || element.Value.Length < 2 || element.Value[0] != '-') + { + return false; + } + + var end = element.Value.Length; + var colon = element.Value.IndexOf(':', 1); + if (colon >= 0) + { + end = colon; + } + + var equals = element.Value.IndexOf('=', 1); + if (equals >= 0 && equals < end) + { + end = equals; + } + + if (end <= 1) + { + return false; + } + + parameter = element.Value.Substring(1, end - 1); + return true; + } + + private static bool IsCommonVariableWriter(string parameter) => + parameter.Equals("ov", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("pv", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("ev", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("wv", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("iv", StringComparison.OrdinalIgnoreCase) || + IsAcceptedPrefix(parameter, "OutVariable", minimumLength: 4) || + IsAcceptedPrefix(parameter, "PipelineVariable", minimumLength: 2) || + IsAcceptedPrefix(parameter, "ErrorVariable", minimumLength: 6) || + IsAcceptedPrefix(parameter, "WarningVariable", minimumLength: 8) || + IsAcceptedPrefix(parameter, "InformationVariable", minimumLength: 12); + + private static bool IsCommandSpecificVariableWriter( + string verb, + string parameter) + { + if (verb.Equals("Tee-Object", StringComparison.OrdinalIgnoreCase)) + { + return IsAcceptedPrefix(parameter, "Variable", minimumLength: 1); + } + + if (verb.Equals("Import-LocalizedData", StringComparison.OrdinalIgnoreCase)) + { + return IsAcceptedPrefix(parameter, "BindingVariable", minimumLength: 2) || + IsAcceptedPrefix(parameter, "Variable", minimumLength: 1); + } + + if (verb.Equals("Invoke-RestMethod", StringComparison.OrdinalIgnoreCase)) + { + return parameter.Equals("SV", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("RHV", StringComparison.OrdinalIgnoreCase) || + IsAcceptedPrefix(parameter, "SessionVariable", minimumLength: 2) || + IsAcceptedPrefix(parameter, "ResponseHeadersVariable", minimumLength: 4) || + IsAcceptedPrefix(parameter, "StatusCodeVariable", minimumLength: 2); + } + + return verb.Equals("Invoke-WebRequest", StringComparison.OrdinalIgnoreCase) && + (parameter.Equals("SV", StringComparison.OrdinalIgnoreCase) || + IsAcceptedPrefix(parameter, "SessionVariable", minimumLength: 2)); + } + + private static bool IsAcceptedPrefix( + string parameter, + string fullName, + int minimumLength) => + parameter.Length >= minimumLength && + parameter.Length <= fullName.Length && + fullName.StartsWith(parameter, StringComparison.OrdinalIgnoreCase); + + internal static bool IsUnsupportedForEachStateVerb(string verb) => + verb.Equals("Push-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Pop-Location", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Clear-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Import-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Import-Module", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Module", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Module", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Import-PSSession", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-PSDrive", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-PSDrive", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-StrictMode", StringComparison.OrdinalIgnoreCase) || + IsInvokeExpression(verb); + + private static bool IsInvokeExpression(string verb) => + verb.Equals("Invoke-Expression", StringComparison.OrdinalIgnoreCase) || + verb.Equals("iex", StringComparison.OrdinalIgnoreCase) || + verb.Equals( + "Microsoft.PowerShell.Utility\\Invoke-Expression", + StringComparison.OrdinalIgnoreCase); + + internal static bool IsProviderStateMutation(string verb, Clause clause) => + IsProviderStateMutation( + verb, + clause, + Array.Empty(), + failOnUnproved: false); + + private static bool IsProviderStateMutation( + string verb, + Clause clause, + IReadOnlyList effectiveArguments, + bool failOnUnproved) + { + if (!verb.Equals("Set-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("New-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Remove-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Rename-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Move-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Copy-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Clear-Item", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Set-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Add-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Clear-Content", StringComparison.OrdinalIgnoreCase) && + !verb.Equals("Remove-Content", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return HasMutableOrUnprovedProviderTarget( + verb, + clause, + effectiveArguments, + failOnUnproved); + } + + private static bool HasMutableOrUnprovedProviderTarget( + string verb, + Clause clause, + IReadOnlyList effectiveArguments, + bool failOnUnproved) + { + var positionalIndex = 0; + var pendingParameter = ProviderParameterRole.None; + for (var elementIndex = 0; elementIndex < clause.Elements.Count; elementIndex++) + { + var element = clause.Elements[elementIndex]; + if (element.Role != ClauseElementRole.Argument) + { + continue; + } + + if (element.IsFlag) + { + var separator = element.Value.IndexOf(':'); + var parameter = separator < 0 + ? element.Value + : element.Value.Substring(0, separator); + pendingParameter = ClassifyProviderParameter(parameter); + if (separator >= 0) + { + if (CanSelectProvider(pendingParameter) && + IsMutableOrUnprovedProviderTarget( + element, + elementIndex, + effectiveArguments, + failOnUnproved)) + { + return true; + } + + pendingParameter = ProviderParameterRole.None; + } + + continue; + } + + var couldSelectProvider = CanSelectProvider(pendingParameter) || + pendingParameter == ProviderParameterRole.None && + IsProviderTargetPosition(verb, positionalIndex); + if (couldSelectProvider && + IsMutableOrUnprovedProviderTarget( + element, + elementIndex, + effectiveArguments, + failOnUnproved)) + { + return true; + } + + if (pendingParameter == ProviderParameterRole.None) + { + positionalIndex++; + } + + pendingParameter = ProviderParameterRole.None; + } + + return failOnUnproved && CanSelectProvider(pendingParameter); + } + + private static bool CanSelectProvider(ProviderParameterRole parameter) => + parameter is ProviderParameterRole.Target or ProviderParameterRole.Unknown; + + private static bool IsMutableOrUnprovedProviderTarget( + ClauseElement element, + int elementIndex, + IReadOnlyList effectiveArguments, + bool failOnUnproved) + { + if (IsMutableStateProviderPath(element.Value) || + IsMutableStateProviderPath(element.Raw) || + element.Resolved is not null && + IsMutableStateProviderPath(element.Resolved)) + { + return true; + } + + if (!failOnUnproved || + element.Kind is ArgKind.Literal or ArgKind.Glob or ArgKind.Tilde) + { + return false; + } + + foreach (var effective in effectiveArguments) + { + if (effective.ClauseElementIndex != elementIndex) + { + continue; + } + + if (effective.Value.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet)) + { + return true; + } + + foreach (var value in effective.Value.Values) + { + if (IsMutableStateProviderPath(value)) + { + return true; + } + } + + return false; + } + + return true; + } + + private static ProviderParameterRole ClassifyProviderParameter(string parameter) + { + if (parameter.Equals("-Path", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-LiteralPath", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-LP", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-PSPath", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Destination", StringComparison.OrdinalIgnoreCase)) + { + return ProviderParameterRole.Target; + } + + if (parameter.Equals("-Value", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Target", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-NewName", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Name", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Filter", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Include", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Exclude", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Credential", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Stream", StringComparison.OrdinalIgnoreCase)) + { + return ProviderParameterRole.NonTarget; + } + + if (parameter.Equals("-Force", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Recurse", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-PassThru", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-WhatIf", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-Confirm", StringComparison.OrdinalIgnoreCase)) + { + return ProviderParameterRole.None; + } + + return ProviderParameterRole.Unknown; + } + + private static bool IsProviderTargetPosition(string verb, int position) => + position == 0 || + position == 1 && + (verb.Equals("Copy-Item", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Move-Item", StringComparison.OrdinalIgnoreCase)); + + private static bool IsMutableStateProviderPath(string value) + { + value = TrimMatchingQuotes(value); + if (HasMutableStateProviderPrefix(value)) + { + return true; + } + + if (value.Length > 1 && value[0] == '-') + { + var parameterSeparator = value.IndexOf(':'); + if (parameterSeparator > 1 && parameterSeparator + 1 < value.Length) + { + var inlineValue = TrimMatchingQuotes( + value.Substring(parameterSeparator + 1)); + if (HasMutableStateProviderPrefix(inlineValue)) + { + return true; + } + } + } + + var providerStart = Math.Max( + value.LastIndexOf('\\'), + value.LastIndexOf('/')) + 1; + return providerStart > 0 && + HasMutableStateProviderPrefix(value.Substring(providerStart)); + } + + private static bool HasMutableStateProviderPrefix(string providerPath) => + providerPath.StartsWith("Alias:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Function:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Variable:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Environment:", StringComparison.OrdinalIgnoreCase) || + providerPath.StartsWith("Env:", StringComparison.OrdinalIgnoreCase); + + private static string TrimMatchingQuotes(string value) => + value.Length >= 2 && + value[0] is '\'' or '"' && + value[value.Length - 1] == value[0] + ? value.Substring(1, value.Length - 2) + : value; + + private enum ProviderParameterRole + { + None, + Target, + NonTarget, + Unknown, + } +} + internal sealed class PwshForEachValueAnalyzer { + private const int MaxLoopAnalysisTransitions = 4096; + + private readonly PwshParserOptions _options; private readonly Func _factsFactory; private readonly Func _planFactory; private readonly IReadOnlyList _incompleteClauses; private readonly Dictionary _facts = new(ClauseReferenceComparer.Instance); - private readonly bool _isolatedInitialState; + private bool _isComplete = true; + private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; private PwshForEachValueAnalyzer( PwshParserOptions options, @@ -373,164 +806,1390 @@ private PwshForEachValueAnalyzer( Func planFactory, IReadOnlyList incompleteClauses) { + _options = options; _factsFactory = factsFactory; _planFactory = planFactory; _incompleteClauses = incompleteClauses; - _isolatedInitialState = options.InitialStateMode == - PwshInitialStateMode.IsolatedNonInteractiveNoProfile; } internal static bool TryAnalyze( ShellBlockSyntax syntax, PwshParserOptions options, + string? initialWorkingDirectory, Func factsFactory, Func planFactory, IReadOnlyList incompleteClauses, + out ShellBlockSyntax analyzedSyntax, out Func analyzedFacts) { - var analyzer = new PwshForEachValueAnalyzer( - options, - factsFactory, - planFactory, - incompleteClauses); - analyzer.AnalyzeBlock( - syntax, - new AnalysisContext( - canPromote: analyzer._isolatedInitialState, - insideLoop: false, - new List())); - analyzedFacts = simple => analyzer._facts.TryGetValue(simple.Clause, out var facts) - ? facts - : factsFactory(simple); - return true; + var analyzer = new PwshForEachValueAnalyzer( + options, + factsFactory, + planFactory, + incompleteClauses); + analyzer.AnalyzeBlock( + syntax, + new AnalysisContext( + initialWorkingDirectory, + canPromote: options.InitialStateMode == + PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + commandResolutionInvalidated: false, + new List())); + if (!analyzer._isComplete) + { + analyzedSyntax = syntax; + analyzedFacts = analyzer.GetFacts; + return false; + } + + var rewrittenFacts = new Dictionary( + ClauseReferenceComparer.Instance); + analyzedSyntax = analyzer.RewriteBlock(syntax, rewrittenFacts); + analyzedFacts = simple => rewrittenFacts.TryGetValue(simple.Clause, out var facts) + ? facts + : new CommandOccurrenceFacts(); + return true; + } + + private PwshFlowResult AnalyzeNode(ShellSyntaxNode node, AnalysisContext input) => + node switch + { + ShellBlockSyntax block => AnalyzeBlock(block, input), + SimpleCommandSyntax simple => AnalyzeSimple(simple, input), + CommandListSyntax list => AnalyzeList(list, input), + PipelineSyntax pipeline => AnalyzePipeline(pipeline, input), + GroupSyntax group => AnalyzeGroup(group, input), + ForEachSyntax forEach => AnalyzeForEach(forEach, input), + CommandSubstitutionSyntax substitution => AnalyzeSubstitution(substitution, input), + _ => PwshFlowResult.Both(input.Invalidate(unknownCwd: true)), + }; + + private PwshFlowResult AnalyzeBlock(ShellBlockSyntax block, AnalysisContext input) + { + var flow = PwshFlowResult.Both(input); + foreach (var statement in block.Statements) + { + if (flow.JoinedState is not AnalysisContext current) + { + break; + } + + flow = AnalyzeNode(statement, current); + } + + return flow; + } + + private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext input) + { + var current = input; + foreach (var substitution in simple.Substitutions) + { + var substitutionFlow = AnalyzeBlock(substitution.Body, current); + if (substitutionFlow.JoinedState is not AnalysisContext substitutionExit) + { + return new PwshFlowResult(null, null); + } + + current = substitutionExit; + } + + var source = _factsFactory(simple); + var isForEachIncomplete = ContainsReference(_incompleteClauses, simple.Clause); + var effective = CreateEffectiveArguments( + source.ValueProvenance, + current, + includeUnresolved: isForEachIncomplete); + var mayPromote = current.CanPromote && + source.HasCompleteValueProvenance && + simple.Substitutions.Count == 0 && + !simple.Clause.IsCommandStringWrapped && + current.CanResolveEveryExpansion(source.ValueProvenance); + RecordFacts( + simple, + current, + source, + effective, + isForEachIncomplete, + mayPromote); + + var location = AnalyzeSetLocation(simple, current); + if (location is not null) + { + if (PwshPersistentStateMutation.TryGetEffect( + simple.Clause, + effective, + out var locationEffectUnknownCwd)) + { + var flow = location.Value; + return new PwshFlowResult( + flow.OnSuccess is AnalysisContext success + ? success.Invalidate(locationEffectUnknownCwd) + : null, + flow.OnFailure is AnalysisContext failure + ? failure.Invalidate(locationEffectUnknownCwd) + : null); + } + + return location.Value; + } + + if (simple.Clause.Verb.IsDynamic) + { + return PwshFlowResult.Both(current.Invalidate(unknownCwd: true)); + } + + if (PwshPersistentStateMutation.TryGetEffect( + simple.Clause, + effective, + out var unknownCwd)) + { + return PwshFlowResult.Both(current.Invalidate(unknownCwd)); + } + + return PwshFlowResult.Both(current); + } + + private void RecordFacts( + SimpleCommandSyntax simple, + AnalysisContext input, + CommandOccurrenceFacts source, + IReadOnlyList effective, + bool isForEachIncomplete, + bool mayPromote) + { + var current = new CommandOccurrenceFacts + { + EffectiveArguments = effective, + WorkingDirectory = input.ToWorkingDirectoryDomain(), + Redirects = source.Redirects, + CwdPathDependencies = source.CwdPathDependencies, + ValueProvenance = source.ValueProvenance, + HasCompleteValueProvenance = source.HasCompleteValueProvenance, + IsComplete = source.IsComplete && + !input.CommandResolutionInvalidated && + (!isForEachIncomplete || mayPromote), + }; + if (!_facts.TryGetValue(simple.Clause, out var prior)) + { + _facts.Add(simple.Clause, current); + return; + } + + _facts[simple.Clause] = new CommandOccurrenceFacts + { + EffectiveArguments = JoinEffectiveArguments( + prior.EffectiveArguments, + current.EffectiveArguments), + WorkingDirectory = JoinWorkingDirectories( + prior.WorkingDirectory, + current.WorkingDirectory), + Redirects = source.Redirects, + CwdPathDependencies = source.CwdPathDependencies, + ValueProvenance = source.ValueProvenance, + HasCompleteValueProvenance = source.HasCompleteValueProvenance, + IsComplete = prior.IsComplete && current.IsComplete, + }; + } + + private PwshFlowResult AnalyzeList(CommandListSyntax list, AnalysisContext input) + { + if (list.Items.Count == 0) + { + return PwshFlowResult.Both(input); + } + + var flow = AnalyzeNode(list.Items[0].Command, input); + for (var index = 1; index < list.Items.Count; index++) + { + var item = list.Items[index]; + switch (item.Operator) + { + case CompoundOperator.AndIf: + if (flow.OnSuccess is AnalysisContext success) + { + var right = AnalyzeNode(item.Command, success); + flow = new PwshFlowResult( + right.OnSuccess, + AnalysisContext.JoinNullable(flow.OnFailure, right.OnFailure)); + } + + break; + case CompoundOperator.OrIf: + if (flow.OnFailure is AnalysisContext failure) + { + var right = AnalyzeNode(item.Command, failure); + flow = new PwshFlowResult( + AnalysisContext.JoinNullable(flow.OnSuccess, right.OnSuccess), + right.OnFailure); + } + + break; + case CompoundOperator.Sequence: + if (flow.JoinedState is AnalysisContext sequenceInput) + { + flow = AnalyzeNode(item.Command, sequenceInput); + } + + break; + default: + return flow.JoinedState is AnalysisContext joined + ? PwshFlowResult.Both(joined.Invalidate(unknownCwd: true)) + : flow; + } + } + + return flow; + } + + private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input) + { + foreach (var stage in pipeline.Stages) + { + var stageFlow = AnalyzeNode(stage, input); + if (stageFlow.JoinedState is not AnalysisContext stageExit) + { + return new PwshFlowResult(null, null); + } + + if (!input.StateEquals(stageExit)) + { + _isComplete = false; + return new PwshFlowResult(null, null); + } + } + + return PwshFlowResult.Both(input); + } + + private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) + { + if (group.GroupKind == ShellGroupKind.CurrentScope) + { + return AnalyzeBlock(group.Body, input); + } + + AnalyzeBlock( + group.Body, + input.WithoutBindings().Invalidate( + unknownCwd: false, + invalidateCommandResolution: false)); + return PwshFlowResult.Both(input); + } + + private PwshFlowResult AnalyzeSubstitution( + CommandSubstitutionSyntax substitution, + AnalysisContext input) + { + return AnalyzeBlock(substitution.Body, input); + } + + private PwshFlowResult AnalyzeForEach(ForEachSyntax forEach, AnalysisContext input) + { + var plan = _planFactory(forEach); + if (plan is null) + { + AnalyzeBlock( + forEach.IteratorCommands, + input.Invalidate( + unknownCwd: false, + invalidateCommandResolution: false)); + AnalyzeBlock( + forEach.Body, + input.Invalidate( + unknownCwd: false, + invalidateCommandResolution: false)); + return PwshFlowResult.Both(input.Invalidate( + unknownCwd: false, + invalidateCommandResolution: false)); + } + + var iterator = AnalyzeBlock(forEach.IteratorCommands, input); + if (iterator.JoinedState is not AnalysisContext loopInput) + { + return new PwshFlowResult(null, null); + } + + if (plan.Cardinality == PwshIterationCardinality.Never) + { + RecordUnvisitedBindingArguments(forEach.Body, plan.BindingName); + return PwshFlowResult.Success(loopInput); + } + + if (plan.RequiresFixedPoint) + { + return AnalyzeForEachFixedPoint(forEach, loopInput, plan); + } + + var iterationInput = loopInput; + foreach (var candidate in plan.OrderedCandidates) + { + if (!TryConsumeLoopAnalysisTransition()) + { + return new PwshFlowResult(null, null); + } + + var body = AnalyzeBlock( + forEach.Body, + iterationInput.WithBinding(plan.BindingName, candidate)); + if (body.JoinedState is not AnalysisContext bodyExit) + { + return new PwshFlowResult(null, null); + } + + iterationInput = bodyExit; + } + + return PwshFlowResult.Both(iterationInput); + } + + private PwshFlowResult AnalyzeForEachFixedPoint( + ForEachSyntax forEach, + AnalysisContext loopInput, + PwshForEachAnalysisPlan plan) + { + AnalysisContext? exits = plan.Cardinality == PwshIterationCardinality.ZeroOrMore + ? loopInput + : null; + var head = loopInput; + var wideningBase = loopInput; + var nextHead = loopInput; + for (var iteration = 0; + iteration <= ShellAnalysisLimits.MaxValueCandidates; + iteration++) + { + if (!TryConsumeLoopAnalysisTransition()) + { + return new PwshFlowResult(null, null); + } + + var body = AnalyzeBlock( + forEach.Body, + head.WithBinding(plan.BindingName, plan.Summary)); + if (body.JoinedState is not AnalysisContext bodyExit) + { + return new PwshFlowResult(null, null); + } + + exits = AnalysisContext.JoinNullable(exits, bodyExit); + wideningBase = head; + nextHead = AnalysisContext.Join(head, bodyExit); + if (head.StateEquals(nextHead)) + { + return exits is AnalysisContext stableExit + ? PwshFlowResult.Both(stableExit) + : new PwshFlowResult(null, null); + } + + head = nextHead; + } + + if (!TryConsumeLoopAnalysisTransition()) + { + return new PwshFlowResult(null, null); + } + + var widened = AnalysisContext.Widen(wideningBase, nextHead); + var widenedBody = AnalyzeBlock( + forEach.Body, + widened.WithBinding(plan.BindingName, plan.Summary)); + exits = AnalysisContext.JoinNullable(exits, widenedBody.JoinedState); + return exits is AnalysisContext widenedExit + ? PwshFlowResult.Both(widenedExit) + : new PwshFlowResult(null, null); + } + + private bool TryConsumeLoopAnalysisTransition() + { + if (_remainingLoopAnalysisTransitions == 0) + { + _isComplete = false; + return false; + } + + _remainingLoopAnalysisTransitions--; + return true; + } + + private PwshFlowResult? AnalyzeSetLocation( + SimpleCommandSyntax simple, + AnalysisContext input) + { + var verb = simple.Clause.Verb.CanonicalVerb ?? + (simple.Clause.Verb.Tokens.Count == 0 + ? null + : simple.Clause.Verb.Tokens[0]); + if (!string.Equals(verb, "Set-Location", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var target = string.Empty; + var hasTarget = false; + var expectsPath = false; + var literalPath = false; + for (var elementIndex = 0; + elementIndex < simple.Clause.Elements.Count; + elementIndex++) + { + var element = simple.Clause.Elements[elementIndex]; + if (element.Role != ClauseElementRole.Argument) + { + continue; + } + + if (!TryGetElementDomain(simple, elementIndex, input, out var domain) || + domain.Kind != ShellValueDomainKind.Exact || + domain.Values.Count != 1) + { + return new PwshFlowResult(input.Invalidate(unknownCwd: true), input); + } + + var value = domain.Values[0]; + if (element.IsFlag) + { + var colon = value.IndexOf(':'); + var parameter = colon < 0 ? value : value.Substring(0, colon); + if (parameter.Equals("-PassThru", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (!IsPathParameter(parameter, out var isLiteralPath)) + { + return new PwshFlowResult(input.Invalidate(unknownCwd: true), input); + } + + literalPath = isLiteralPath; + if (colon >= 0) + { + if (hasTarget || colon + 1 == value.Length) + { + return new PwshFlowResult(null, input); + } + + target = value.Substring(colon + 1); + hasTarget = true; + } + else + { + expectsPath = true; + } + + continue; + } + + if (hasTarget) + { + return new PwshFlowResult(null, input); + } + + target = value; + hasTarget = true; + expectsPath = false; + } + + if (expectsPath) + { + return new PwshFlowResult(null, input); + } + + if (!hasTarget) + { + var home = string.IsNullOrEmpty(_options.HomeDirectory) + ? Environment.GetFolderPath(Environment.SpecialFolder.UserProfile) + : _options.HomeDirectory!; + return new PwshFlowResult(input.WithCwd(NormalizePath(home)), input); + } + + if (target is "-" or "+") + { + return new PwshFlowResult(input.Invalidate(unknownCwd: true), input); + } + + var resolverOptions = new PwshParserOptions + { + HomeDirectory = _options.HomeDirectory, + WorkingDirectory = input.WorkingDirectory, + InitialStateMode = _options.InitialStateMode, + }; + var resolved = PwshResolver.Resolve( + ShellValue.Literal(target), + treatAsPath: true, + resolverOptions, + workingDirectoryUnknown: input.WorkingDirectory is null, + literalPath + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : ShellResolutionConsumer.PowerShellCmdletPath); + return resolved.IsPath && resolved.Resolved is not null + ? new PwshFlowResult(input.WithCwd(resolved.Resolved), input) + : new PwshFlowResult(input.Invalidate(unknownCwd: true), input); + } + + private static bool IsPathParameter(string parameter, out bool literalPath) + { + literalPath = parameter.Equals("-LiteralPath", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-LP", StringComparison.OrdinalIgnoreCase); + return literalPath || + parameter.Equals("-Path", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-PSPath", StringComparison.OrdinalIgnoreCase); + } + + private bool TryGetElementDomain( + SimpleCommandSyntax simple, + int elementIndex, + AnalysisContext input, + out ShellValueDomain domain) + { + var source = _factsFactory(simple); + foreach (var provenance in source.ValueProvenance) + { + if (provenance.ClauseElementIndex == elementIndex) + { + return input.TryEvaluateValue(provenance.Value, out domain); + } + } + + domain = ShellValueDomain.Unknown; + return false; + } + + private CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) + { + if (_facts.TryGetValue(simple.Clause, out var facts)) + { + return facts; + } + + var source = _factsFactory(simple); + return ContainsReference(_incompleteClauses, simple.Clause) + ? new CommandOccurrenceFacts + { + EffectiveArguments = source.EffectiveArguments, + WorkingDirectory = ShellValueDomain.Unknown, + Redirects = source.Redirects, + CwdPathDependencies = source.CwdPathDependencies, + ValueProvenance = source.ValueProvenance, + HasCompleteValueProvenance = source.HasCompleteValueProvenance, + IsComplete = false, + } + : source; + } + + private ShellBlockSyntax RewriteBlock( + ShellBlockSyntax block, + Dictionary facts) + { + var statements = new ShellSyntaxNode[block.Statements.Count]; + for (var index = 0; index < statements.Length; index++) + { + statements[index] = RewriteNode(block.Statements[index], facts); + } + + return block with { Statements = statements }; + } + + private ShellSyntaxNode RewriteNode( + ShellSyntaxNode node, + Dictionary facts) => + node switch + { + ShellBlockSyntax block => RewriteBlock(block, facts), + SimpleCommandSyntax simple => RewriteSimple(simple, facts), + PipelineSyntax pipeline => pipeline with + { + Stages = RewriteNodes(pipeline.Stages, facts), + }, + CommandListSyntax list => list with + { + Items = RewriteItems(list.Items, facts), + }, + GroupSyntax group => group with { Body = RewriteBlock(group.Body, facts) }, + ForEachSyntax forEach => forEach with + { + IteratorCommands = RewriteBlock(forEach.IteratorCommands, facts), + Body = RewriteBlock(forEach.Body, facts), + }, + ConditionLoopSyntax loop => loop with + { + Condition = RewriteBlock(loop.Condition, facts), + Body = RewriteBlock(loop.Body, facts), + }, + ConditionalSyntax conditional => conditional with + { + Branches = RewriteBranches(conditional.Branches, facts), + Else = conditional.Else is null + ? null + : RewriteBlock(conditional.Else, facts), + }, + ConditionalBranchSyntax branch => branch with + { + Condition = RewriteBlock(branch.Condition, facts), + Body = RewriteBlock(branch.Body, facts), + }, + CommandSubstitutionSyntax substitution => substitution with + { + Body = RewriteBlock(substitution.Body, facts), + }, + _ => node, + }; + + private SimpleCommandSyntax RewriteSimple( + SimpleCommandSyntax simple, + Dictionary facts) + { + var substitutions = new CommandSubstitutionSyntax[simple.Substitutions.Count]; + for (var index = 0; index < substitutions.Length; index++) + { + substitutions[index] = (CommandSubstitutionSyntax)RewriteNode( + simple.Substitutions[index], + facts); + } + + var source = GetFacts(simple); + var clause = RewriteCwdCompatibility( + simple.Clause, + source, + out var hasUnresolvedCwdDynamicElement); + facts.Add(clause, new CommandOccurrenceFacts + { + EffectiveArguments = source.EffectiveArguments, + WorkingDirectory = source.WorkingDirectory, + Redirects = source.Redirects, + CwdPathDependencies = source.CwdPathDependencies, + ValueProvenance = source.ValueProvenance, + HasCompleteValueProvenance = source.HasCompleteValueProvenance, + IsComplete = source.IsComplete && !hasUnresolvedCwdDynamicElement, + }); + return simple with + { + Clause = clause, + Substitutions = substitutions, + }; + } + + private Clause RewriteCwdCompatibility( + Clause clause, + CommandOccurrenceFacts facts, + out bool hasUnresolvedCwdDynamicElement) + { + hasUnresolvedCwdDynamicElement = false; + var workingDirectory = facts.WorkingDirectory; + var currentCwd = workingDirectory.Kind == ShellValueDomainKind.Exact && + workingDirectory.Values.Count == 1 + ? workingDirectory.Values[0] + : null; + var parseCwd = CompatibilityWorkingDirectory(clause, out var hadAttribution); + if (string.Equals(currentCwd, parseCwd, StringComparison.Ordinal)) + { + return clause; + } + + var resolutionChanges = new Dictionary(StringComparer.Ordinal); + var promotedArguments = new Dictionary(); + var elements = new ClauseElement[clause.Elements.Count]; + var changedResolution = false; + var literalPath = false; + var cmdletStyle = IsCmdletStyle(clause); + var argumentIndex = 0; + for (var index = 0; index < elements.Length; index++) + { + var element = clause.Elements[index]; + if (IsCwdDependentResolution(element)) + { + var elementLiteralPath = literalPath || + IsLiteralPathParameter(element.Value); + var consumer = element.Role == ClauseElementRole.Redirect + ? ShellResolutionConsumer.PowerShellRedirect + : elementLiteralPath + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : cmdletStyle + ? ShellResolutionConsumer.PowerShellCmdletPath + : ShellResolutionConsumer.PowerShellNativeArgument; + var rewritten = currentCwd is null + ? null + : ResolveCompatibilityPath( + PathCandidate(element.Value, element.IsFlag), + currentCwd, + consumer); + resolutionChanges[element.Resolved!] = rewritten; + elements[index] = element with { Resolved = rewritten }; + changedResolution = true; + } + else if (currentCwd is not null && + element.Role is ( + ClauseElementRole.Argument or ClauseElementRole.Redirect) && + element.Kind == ArgKind.DynamicSkip && + CanPromoteCwdDynamicElement( + element, + index, + facts.ValueProvenance, + hadAttribution && parseCwd is null)) + { + var elementLiteralPath = literalPath || + IsLiteralPathParameter(element.Value); + var consumer = element.Role == ClauseElementRole.Redirect + ? ShellResolutionConsumer.PowerShellRedirect + : elementLiteralPath + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : cmdletStyle + ? ShellResolutionConsumer.PowerShellCmdletPath + : ShellResolutionConsumer.PowerShellNativeArgument; + var promoted = ResolveCompatibilityPath( + PathCandidate(element.Value, element.IsFlag), + currentCwd, + consumer); + if (promoted is not null) + { + elements[index] = element with + { + Kind = ArgKind.Literal, + IsPath = true, + Resolved = promoted, + }; + if (element.Role == ClauseElementRole.Argument) + { + promotedArguments.Add(argumentIndex, promoted); + } + changedResolution = true; + } + else + { + elements[index] = element; + } + } + else + { + elements[index] = element; + } + + if (element.Role == ClauseElementRole.Argument) + { + argumentIndex++; + } + + literalPath = IsLiteralPathParameter(element.Value); + } + + var args = new List(clause.Args.Count + 1); + literalPath = false; + argumentIndex = 0; + foreach (var argument in clause.Args) + { + if (argument.IsCwdAttribution) + { + continue; + } + + if (promotedArguments.TryGetValue(argumentIndex, out var promoted)) + { + args.Add(argument with + { + Kind = ArgKind.Literal, + IsPath = true, + Resolved = promoted, + }); + changedResolution = true; + } + else if (IsCwdDependentResolution(argument)) + { + var rewritten = argument.Resolved is not null && + resolutionChanges.TryGetValue(argument.Resolved, out var mapped) + ? mapped + : currentCwd is null + ? null + : ResolveCompatibilityPath( + PathCandidate(argument.Raw, argument.IsFlag), + currentCwd, + literalPath + ? ShellResolutionConsumer.PowerShellCmdletLiteralPath + : cmdletStyle + ? ShellResolutionConsumer.PowerShellCmdletPath + : ShellResolutionConsumer.PowerShellNativeArgument); + args.Add(argument with { Resolved = rewritten }); + changedResolution = true; + } + else + { + args.Add(argument); + } + + argumentIndex++; + literalPath = IsLiteralPathParameter(argument.Raw); + } + + if (hadAttribution || changedResolution) + { + args.Add(currentCwd is null + ? new Arg + { + Raw = "", + Kind = ArgKind.DynamicSkip, + IsCwdAttribution = true, + } + : new Arg + { + Raw = currentCwd, + Resolved = currentCwd, + Kind = ArgKind.Literal, + IsPath = true, + IsCwdAttribution = true, + }); + } + + var redirects = RewriteCompatibilityRedirects( + clause.Redirects, + clause.Elements, + elements); + hasUnresolvedCwdDynamicElement = hadAttribution && + parseCwd is null && + currentCwd is not null && + HasUnresolvedCwdDynamicElement(clause.Elements, elements); + return clause with + { + Args = args.ToArray(), + Elements = elements, + Redirects = redirects, + }; + } + + private static bool HasUnresolvedCwdDynamicElement( + IReadOnlyList original, + IReadOnlyList rewritten) + { + for (var index = 0; index < original.Count; index++) + { + var element = original[index]; + if (element.Role is not ( + ClauseElementRole.Argument or ClauseElementRole.Redirect) || + element.Resolved is not null || + element.Kind is not (ArgKind.DynamicSkip or ArgKind.EnvVar)) + { + continue; + } + + if (rewritten[index].Resolved is null) + { + return true; + } + } + + return false; + } + + private static bool TryGetLiteralProvenance( + IReadOnlyList provenance, + int elementIndex) + { + foreach (var value in provenance) + { + if (value.ClauseElementIndex != elementIndex) + { + continue; + } + + foreach (var fragment in value.Value.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Literal || + fragment.Cardinality != ShellValueCardinality.ExactlyOne) + { + return false; + } + } + + return true; + } + + return false; + } + + private static bool CanPromoteCwdDynamicElement( + ClauseElement element, + int elementIndex, + IReadOnlyList provenance, + bool hadDynamicAttribution) + { + if (!IsStaticSingleValueSpelling(element)) + { + return false; + } + + if (TryGetLiteralProvenance(provenance, elementIndex)) + { + return true; + } + + if (!hadDynamicAttribution && element.Role != ClauseElementRole.Redirect) + { + return false; + } + + return true; + } + + private static bool IsStaticSingleValueSpelling(ClauseElement element) + { + var value = element.Value; + if (value.Length == 0) + { + return false; + } + + foreach (var character in value) + { + if (character == ',' && !IsFullyQuoted(element.Raw) || + character is '$' or '`' or '@' or '*' or '?' or '[' or ']' or + '{' or '}' or '(' or ')' or ';' or '|' or '&') + { + return false; + } + } + + return true; + } + + private static bool IsFullyQuoted(string value) + { + if (value.Length < 2 || + value[0] is not ('\'' or '"') || + value[value.Length - 1] != value[0]) + { + return false; + } + + for (var index = 1; index < value.Length - 1; index++) + { + if (value[index] == value[0]) + { + return false; + } + } + + return true; + } + + private string? CompatibilityWorkingDirectory( + Clause clause, + out bool hadAttribution) + { + foreach (var argument in clause.Args) + { + if (argument.IsCwdAttribution) + { + hadAttribution = true; + return argument.Resolved; + } + } + + hadAttribution = false; + return NormalizePath( + _options.WorkingDirectory ?? Environment.CurrentDirectory); + } + + private string? ResolveCompatibilityPath( + string value, + string workingDirectory, + ShellResolutionConsumer consumer) + { + var options = new PwshParserOptions + { + HomeDirectory = _options.HomeDirectory, + WorkingDirectory = workingDirectory, + InitialStateMode = _options.InitialStateMode, + }; + var resolved = PwshResolver.Resolve( + ShellValue.Literal(value), + treatAsPath: true, + options, + workingDirectoryUnknown: false, + consumer); + return resolved.IsPath ? resolved.Resolved : null; + } + + private static bool IsCmdletStyle(Clause clause) + { + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + return verb?.IndexOf('-') >= 0; + } + + private static bool IsCwdDependentResolution(ClauseElement element) + { + if (!element.IsPath || + element.Resolved is null || + element.Kind != ArgKind.Literal) + { + return false; + } + + return !IsRootedPath(PathCandidate(element.Value, element.IsFlag)); + } + + private static bool IsCwdDependentResolution(Arg argument) + { + if (!argument.IsPath || + argument.Resolved is null || + argument.Kind != ArgKind.Literal) + { + return false; + } + + return !IsRootedPath(PathCandidate(argument.Raw, argument.IsFlag)); + } + + private static string PathCandidate(string value, bool isFlag) + { + if (value.Length >= 2 && + value[0] is '\'' or '"' && + value[value.Length - 1] == value[0]) + { + value = value.Substring(1, value.Length - 2); + } + + if (isFlag) + { + var colon = value.IndexOf(':'); + var equals = value.IndexOf('='); + var separator = colon < 0 + ? equals + : equals < 0 + ? colon + : Math.Min(colon, equals); + if (separator >= 0 && separator + 1 < value.Length) + { + value = value.Substring(separator + 1); + } + } + + const string fullFileSystemProvider = + "Microsoft.PowerShell.Core\\FileSystem::"; + const string fileSystemProvider = "FileSystem::"; + if (value.StartsWith(fullFileSystemProvider, StringComparison.OrdinalIgnoreCase)) + { + value = value.Substring(fullFileSystemProvider.Length); + } + else if (value.StartsWith(fileSystemProvider, StringComparison.OrdinalIgnoreCase)) + { + value = value.Substring(fileSystemProvider.Length); + } + + return value; + } + + private static bool IsLiteralPathParameter(string value) + { + var colon = value.IndexOf(':'); + var equals = value.IndexOf('='); + var separator = colon < 0 + ? equals + : equals < 0 + ? colon + : Math.Min(colon, equals); + var parameter = separator < 0 ? value : value.Substring(0, separator); + return parameter.Equals("-LiteralPath", StringComparison.OrdinalIgnoreCase) || + parameter.Equals("-LP", StringComparison.OrdinalIgnoreCase); } - private AnalysisContext AnalyzeNode(ShellSyntaxNode node, AnalysisContext input) => - node switch + private static bool IsRootedPath(string value) => + value.Length > 0 && value[0] is '/' or '\\' || + value.Length >= 3 && + IsAsciiLetter(value[0]) && + value[1] == ':' && + value[2] is '/' or '\\'; + + private static bool IsAsciiLetter(char value) => + value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + + private static IReadOnlyList RewriteCompatibilityRedirects( + IReadOnlyList redirects, + IReadOnlyList originalElements, + IReadOnlyList rewrittenElements) + { + if (redirects.Count == 0) { - ShellBlockSyntax block => AnalyzeBlock(block, input), - SimpleCommandSyntax simple => AnalyzeSimple(simple, input), - CommandListSyntax list => AnalyzeList(list, input), - PipelineSyntax pipeline => AnalyzePipeline(pipeline, input), - GroupSyntax group => AnalyzeGroup(group, input), - ForEachSyntax forEach => AnalyzeForEach(forEach, input), - CommandSubstitutionSyntax substitution => AnalyzeSubstitution(substitution, input), - _ => input.Invalidate(), - }; + return redirects; + } + + var result = new Redirect[redirects.Count]; + var redirectIndex = 0; + for (var elementIndex = 0; + elementIndex < originalElements.Count && redirectIndex < result.Length; + elementIndex++) + { + var original = originalElements[elementIndex]; + if (original.Role != ClauseElementRole.Redirect) + { + continue; + } + + var rewritten = rewrittenElements[elementIndex]; + result[redirectIndex] = !string.Equals( + original.Resolved, + rewritten.Resolved, + StringComparison.Ordinal) + ? rewritten.Resolved is null + ? redirects[redirectIndex] with + { + Target = original.Value, + IsDynamicSkip = true, + } + : redirects[redirectIndex] with + { + Target = rewritten.Resolved, + IsDynamicSkip = false, + } + : redirects[redirectIndex]; + redirectIndex++; + } + + while (redirectIndex < result.Length) + { + result[redirectIndex] = redirects[redirectIndex]; + redirectIndex++; + } + + return result; + } - private AnalysisContext AnalyzeBlock(ShellBlockSyntax block, AnalysisContext input) + private IReadOnlyList RewriteNodes( + IReadOnlyList nodes, + Dictionary facts) { - var current = input; - foreach (var statement in block.Statements) + var rewritten = new ShellSyntaxNode[nodes.Count]; + for (var index = 0; index < rewritten.Length; index++) { - current = AnalyzeNode(statement, current); + rewritten[index] = RewriteNode(nodes[index], facts); } - return current; + return rewritten; } - private AnalysisContext AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext input) + private IReadOnlyList RewriteItems( + IReadOnlyList items, + Dictionary facts) { - foreach (var substitution in simple.Substitutions) + var rewritten = new CommandListItemSyntax[items.Count]; + for (var index = 0; index < rewritten.Length; index++) { - AnalyzeBlock(substitution.Body, input.Invalidate()); + rewritten[index] = items[index] with + { + Command = RewriteNode(items[index].Command, facts), + }; } - var source = _factsFactory(simple); - var effective = CreateEffectiveArguments(source.ValueProvenance, input); - var mayPromote = input.InsideLoop && - input.CanPromote && - source.HasCompleteValueProvenance && - simple.Substitutions.Count == 0 && - !simple.Clause.IsCommandStringWrapped; - _facts.Add(simple.Clause, new CommandOccurrenceFacts + return rewritten; + } + + private IReadOnlyList RewriteBranches( + IReadOnlyList branches, + Dictionary facts) + { + var rewritten = new ConditionalBranchSyntax[branches.Count]; + for (var index = 0; index < rewritten.Length; index++) { - EffectiveArguments = effective, - WorkingDirectory = source.WorkingDirectory, - Redirects = source.Redirects, - CwdPathDependencies = source.CwdPathDependencies, - ValueProvenance = source.ValueProvenance, - HasCompleteValueProvenance = source.HasCompleteValueProvenance, - IsComplete = source.IsComplete && - (!ContainsReference(_incompleteClauses, simple.Clause) || mayPromote), - }); + rewritten[index] = (ConditionalBranchSyntax)RewriteNode( + branches[index], + facts); + } - return simple.Substitutions.Count == 0 ? input : input.Invalidate(); + return rewritten; } - private AnalysisContext AnalyzeList(CommandListSyntax list, AnalysisContext input) + private void RecordUnvisitedBindingArguments( + ShellBlockSyntax block, + string bindingName) { - var current = input; - foreach (var item in list.Items) + foreach (var statement in block.Statements) { - current = AnalyzeNode(item.Command, current); + RecordUnvisitedBindingArguments(statement, bindingName); } + } - return current; + private void RecordUnvisitedBindingArguments( + ShellSyntaxNode node, + string bindingName) + { + switch (node) + { + case SimpleCommandSyntax simple: + var source = _factsFactory(simple); + var effective = new List(); + foreach (var provenance in source.ValueProvenance) + { + if (ReferencesBinding(provenance.Value, bindingName)) + { + effective.Add(new EffectiveArgument + { + ClauseElementIndex = provenance.ClauseElementIndex, + Value = ShellValueDomain.Unknown, + }); + } + } + + _facts[simple.Clause] = new CommandOccurrenceFacts + { + EffectiveArguments = effective.ToArray(), + WorkingDirectory = ShellValueDomain.Unknown, + Redirects = source.Redirects, + CwdPathDependencies = source.CwdPathDependencies, + ValueProvenance = source.ValueProvenance, + HasCompleteValueProvenance = source.HasCompleteValueProvenance, + IsComplete = false, + }; + foreach (var substitution in simple.Substitutions) + { + RecordUnvisitedBindingArguments(substitution.Body, bindingName); + } + + break; + case ShellBlockSyntax nestedBlock: + RecordUnvisitedBindingArguments(nestedBlock, bindingName); + break; + case PipelineSyntax pipeline: + foreach (var stage in pipeline.Stages) + { + RecordUnvisitedBindingArguments(stage, bindingName); + } + + break; + case CommandListSyntax list: + foreach (var item in list.Items) + { + RecordUnvisitedBindingArguments(item.Command, bindingName); + } + + break; + case GroupSyntax group: + RecordUnvisitedBindingArguments(group.Body, bindingName); + break; + case ForEachSyntax forEach: + RecordUnvisitedBindingArguments(forEach.IteratorCommands, bindingName); + RecordUnvisitedBindingArguments(forEach.Body, bindingName); + break; + case CommandSubstitutionSyntax substitution: + RecordUnvisitedBindingArguments(substitution.Body, bindingName); + break; + } } - private AnalysisContext AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input) + private static bool ReferencesBinding(ShellValue value, string bindingName) { - var result = input; - foreach (var stage in pipeline.Stages) + foreach (var fragment in value.Fragments) { - if (!AnalyzeNode(stage, input).CanPromote) + if (fragment.Expansion is ShellExpansionReference expansion && + expansion.Kind == ShellExpansionKind.Variable && + string.Equals( + expansion.Name, + bindingName, + StringComparison.OrdinalIgnoreCase)) { - result = result.Invalidate(); + return true; } } - return result; + return false; } - private AnalysisContext AnalyzeGroup(GroupSyntax group, AnalysisContext input) + private static IReadOnlyList JoinEffectiveArguments( + IReadOnlyList left, + IReadOnlyList right) { - AnalyzeBlock(group.Body, input.Invalidate()); - return group.GroupKind == ShellGroupKind.IsolatedScope - ? input - : input.Invalidate(); - } + var joined = new Dictionary(); + foreach (var argument in left) + { + joined[argument.ClauseElementIndex] = argument.Value; + } - private AnalysisContext AnalyzeSubstitution( - CommandSubstitutionSyntax substitution, - AnalysisContext input) - { - AnalyzeBlock(substitution.Body, input.Invalidate()); - return input.Invalidate(); + foreach (var argument in right) + { + joined[argument.ClauseElementIndex] = joined.TryGetValue( + argument.ClauseElementIndex, + out var prior) + ? JoinDomains(prior, argument.Value) + : argument.Value; + } + + var indices = new List(joined.Keys); + indices.Sort(); + var result = new EffectiveArgument[indices.Count]; + for (var index = 0; index < indices.Count; index++) + { + result[index] = new EffectiveArgument + { + ClauseElementIndex = indices[index], + Value = joined[indices[index]], + }; + } + + return result; } - private AnalysisContext AnalyzeForEach(ForEachSyntax forEach, AnalysisContext input) + private static ShellValueDomain JoinWorkingDirectories( + ShellValueDomain left, + ShellValueDomain right) => + left.Kind == ShellValueDomainKind.Exact && + right.Kind == ShellValueDomainKind.Exact && + left.Values.Count == 1 && + right.Values.Count == 1 && + string.Equals(left.Values[0], right.Values[0], StringComparison.Ordinal) + ? left + : ShellValueDomain.Unknown; + + private static ShellValueDomain JoinDomains( + ShellValueDomain left, + ShellValueDomain right) { - var plan = _planFactory(forEach); - if (plan is null) + if (left.Kind is not (ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet) || + right.Kind is not (ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet)) + { + return ShellValueDomain.Unknown; + } + + var values = new List(); + var distinct = new HashSet(StringComparer.Ordinal); + foreach (var value in left.Values) { - AnalyzeBlock(forEach.IteratorCommands, input.Invalidate()); - AnalyzeBlock(forEach.Body, input.Invalidate()); - return input.Invalidate(); + if (distinct.Add(value)) + { + values.Add(value); + } } - var iteratorOutput = AnalyzeBlock(forEach.IteratorCommands, input); - var sameName = iteratorOutput.ContainsBinding(plan.BindingName); - var canPromote = _isolatedInitialState && - iteratorOutput.CanPromote && - !sameName && - PwshForEachValueAnalysis.IsEligibleBindingName(plan.BindingName); - var domain = canPromote ? plan.Summary : ShellValueDomain.Unknown; - var bodyInput = iteratorOutput.WithBinding( - plan.BindingName, - domain, - canPromote); - AnalyzeBlock(forEach.Body, bodyInput); + foreach (var value in right.Values) + { + if (distinct.Add(value)) + { + if (values.Count == ShellAnalysisLimits.MaxValueCandidates) + { + return ShellValueDomain.Unknown; + } + + values.Add(value); + } + } - // PowerShell foreach assignments persist in the current scope. Task - // 7.4 owns the ordered post-loop state; until then no later command - // receives a restored parser-frame value. - return input.Invalidate(); + return values.Count switch + { + 1 => new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = values.ToArray(), + }, + > 1 => new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = values.ToArray(), + }, + _ => ShellValueDomain.Unknown, + }; } + private static string NormalizePath(string path) => path.Replace('\\', '/'); + private static IReadOnlyList CreateEffectiveArguments( IReadOnlyList provenance, - AnalysisContext context) + AnalysisContext context, + bool includeUnresolved) { var effective = new List(); foreach (var value in provenance) @@ -543,11 +2202,32 @@ private static IReadOnlyList CreateEffectiveArguments( Value = domain, }); } + else if (includeUnresolved && ContainsExpansion(value.Value)) + { + effective.Add(new EffectiveArgument + { + ClauseElementIndex = value.ClauseElementIndex, + Value = ShellValueDomain.Unknown, + }); + } } return effective.ToArray(); } + private static bool ContainsExpansion(ShellValue value) + { + foreach (var fragment in value.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Literal) + { + return true; + } + } + + return false; + } + private static bool ContainsReference(IReadOnlyList clauses, Clause expected) { foreach (var clause in clauses) @@ -579,24 +2259,36 @@ private readonly struct AnalysisContext private readonly IReadOnlyList _bindings; internal AnalysisContext( + string? workingDirectory, bool canPromote, - bool insideLoop, + bool commandResolutionInvalidated, IReadOnlyList bindings) { + WorkingDirectory = workingDirectory; CanPromote = canPromote; - InsideLoop = insideLoop; + CommandResolutionInvalidated = commandResolutionInvalidated; _bindings = bindings; } + internal string? WorkingDirectory { get; } + internal bool CanPromote { get; } - internal bool InsideLoop { get; } + internal bool CommandResolutionInvalidated { get; } - internal AnalysisContext Invalidate() + internal AnalysisContext Invalidate( + bool unknownCwd, + bool invalidateCommandResolution = true) { + var commandResolutionInvalidated = CommandResolutionInvalidated || + invalidateCommandResolution; if (_bindings.Count == 0) { - return new AnalysisContext(false, InsideLoop, _bindings); + return new AnalysisContext( + unknownCwd ? null : WorkingDirectory, + false, + commandResolutionInvalidated, + _bindings); } var unknown = new BindingFrame[_bindings.Count]; @@ -607,15 +2299,30 @@ internal AnalysisContext Invalidate() ShellValueDomain.Unknown); } - return new AnalysisContext(false, InsideLoop, unknown); + return new AnalysisContext( + unknownCwd ? null : WorkingDirectory, + false, + commandResolutionInvalidated, + unknown); } - internal bool ContainsBinding(string name) => FindBinding(name) is not null; + internal AnalysisContext WithoutBindings() => + new( + WorkingDirectory, + CanPromote, + CommandResolutionInvalidated, + Array.Empty()); + + internal AnalysisContext WithCwd(string? workingDirectory) => + new( + workingDirectory, + CanPromote, + CommandResolutionInvalidated, + _bindings); internal AnalysisContext WithBinding( string name, - ShellValueDomain domain, - bool canPromote) + ShellValueDomain domain) { var bindings = new List(_bindings.Count + 1); foreach (var binding in _bindings) @@ -626,8 +2333,76 @@ internal AnalysisContext WithBinding( } } - bindings.Add(new BindingFrame(name, domain)); - return new AnalysisContext(canPromote, insideLoop: true, bindings); + bindings.Add(new BindingFrame( + name, + CanPromote ? domain : ShellValueDomain.Unknown)); + return new AnalysisContext( + WorkingDirectory, + CanPromote, + CommandResolutionInvalidated, + bindings); + } + + internal ShellValueDomain ToWorkingDirectoryDomain() => + WorkingDirectory is null + ? ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { WorkingDirectory }, + }; + + internal bool TryEvaluateValue( + ShellValue value, + out ShellValueDomain domain) + { + var literal = true; + foreach (var fragment in value.Fragments) + { + if (fragment.Kind != ShellValueFragmentKind.Literal) + { + literal = false; + break; + } + } + + if (literal) + { + domain = new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { value.Decoded }, + }; + return true; + } + + return TryAnalyzeEffectiveValue(value, out domain); + } + + internal bool CanResolveEveryExpansion( + IReadOnlyList provenance) + { + foreach (var value in provenance) + { + foreach (var fragment in value.Value.Fragments) + { + if (fragment.Kind == ShellValueFragmentKind.Literal) + { + continue; + } + + if (fragment.Kind != ShellValueFragmentKind.Expansion || + fragment.Expansion is not ShellExpansionReference expansion || + expansion.Kind != ShellExpansionKind.Variable || + expansion.Name is null || + FindBinding(expansion.Name) is null) + { + return false; + } + } + } + + return true; } internal bool TryAnalyzeEffectiveValue( @@ -812,6 +2587,123 @@ fragment.Expansion is not ShellExpansionReference expansion || return null; } + internal bool StateEquals(AnalysisContext other) + { + if (!string.Equals( + WorkingDirectory, + other.WorkingDirectory, + StringComparison.Ordinal) || + CanPromote != other.CanPromote || + CommandResolutionInvalidated != other.CommandResolutionInvalidated || + _bindings.Count != other._bindings.Count) + { + return false; + } + + foreach (var binding in _bindings) + { + var otherBinding = other.FindBinding(binding.Name); + if (otherBinding is null || + !DomainEquals(binding.Domain, otherBinding.Domain)) + { + return false; + } + } + + return true; + } + + internal static AnalysisContext Join( + AnalysisContext left, + AnalysisContext right) + { + var names = new List(); + var seenNames = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var binding in left._bindings) + { + if (seenNames.Add(binding.Name)) + { + names.Add(binding.Name); + } + } + + foreach (var binding in right._bindings) + { + if (seenNames.Add(binding.Name)) + { + names.Add(binding.Name); + } + } + + var bindings = new List(names.Count); + foreach (var name in names) + { + var leftBinding = left.FindBinding(name); + var rightBinding = right.FindBinding(name); + bindings.Add(new BindingFrame( + name, + leftBinding is null || rightBinding is null + ? ShellValueDomain.Unknown + : JoinDomains(leftBinding.Domain, rightBinding.Domain))); + } + + return new AnalysisContext( + string.Equals( + left.WorkingDirectory, + right.WorkingDirectory, + StringComparison.Ordinal) + ? left.WorkingDirectory + : null, + left.CanPromote && right.CanPromote, + left.CommandResolutionInvalidated || + right.CommandResolutionInvalidated, + bindings); + } + + internal static AnalysisContext Widen( + AnalysisContext left, + AnalysisContext right) => Join(left, right); + + internal static AnalysisContext? JoinNullable( + AnalysisContext? left, + AnalysisContext? right) + { + if (left is null) + { + return right; + } + + if (right is null) + { + return left; + } + + return Join(left.Value, right.Value); + } + + private static bool DomainEquals( + ShellValueDomain left, + ShellValueDomain right) + { + if (left.Kind != right.Kind || left.Values.Count != right.Values.Count) + { + return false; + } + + for (var index = 0; index < left.Values.Count; index++) + { + if (!string.Equals( + left.Values[index], + right.Values[index], + StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + private static bool ContainsReference( IReadOnlyList bindings, BindingFrame expected) @@ -828,6 +2720,28 @@ private static bool ContainsReference( } } + private readonly struct PwshFlowResult + { + internal PwshFlowResult( + AnalysisContext? onSuccess, + AnalysisContext? onFailure) + { + OnSuccess = onSuccess; + OnFailure = onFailure; + } + + internal AnalysisContext? OnSuccess { get; } + + internal AnalysisContext? OnFailure { get; } + + internal AnalysisContext? JoinedState => + AnalysisContext.JoinNullable(OnSuccess, OnFailure); + + internal static PwshFlowResult Both(AnalysisContext state) => new(state, state); + + internal static PwshFlowResult Success(AnalysisContext state) => new(state, null); + } + private sealed class ClauseReferenceComparer : IEqualityComparer { internal static ClauseReferenceComparer Instance { get; } = new(); diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs index 7173c26..db4fadc 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshSetLocationContext.cs @@ -44,4 +44,19 @@ public void SetDynamic() ResolvedCwd = null; IsDynamic = true; } + + internal PwshSetLocationContext Clone() + { + var clone = new PwshSetLocationContext(); + if (IsDynamic) + { + clone.SetDynamic(); + } + else if (ResolvedCwd is not null) + { + clone.SetLiteral(ResolvedCwd); + } + + return clone; + } } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index 79599ce..dd6cf64 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -41,28 +41,35 @@ private static ParsedCommand ParseStructured( } var incompleteForEachClauses = CollectIncompleteForEachClauses(syntax); + var initialWorkingDirectory = sharedLocation?.IsDynamic == true + ? null + : sharedLocation?.ResolvedCwd ?? + options.WorkingDirectory ?? + Environment.CurrentDirectory; if (!PwshForEachValueAnalyzer.TryAnalyze( syntax, options, + initialWorkingDirectory, coordinator.GetFacts, coordinator.GetForEachPlan, incompleteForEachClauses, + out var analyzedSyntax, out var analyzedFacts) || !ShellSyntaxProjection.TryProject( - syntax, + analyzedSyntax, analyzedFacts, out var projection)) { return StructuralFailure( source, "PowerShell structural syntax exceeded limits or contained invalid parser-owned facts", - syntax); + analyzedSyntax); } return new ParsedCommand { Source = source, - Syntax = syntax, + Syntax = analyzedSyntax, Commands = projection.Commands, Clauses = projection.Clauses, }; @@ -411,6 +418,11 @@ private bool TryParseCommand( CollapseSafeForEachCommandArgument(segmentTokens, compatibilityOperator); + if (TryDetectUnsupportedInvocationShape(segmentTokens, out error)) + { + return false; + } + if (_insideCommandSubstitution) { var firstSegmentToken = segmentTokens[0]; diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/072_setloc_cd_then_git.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/072_setloc_cd_then_git.json index a65d353..4fec8a5 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/072_setloc_cd_then_git.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/072_setloc_cd_then_git.json @@ -1,6 +1,6 @@ { "name": "Setloc cd then git", - "input": "cd C:\\repo; git status", + "input": "cd C:\\repo \u0026\u0026 git status", "expected": { "isUnparseable": false, "clauses": [ @@ -21,7 +21,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "git", "status" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/073_setloc_setlocation_then_gci.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/073_setloc_setlocation_then_gci.json index 6b2c4ff..0f2e81a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/073_setloc_setlocation_then_gci.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/073_setloc_setlocation_then_gci.json @@ -1,6 +1,6 @@ { "name": "Setloc setlocation then gci", - "input": "Set-Location C:\\src; Get-ChildItem", + "input": "Set-Location C:\\src \u0026\u0026 Get-ChildItem", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Get-ChildItem" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/074_setloc_two_cd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/074_setloc_two_cd.json index e25991a..324b272 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/074_setloc_two_cd.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/074_setloc_two_cd.json @@ -1,6 +1,6 @@ { "name": "Setloc two cd", - "input": "cd C:\\a; cd C:\\b; gci", + "input": "cd C:\\a \u0026\u0026 cd C:\\b \u0026\u0026 gci", "expected": { "isUnparseable": false, "clauses": [ @@ -21,7 +21,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "cd" ], @@ -44,7 +44,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "gci" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/077_setloc_path_param.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/077_setloc_path_param.json index 97717d5..216ec3a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/077_setloc_path_param.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/077_setloc_path_param.json @@ -1,6 +1,6 @@ { "name": "Setloc path param", - "input": "Set-Location -Path C:\\proj; rm temp.txt", + "input": "Set-Location -Path C:\\proj \u0026\u0026 rm temp.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -25,7 +25,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "rm" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/078_setloc_through_group.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/078_setloc_through_group.json index 7d5afb3..cbfee36 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/078_setloc_through_group.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/078_setloc_through_group.json @@ -1,6 +1,6 @@ { "name": "Setloc through group", - "input": "(cd C:\\sensitive); Remove-Item *", + "input": "(cd C:\\sensitive) \u0026\u0026 Remove-Item *", "expected": { "isUnparseable": false, "clauses": [ @@ -22,7 +22,7 @@ "isSubshell": true }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Remove-Item" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/079_setloc_tilde.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/079_setloc_tilde.json index a9ba4b5..325ebb7 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/079_setloc_tilde.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/079_setloc_tilde.json @@ -1,6 +1,6 @@ { "name": "Setloc tilde", - "input": "cd ~; gci", + "input": "cd ~ \u0026\u0026 gci", "expected": { "isUnparseable": false, "clauses": [ @@ -21,17 +21,16 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "gci" ], "canonicalVerb": "Get-ChildItem", "args": [ { - "raw": "C:/Users/user", - "kind": "Literal", - "isPath": true, - "resolved": "C:/Users/user", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/080_setloc_no_arg.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/080_setloc_no_arg.json index 2d13e3a..059a1c8 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/080_setloc_no_arg.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/080_setloc_no_arg.json @@ -1,6 +1,6 @@ { "name": "Setloc no arg", - "input": "Set-Location; gci", + "input": "Set-Location \u0026\u0026 gci", "expected": { "isUnparseable": false, "clauses": [ @@ -13,7 +13,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "gci" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/081_setloc_relative_after_literal.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/081_setloc_relative_after_literal.json index d3512b0..e73250a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/081_setloc_relative_after_literal.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/081_setloc_relative_after_literal.json @@ -1,6 +1,6 @@ { "name": "Setloc relative after literal", - "input": "cd C:\\repo; cd sub; gci", + "input": "cd C:\\repo \u0026\u0026 cd sub \u0026\u0026 gci", "expected": { "isUnparseable": false, "clauses": [ @@ -21,7 +21,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "cd" ], @@ -44,7 +44,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "gci" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/148_recursion_command_setlocation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/148_recursion_command_setlocation.json index 3ba49bb..5043188 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/148_recursion_command_setlocation.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/148_recursion_command_setlocation.json @@ -1,6 +1,6 @@ { "name": "Recursion command setlocation", - "input": "pwsh -Command \u0022cd C:\\x; gci\u0022", + "input": "pwsh -Command \u0022cd C:\\x \u0026\u0026 gci\u0022", "expected": { "isUnparseable": false, "clauses": [ @@ -22,17 +22,16 @@ "isCommandStringWrapped": true }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "gci" ], "canonicalVerb": "Get-ChildItem", "args": [ { - "raw": "C:/x", - "kind": "Literal", - "isPath": true, - "resolved": "C:/x", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json index 6a525f7..8123893 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/226_iex_inherits_location.json @@ -1,6 +1,6 @@ { "name": "Iex inherits location", - "input": "Set-Location C:\\a; iex \u0027Remove-Item child.txt\u0027", + "input": "Set-Location C:\\a \u0026\u0026 iex \u0027Remove-Item child.txt\u0027", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Remove-Item" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json index a55db47..e66179b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/227_iex_exports_location.json @@ -1,6 +1,6 @@ { "name": "Iex exports location", - "input": "iex \u0027Set-Location C:\\b\u0027; Remove-Item child.txt", + "input": "iex \u0027Set-Location C:\\b\u0027 \u0026\u0026 Remove-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -21,7 +21,7 @@ "isCommandStringWrapped": true }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Remove-Item" ], @@ -29,14 +29,12 @@ { "raw": "child.txt", "kind": "Literal", - "isPath": true, - "resolved": "C:/b/child.txt" + "isPath": true }, { - "raw": "C:/b", - "kind": "Literal", - "isPath": true, - "resolved": "C:/b", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json index 96fad19..b6d7b9e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/233_iex_dynamic_location.json @@ -1,6 +1,6 @@ { "name": "Iex dynamic location", - "input": "Set-Location C:\\safe; iex $code; Remove-Item child.txt", + "input": "Set-Location C:\\safe \u0026\u0026 iex $code; Remove-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "iex" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json index 4cdc23c..885b20f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/238_iex_backtick_location.json @@ -1,6 +1,6 @@ { "name": "Iex backtick location", - "input": "Set-Location C:\\safe; iex Write-Output\u0060 ok\u0060nSet-Location\u0060 C:\\evil; Remove-Item child.txt", + "input": "Set-Location C:\\safe \u0026\u0026 iex Write-Output\u0060 ok\u0060nSet-Location\u0060 C:\\evil \u0026\u0026 Remove-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Write-Output" ], @@ -65,7 +65,7 @@ "isCommandStringWrapped": true }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Remove-Item" ], @@ -73,14 +73,12 @@ { "raw": "child.txt", "kind": "Literal", - "isPath": true, - "resolved": "C:/evil/child.txt" + "isPath": true }, { - "raw": "C:/evil", - "kind": "Literal", - "isPath": true, - "resolved": "C:/evil", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json index f3fc33e..2bb8c9e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/245_iex_vertical_tab_location.json @@ -1,6 +1,6 @@ { "name": "Iex vertical tab location", - "input": "Set-Location C:\\safe; iex \u0022Set-Location\u0060vC:\\evil\u0022; Remove-Item child.txt", + "input": "Set-Location C:\\safe \u0026\u0026 iex \u0022Set-Location\u0060vC:\\evil\u0022 \u0026\u0026 Remove-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Set-Location" ], @@ -43,7 +43,7 @@ "isCommandStringWrapped": true }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Remove-Item" ], @@ -51,14 +51,12 @@ { "raw": "child.txt", "kind": "Literal", - "isPath": true, - "resolved": "C:/evil/child.txt" + "isPath": true }, { - "raw": "C:/evil", - "kind": "Literal", - "isPath": true, - "resolved": "C:/evil", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json index f344f2d..ae9dc05 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/254_dynamic_command_location.json @@ -1,6 +1,6 @@ { "name": "Dynamic command location", - "input": "Set-Location C:\\safe; \u0026 \u0022i$part\u0022 $code; Get-Item child.txt", + "input": "Set-Location C:\\safe \u0026\u0026 \u0026 \u0022i$part\u0022 $code; Get-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "i$part" ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json index eda0b66..8da2d54 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/257_iex_escape_character.json @@ -1,6 +1,6 @@ { "name": "Iex escape character", - "input": "Set-Location C:\\safe; iex \u0022S\u0060et-Location C:\\evil\u0022; Get-Item child.txt", + "input": "Set-Location C:\\safe \u0026\u0026 iex \u0022S\u0060et-Location C:\\evil\u0022; Get-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "S\u001Bt-Location" ], @@ -51,14 +51,12 @@ { "raw": "child.txt", "kind": "Literal", - "isPath": true, - "resolved": "C:/safe/child.txt" + "isPath": true }, { - "raw": "C:/safe", - "kind": "Literal", - "isPath": true, - "resolved": "C:/safe", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json index 4195ecc..f645d6d 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/315_v03_substitution_current_scope_cwd.json @@ -1,6 +1,6 @@ { "name": "V03 substitution current scope cwd", - "input": "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt", + "input": "Write-Output $(Set-Location C:\\sensitive \u0026\u0026 Get-Location); Get-Item child.txt", "expected": { "isUnparseable": false, "clauses": [ @@ -20,7 +20,7 @@ "redirects": [] }, { - "operator": "Sequence", + "operator": "AndIf", "verb": [ "Get-Location" ], @@ -42,15 +42,14 @@ ], "args": [ { - "raw": "$(Set-Location C:\\sensitive; Get-Location)", + "raw": "$(Set-Location C:\\sensitive \u0026\u0026 Get-Location)", "kind": "DynamicSkip", "isPath": false }, { - "raw": "C:/sensitive", - "kind": "Literal", - "isPath": true, - "resolved": "C:/sensitive", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], @@ -65,14 +64,12 @@ { "raw": "child.txt", "kind": "Literal", - "isPath": true, - "resolved": "C:/sensitive/child.txt" + "isPath": true }, { - "raw": "C:/sensitive", - "kind": "Literal", - "isPath": true, - "resolved": "C:/sensitive", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, "isCwdAttribution": true } ], @@ -86,7 +83,7 @@ "region": "Unknown", "childIndex": null, "sourceStart": 0, - "sourceLength": 75, + "sourceLength": 77, "clauseIndex": null, "groupKind": null, "listOperator": null @@ -97,7 +94,7 @@ "region": "Root", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75, + "sourceLength": 77, "clauseIndex": null, "groupKind": null, "listOperator": null @@ -108,7 +105,7 @@ "region": "Statement", "childIndex": 0, "sourceStart": 0, - "sourceLength": 55, + "sourceLength": 57, "clauseIndex": 2, "groupKind": null, "listOperator": "None" @@ -119,7 +116,7 @@ "region": "Substitution", "childIndex": 0, "sourceStart": 13, - "sourceLength": 42, + "sourceLength": 44, "clauseIndex": null, "groupKind": null, "listOperator": null @@ -130,7 +127,7 @@ "region": "Substitution", "childIndex": 0, "sourceStart": 15, - "sourceLength": 39, + "sourceLength": 41, "clauseIndex": null, "groupKind": null, "listOperator": null @@ -141,7 +138,7 @@ "region": "Statement", "childIndex": 0, "sourceStart": 15, - "sourceLength": 39, + "sourceLength": 41, "clauseIndex": null, "groupKind": null, "listOperator": null @@ -162,18 +159,18 @@ "parentIndex": 5, "region": "Statement", "childIndex": 1, - "sourceStart": 42, + "sourceStart": 44, "sourceLength": 12, "clauseIndex": 1, "groupKind": null, - "listOperator": "Sequence" + "listOperator": "AndIf" }, { "kind": "SimpleCommand", "parentIndex": 1, "region": "Statement", "childIndex": 1, - "sourceStart": 57, + "sourceStart": 59, "sourceLength": 18, "clauseIndex": 3, "groupKind": null, @@ -191,35 +188,35 @@ "region": "Root", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandSubstitution", "region": "Substitution", "childIndex": 0, "sourceStart": 13, - "sourceLength": 42 + "sourceLength": 44 }, { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 15, - "sourceLength": 39 + "sourceLength": 41 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 15, - "sourceLength": 39 + "sourceLength": 41 } ] }, @@ -233,35 +230,35 @@ "region": "Root", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandSubstitution", "region": "Substitution", "childIndex": 0, "sourceStart": 13, - "sourceLength": 42 + "sourceLength": 44 }, { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 15, - "sourceLength": 39 + "sourceLength": 41 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 15, - "sourceLength": 39 + "sourceLength": 41 } ] }, @@ -275,14 +272,14 @@ "region": "Root", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 } ] }, @@ -296,18 +293,18 @@ "region": "Root", "childIndex": 0, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 }, { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 0, - "sourceLength": 75 + "sourceLength": 77 } ] } ] }, - "notes": "Subexpression location changes affect its consumer and following current-scope commands." + "notes": "A subexpression success continuation sees the target while its failure-aware outer join stays unknown." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json index b6577e7..fd6a6a2 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/336_v03_substitution_dynamic_cwd_poisoning.json @@ -226,7 +226,7 @@ { "clauseIndex": 1, "immediateRole": "Substitution", - "isComplete": true, + "isComplete": false, "ancestry": [ { "ancestorKind": "Block", @@ -268,7 +268,7 @@ { "clauseIndex": 2, "immediateRole": "Ordinary", - "isComplete": true, + "isComplete": false, "ancestry": [ { "ancestorKind": "Block", @@ -289,7 +289,7 @@ { "clauseIndex": 3, "immediateRole": "Ordinary", - "isComplete": true, + "isComplete": false, "ancestry": [ { "ancestorKind": "Block", diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json index a62bbea..eb2800d 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json @@ -128,8 +128,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json index 4a6338f..adfdcef 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json @@ -144,8 +144,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -189,8 +189,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json index d10166e..0a2311a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json @@ -175,8 +175,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -220,8 +220,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json index f45e039..ab37036 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json @@ -160,8 +160,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -202,8 +202,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json index cb4978e..46ece6b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/346_v03_foreach_parenthesized_alias.json @@ -103,8 +103,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -131,8 +131,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json index 17534e6..8f6ef47 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/347_v03_foreach_call_operator_alias.json @@ -60,8 +60,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json index f27863d..be3c565 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/348_v03_foreach_numeric_pipeline_alias.json @@ -103,8 +103,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -131,8 +131,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json index 1fe0264..ed6c324 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/349_v03_foreach_numeric_call_operator_alias.json @@ -60,8 +60,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json index f355ca7..3bb0bc1 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json @@ -155,8 +155,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -207,8 +207,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -235,8 +235,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json index aba42c0..bddcd53 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/351_v03_foreach_child_host_isolation.json @@ -187,8 +187,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -215,8 +215,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json index d26ccf5..d436a38 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json @@ -179,8 +179,8 @@ } ], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -207,8 +207,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } @@ -235,8 +235,8 @@ ], "effectiveArguments": [], "workingDirectory": { - "kind": "Unknown", - "values": [], + "kind": "Exact", + "values": ["C:/work"], "pattern": null, "coveringDirectory": null } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/369_v03_foreach_ordered_persistent_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/369_v03_foreach_ordered_persistent_binding.json new file mode 100644 index 0000000..366603a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/369_v03_foreach_ordered_persistent_binding.json @@ -0,0 +1,63 @@ +{ + "name": "V03 foreach ordered persistent binding", + "input": "foreach ($f in @('a','b','a')) { Write-Output $f }; Write-Output $f", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "$f", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["Write-Output"], + "args": [ + { "raw": "$f", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 67 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 67 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 50 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 32, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { "kind": "FiniteSet", "values": ["a", "b"] } + } + ], + "workingDirectory": { "kind": "Exact", "values": ["C:/work"] } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 67 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 0, "sourceLength": 67 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { "kind": "Exact", "values": ["a"] } + } + ], + "workingDirectory": { "kind": "Exact", "values": ["C:/work"] } + } + ] + }, + "notes": "The internal visit plan retains a,b,a while the body exposes the distinct finite set and the current-scope continuation sees final a." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/370_v03_foreach_empty_preserves_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/370_v03_foreach_empty_preserves_cwd.json new file mode 100644 index 0000000..62824e1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/370_v03_foreach_empty_preserves_cwd.json @@ -0,0 +1,51 @@ +{ + "name": "V03 foreach empty preserves cwd", + "input": "foreach ($f in @()) { Set-Location C:\\tmp }; Get-Location", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Set-Location"], + "args": [ + { "raw": "C:\\tmp", "kind": "Literal", "isPath": true, "resolved": "C:/tmp" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["Get-Location"], + "args": [], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 57 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 57 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 43 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 21, "sourceLength": 21 } + ], + "effectiveArguments": [], + "workingDirectory": { "kind": "Unknown", "values": [] } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 57 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 0, "sourceLength": 57 } + ], + "effectiveArguments": [], + "workingDirectory": { "kind": "Exact", "values": ["C:/work"] } + } + ] + }, + "notes": "The unreachable body stays visible and incomplete, performs no state transition, and cannot leak parser-time cwd attribution into the continuation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/371_v03_foreach_location_failure_join.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/371_v03_foreach_location_failure_join.json new file mode 100644 index 0000000..1697694 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/371_v03_foreach_location_failure_join.json @@ -0,0 +1,59 @@ +{ + "name": "V03 foreach location failure join", + "input": "foreach ($d in @('C:\\a', 'C:\\b')) { Set-Location $d }; Get-ChildItem file.txt", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Set-Location"], + "args": [ + { "raw": "$d", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["Get-ChildItem"], + "args": [ + { "raw": "file.txt", "kind": "DynamicSkip", "isPath": false }, + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 77 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 0, "sourceStart": 0, "sourceLength": 77 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 53 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 35, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { "kind": "FiniteSet", "values": ["C:\\a", "C:\\b"] } + } + ], + "workingDirectory": { "kind": "Unknown", "values": [] } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 77 }, + { "ancestorKind": "CommandList", "region": "Statement", "childIndex": 1, "sourceStart": 0, "sourceLength": 77 } + ], + "effectiveArguments": [], + "workingDirectory": { "kind": "Unknown", "values": [] } + } + ] + }, + "notes": "Every candidate is evaluated, but Set-Location failure and divergent successful targets force the body-input join and continuation cwd to Unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/372_v03_foreach_transition_budget_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/372_v03_foreach_transition_budget_gated.json new file mode 100644 index 0000000..904e83e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/372_v03_foreach_transition_budget_gated.json @@ -0,0 +1,10 @@ +{ + "name": "V03 foreach transition budget gated", + "input": "foreach ($a in @('a01','a02','a03','a04','a05','a06','a07','a08','a09','a10','a11','a12','a13','a14','a15','a16','a17')) { foreach ($b in @('b01','b02','b03','b04','b05','b06','b07','b08','b09','b10','b11','b12','b13','b14','b15','b16','b17')) { foreach ($c in @('c01','c02','c03','c04','c05','c06','c07','c08','c09','c10','c11','c12','c13','c14','c15')) { Write-Output \"$a$b$c\" } } }", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "exceeded limits" + }, + "oracleExpectation": "OutOfScope", + "notes": "The valid PowerShell source exceeds the shared 4096 loop-body transition budget and fails atomically without a partial authorization projection." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index 96797f6..a19d410 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -188,9 +188,9 @@ }, { "id": "pwsh-command-substitution-current-scope-cwd", - "concern": "PowerShell subexpression location changes propagate to the consumer and continuation", + "concern": "PowerShell subexpression location changes propagate with failure-aware joins", "compatibilityProjectionLanded": true, - "input": "Write-Output $(Set-Location /tmp; Get-Location); Get-Item relative.txt", + "input": "Write-Output $(Set-Location /tmp && Get-Location); Get-Item relative.txt", "current": { "isUnparseable": false }, "desired": { "syntax": [ @@ -206,8 +206,8 @@ "commands": [ { "authoredVerb": "Set-Location", "immediateRole": "Substitution", "ancestry": ["root", "list", "sub", "subBody"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "C:/work" } }, { "authoredVerb": "Get-Location", "immediateRole": "Substitution", "ancestry": ["root", "list", "sub", "subBody"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "/tmp" } }, - { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "/tmp" } }, - { "authoredVerb": "Get-Item", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "/tmp" } } + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Unknown" } }, + { "authoredVerb": "Get-Item", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Unknown" } } ], "compatibility": { "verbs": ["Set-Location", "Get-Location", "Write-Output", "Get-Item"], "preservesAuthoredDynamicValues": true }, "securityInvariants": ["AllCommandsVisible", "StateJoinConservative", "NoSyntheticOperator"] @@ -703,6 +703,7 @@ { "id": "pwsh-foreach-empty-iterable-preserves-cwd", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, "concern": "A proved empty array performs no body state transition", "input": "foreach ($f in @()) { Set-Location C:\\tmp }; Get-Location", "current": { "isUnparseable": true, "reasonContains": "state mutation" }, @@ -716,7 +717,7 @@ { "id": "get", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 1 } ], "commands": [ - { "authoredVerb": "Set-Location", "immediateRole": "LoopBody", "ancestry": ["root", "list", "loop", "body"], "isComplete": true, "workingDirectory": { "kind": "Unknown" } }, + { "authoredVerb": "Set-Location", "immediateRole": "LoopBody", "ancestry": ["root", "list", "loop", "body"], "isComplete": false, "workingDirectory": { "kind": "Unknown" } }, { "authoredVerb": "Get-Location", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "C:/work" } } ], "compatibility": { "verbs": ["Set-Location", "Get-Location"], "preservesAuthoredDynamicValues": false }, @@ -724,6 +725,94 @@ }, "notes": "The body remains structurally visible, but the Never iteration plan contributes no location mutation." }, + { + "id": "pwsh-variable-writer-invalidates-proved-binding", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "PowerShell parameter binding can overwrite a proved loop value without using a mutation verb", + "input": "foreach ($f in 'safe.txt') { }; Write-Output C:/sensitive.txt -OutVariable f; Remove-Item $f", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "list", "kind": "CommandList", "parent": "root", "slot": "Statement" }, + { "id": "loop", "kind": "ForEach", "parent": "list", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "writer", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 0 }, + { "id": "remove", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true }, + { "authoredVerb": "Remove-Item", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": false, "effectiveValues": [{ "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": true }] } + ], + "compatibility": { "verbs": ["Write-Output", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + }, + "notes": "Aliases, accepted prefixes, inline values, command-specific writers, and splats share the same invalidation rule; current-runspace writers propagate, Set-Location composes the writer with both cwd outcomes, and decoded child-host writers remain isolated." + }, + { + "id": "pwsh-alternate-parameter-dash-gated", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "A PowerShell-compatible alternate dash cannot turn a runtime parameter into a parser literal", + "input": "foreach ($f in 'safe.txt') { }; Write-Output C:/sensitive.txt \u2013OutVariable f; Remove-Item $f", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Argument" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + }, + "notes": "U+2013, U+2014, and U+2015 are runtime parameter dashes but fail closed until an additive typed representation can preserve Raw and flag identity together." + }, + { + "id": "pwsh-structured-module-qualified-mutation-gated", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Structural parsing cannot skip the existing module-qualified-cmdlet prohibition", + "input": "foreach ($f in 'safe.txt') { }; Write-Output C:/sensitive.txt | Microsoft.PowerShell.Utility\\Tee-Object -Variable f; Remove-Item $f", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + }, + "notes": "The one separately modeled module-qualified Invoke-Expression spelling remains supported." + }, + { + "id": "pwsh-dynamic-invoke-expression-invalidates-current-state", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Computed Invoke-Expression code can mutate every tracked current-runspace fact", + "input": "foreach ($f in 'safe.txt') { }; Invoke-Expression $code; git $f", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "list", "kind": "CommandList", "parent": "root", "slot": "Statement" }, + { "id": "loop", "kind": "ForEach", "parent": "list", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "iex", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 0 }, + { "id": "git", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Invoke-Expression", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": false, "effectiveValues": [{ "sourceElement": "$code", "kind": "Unknown", "isPolicySensitive": true }] }, + { "authoredVerb": "git", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": false, "workingDirectory": { "kind": "Unknown" }, "effectiveValues": [{ "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": true }] } + ], + "compatibility": { "verbs": ["Invoke-Expression", "git"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + }, + "notes": "The same invalidation applies to iex, static call-operator spelling, and the supported module-qualified spelling; inside an unmodeled loop transfer the parse fails atomically." + }, { "id": "pwsh-foreach-duplicate-order-controls-final-binding", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", @@ -904,6 +993,7 @@ { "id": "pwsh-foreach-transition-budget-overflow", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, "concern": "Nested concrete visits fail atomically above the parse-wide transition budget", "input": "foreach ($a in @('a01','a02','a03','a04','a05','a06','a07','a08','a09','a10','a11','a12','a13','a14','a15','a16','a17')) { foreach ($b in @('b01','b02','b03','b04','b05','b06','b07','b08','b09','b10','b11','b12','b13','b14','b15','b16','b17')) { foreach ($c in @('c01','c02','c03','c04','c05','c06','c07','c08','c09','c10','c11','c12','c13','c14','c15')) { Write-Output \"$a$b$c\" } } }", "current": { "isUnparseable": false }, @@ -964,6 +1054,7 @@ { "id": "pwsh-foreach-location-failure-aware-join", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, "concern": "Loop exit joins failure and divergent successful location state", "input": "foreach ($d in @('C:\\a', 'C:\\b')) { Set-Location $d }; Get-ChildItem file.txt", "current": { "isUnparseable": true, "reasonContains": "state mutation" }, diff --git a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs index 8acca7e..868d543 100644 --- a/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Lexing/PwshLexerTests.cs @@ -230,6 +230,18 @@ public void Parameter_token_is_classified() Assert.Equal(PwshTokenKind.Word, tokens[2].Kind); } + [Theory] + [InlineData('\u2013')] + [InlineData('\u2014')] + [InlineData('\u2015')] + public void Alternate_PowerShell_parameter_dash_fails_closed(char dash) + { + var token = Assert.Single(Significant($"{dash}OutVariable")); + + Assert.Equal(PwshTokenKind.UnparseableSentinel, token.Kind); + Assert.Contains($"U+{(int)dash:X4}", token.UnparseableReason); + } + [Fact] public void Colon_form_parameter_keeps_its_value() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index 25f6244..c339bae 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -459,7 +459,7 @@ public void Null_redirect_target_is_dynamic_skip() [Fact] public void Set_location_attributes_cwd_to_subsequent_clauses() { - var result = Parse("cd C:\\repo; git status"); + var result = Parse("cd C:\\repo && git status"); var attribution = result.Clauses[1].Args.Single(a => a.IsCwdAttribution); Assert.True(attribution.IsPath); Assert.Equal("C:/repo", attribution.Resolved); @@ -487,7 +487,7 @@ public void Set_location_propagates_through_a_group() { // PowerShell ( ) is a grouping operator, not a subshell — attribution // propagates through it (§9 rule 4). - var result = Parse("(cd C:\\sensitive); Remove-Item *"); + var result = Parse("(cd C:\\sensitive) && Remove-Item *"); var rm = result.Clauses.Last(); Assert.Contains(rm.Args, a => a.IsCwdAttribution && a.Resolved == "C:/sensitive"); } @@ -707,14 +707,16 @@ public void Expandable_here_string_backtick_newline_surfaces_every_command() } [Fact] - public void Backtick_newline_payload_updates_outer_location() + public void Backtick_newline_payload_keeps_outer_location_conservative_until_remapping() { var result = Parse( - "Set-Location C:\\safe; iex Write-Output` ok`nSet-Location` C:\\evil; Remove-Item child.txt"); + "Set-Location C:\\safe && iex Write-Output` ok`nSet-Location` C:\\evil && Remove-Item child.txt"); var remove = result.Clauses.Last(); Assert.Contains(remove.Args, - a => a.Raw == "child.txt" && a.Resolved == "C:/evil/child.txt"); + a => a.Raw == "child.txt" && a.Resolved is null); + Assert.Contains(remove.Args, + a => a.IsCwdAttribution && a.Kind == ArgKind.DynamicSkip); } [Theory] @@ -729,14 +731,16 @@ public void Decoded_inline_whitespace_separates_inner_path(string input) } [Fact] - public void Decoded_vertical_tab_location_change_updates_outer_location() + public void Decoded_vertical_tab_location_keeps_outer_state_conservative_until_remapping() { var result = Parse( - "Set-Location C:\\safe; iex \"Set-Location`vC:\\evil\"; Remove-Item child.txt"); + "Set-Location C:\\safe && iex \"Set-Location`vC:\\evil\" && Remove-Item child.txt"); var remove = result.Clauses.Last(); Assert.Contains(remove.Args, - a => a.Raw == "child.txt" && a.Resolved == "C:/evil/child.txt"); + a => a.Raw == "child.txt" && a.Resolved is null); + Assert.Contains(remove.Args, + a => a.IsCwdAttribution && a.Kind == ArgKind.DynamicSkip); } [Fact] @@ -868,11 +872,15 @@ public void Quoted_expression_without_call_operator_is_unparseable(string input) public void Escape_character_does_not_create_set_location_identity() { var result = Parse( - "Set-Location C:\\safe; iex \"S`et-Location C:\\evil\"; Get-Item child.txt"); + "Set-Location C:\\safe && iex \"S`et-Location C:\\evil\"; Get-Item child.txt"); var item = result.Clauses.Last(); + Assert.DoesNotContain( + result.Clauses, + clause => clause.Verb.CanonicalVerb == "Set-Location" && + !ReferenceEquals(clause, result.Clauses[0])); Assert.Contains(item.Args, - a => a.Raw == "child.txt" && a.Resolved == "C:/safe/child.txt"); + a => a.Raw == "child.txt" && a.Resolved is null); } [Theory] @@ -887,7 +895,7 @@ public void Malformed_unicode_escape_is_unparseable(string input) [Fact] public void Invoke_expression_inherits_the_current_location() { - var result = Parse("Set-Location C:\\a; iex 'Remove-Item child.txt'"); + var result = Parse("Set-Location C:\\a && iex 'Remove-Item child.txt'"); var remove = result.Clauses.Last(); Assert.Equal("Remove-Item", remove.Verb.Tokens[0]); @@ -895,22 +903,22 @@ public void Invoke_expression_inherits_the_current_location() } [Fact] - public void Invoke_expression_exports_location_changes_to_outer_clauses() + public void Invoke_expression_location_changes_remain_conservative_until_remapping() { var result = Parse( - "Set-Location C:\\a; iex 'Set-Location C:\\b; Remove-Item child.txt'; Get-ChildItem child.txt"); + "Set-Location C:\\a && iex 'Set-Location C:\\b && Remove-Item child.txt' && Get-ChildItem child.txt"); Assert.Contains(result.Clauses[2].Args, - a => a.Raw == "child.txt" && a.Resolved == "C:/b/child.txt"); + a => a.Raw == "child.txt" && a.Resolved is null); Assert.Contains(result.Clauses[3].Args, - a => a.Raw == "child.txt" && a.Resolved == "C:/b/child.txt"); + a => a.Raw == "child.txt" && a.Resolved is null); } [Fact] public void Dynamic_invoke_expression_invalidates_following_location() { var result = Parse( - "Set-Location C:\\safe; Invoke-Expression $code; Remove-Item child.txt"); + "Set-Location C:\\safe && Invoke-Expression $code; Remove-Item child.txt"); var remove = result.Clauses.Last(); var child = Assert.Single(remove.Args, a => a.Raw == "child.txt"); @@ -939,7 +947,7 @@ public void Child_pwsh_location_change_remains_isolated() public void Child_pwsh_inherits_exact_invocation_location() { var result = Parse( - "Set-Location C:\\a; pwsh -Command 'Get-Item child.txt'"); + "Set-Location C:\\a && pwsh -Command 'Get-Item child.txt'"); var child = result.Clauses.Last(); Assert.Contains(child.Args, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index 556cf26..49e11d5 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -159,7 +159,6 @@ public void Dynamic_or_malformed_foreach_fails_atomically(string source) } [Theory] - [InlineData("foreach ($x in 1) { Set-Location C:\\other }")] [InlineData("foreach ($x in 1) { Push-Location C:\\other }")] [InlineData("foreach ($x in 1) { Pop-Location }")] [InlineData("foreach ($x in 1) { Set-Variable x 2 }")] @@ -389,7 +388,7 @@ public void Nested_distinct_bindings_compose_case_insensitively() } [Fact] - public void Same_name_nested_binding_does_not_restore_outer_parser_frame() + public void Same_name_nested_binding_persists_the_inner_assignment() { var result = ParseIsolated( "foreach ($f in 'outer') { foreach ($F in 'inner') " + @@ -397,10 +396,630 @@ public void Same_name_nested_binding_does_not_restore_outer_parser_frame() Assert.False(result.IsUnparseable, result.UnparseableReason); Assert.Equal(2, result.Commands.Count); - Assert.All(result.Commands, command => Assert.False(command.IsComplete)); - Assert.All(result.Commands, command => Assert.Equal( + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + Assert.All(result.Commands, command => AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "inner")); + } + + [Fact] + public void Ordered_visits_leave_the_last_binding_visible_after_foreach() + { + var result = ParseIsolated( + "foreach ($f in @('a','b','a')) { Write-Output $f }; Write-Output $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + AssertDomain( + Assert.Single(result.Commands[0].EffectiveArguments).Value, + ShellValueDomainKind.FiniteSet, + "a", + "b"); + AssertDomain( + Assert.Single(result.Commands[1].EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "a"); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + Assert.All(result.Commands, command => AssertDomain( + command.WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/work")); + } + + [Fact] + public void Empty_foreach_preserves_cwd_and_does_not_invent_a_binding() + { + var result = ParseIsolated( + "foreach ($f in @()) { Set-Location C:\\other }; Write-Output $f; Get-Date"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(3, result.Commands.Count); + Assert.False(result.Commands[0].IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[0].WorkingDirectory.Kind); + Assert.False(result.Commands[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(result.Commands[1].EffectiveArguments).Value.Kind); + Assert.True(result.Commands[2].IsComplete); + AssertDomain( + result.Commands[2].WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/work"); + Assert.DoesNotContain( + result.Clauses[2].Args, + argument => argument.IsCwdAttribution); + } + + [Fact] + public void Set_location_success_partition_flows_through_and_if_inside_foreach() + { + var result = ParseIsolated( + "foreach ($f in 'C:\\target') { Set-Location $f && Get-Item child.txt }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + AssertDomain( + result.Commands[0].WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/work"); + AssertDomain( + result.Commands[1].WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/target"); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void Set_location_failure_path_makes_post_loop_cwd_unknown() + { + var result = ParseIsolated( + "foreach ($f in 'C:\\target') { Set-Location $f }; Get-Item child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + Assert.Contains( + result.Clauses[1].Args, + argument => argument.IsCwdAttribution && + argument.Kind == ArgKind.DynamicSkip); + Assert.DoesNotContain( + result.Clauses[1].Args, + argument => argument.Raw == "child.txt" && argument.Resolved is not null); + } + + [Fact] + public void Set_location_sequence_sanitizes_false_exact_loop_body_compatibility_path() + { + var result = ParseIsolated( + "foreach ($f in 'x') { Set-Location C:\\target; Get-Item child.txt }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + var clause = result.Clauses[1]; + Assert.Same(clause, result.Commands[1].Clause); + Assert.Contains( + clause.Args, + argument => argument.Raw == "child.txt" && argument.Resolved is null); + Assert.Contains( + clause.Elements, + element => element.Value == "child.txt" && element.Resolved is null); + Assert.Contains( + clause.Args, + argument => argument.IsCwdAttribution && + argument.Kind == ArgKind.DynamicSkip && + argument.Raw == ""); + } + + [Fact] + public void Unknown_cwd_does_not_clear_an_absolute_compatibility_path() + { + var result = ParseIsolated( + "foreach ($f in 'x') { Set-Location C:\\target; Get-Item C:\\fixed.txt }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + Assert.Contains( + result.Clauses[1].Args, + argument => argument.Raw == "C:\\fixed.txt" && + argument.Resolved == "C:/fixed.txt"); + } + + [Fact] + public void Fixed_point_repeated_value_retains_exact_post_loop_binding() + { + var values = string.Join(",", Enumerable.Repeat("'same'", 33)); + var result = ParseIsolated( + $"foreach ($f in @({values})) {{ Write-Output $f }}; Write-Output $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.All(result.Commands, command => AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "same")); + } + + [Fact] + public void Runtime_iterator_uses_zero_or_more_post_loop_binding_state() + { + var result = ParseIsolated( + "foreach ($f in Get-ChildItem C:\\input) { Write-Output $f }; Write-Output $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(3, result.Commands.Count); + Assert.All(result.Commands.Skip(1), command => Assert.Equal( ShellValueDomainKind.Unknown, Assert.Single(command.EffectiveArguments).Value.Kind)); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void Prior_variable_mutation_invalidates_later_binding_proof() + { + var result = ParseIsolated( + "Set-Variable f seeded; foreach ($f in 'value') { Write-Output $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.False(result.Commands[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(result.Commands[1].EffectiveArguments).Value.Kind); + AssertDomain( + result.Commands[1].WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/work"); + } + + [Theory] + [InlineData("-OutVariable f")] + [InlineData("-ov f")] + [InlineData("-ov:f")] + [InlineData("-ov +f")] + [InlineData("-OutV f")] + [InlineData("-PipelineVariable f")] + [InlineData("-pv f")] + [InlineData("-Pi f")] + [InlineData("-ErrorVariable f")] + [InlineData("-ev f")] + [InlineData("-ErrorV f")] + [InlineData("-WarningVariable f")] + [InlineData("-wv f")] + [InlineData("-WarningV f")] + [InlineData("-InformationVariable f")] + [InlineData("-iv f")] + [InlineData("-InformationV f")] + public void Common_variable_writer_invalidates_a_proved_binding( + string parameter) + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + $"Write-Output C:/sensitive.txt {parameter}; Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Set_location_error_variable_invalidates_failure_continuation() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "Set-Location Z:/missing -ErrorVariable f || Write-Output $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + AssertDomain( + command.WorkingDirectory, + ShellValueDomainKind.Exact, + "C:/work"); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Set_location_out_variable_invalidates_success_continuation() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "Set-Location C:\\target -OutVariable f && Write-Output $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, command.WorkingDirectory.Kind); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData('\u2013')] + [InlineData('\u2014')] + [InlineData('\u2015')] + public void Alternate_parameter_dash_fails_state_analysis_atomically(char dash) + { + var writer = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + $"Write-Output C:/sensitive.txt {dash}OutVariable f; Remove-Item $f"); + var location = ParseIsolated( + $"Set-Location {dash}Path C:/target; Get-Item child.txt"); + var provider = ParseIsolated( + $"Set-Item {dash}Path Alias:git {dash}Value Remove-Item; git child.txt"); + + Assert.All(new[] { writer, location, provider }, result => + { + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains($"U+{(int)dash:X4}", result.UnparseableReason); + }); + } + + [Theory] + [InlineData("Microsoft.PowerShell.Utility\\Tee-Object -Variable f -InputObject C:/sensitive.txt")] + [InlineData("Write-Output C:/sensitive.txt | Microsoft.PowerShell.Utility\\Tee-Object -Variable f")] + [InlineData("& 'Microsoft.PowerShell.Utility\\Tee-Object' -Variable f -InputObject C:/sensitive.txt")] + [InlineData("Microsoft.PowerShell.Utility\\Set-Variable f C:/sensitive.txt")] + [InlineData("Microsoft.PowerShell.Management\\Set-Location C:/sensitive")] + public void Unsupported_module_qualified_mutation_fails_structured_parse_atomically( + string mutation) + { + var result = ParseIsolated( + $"foreach ($f in 'safe.txt') {{ }}; {mutation}; Remove-Item $f"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("module-qualified cmdlet", result.UnparseableReason); + } + + [Theory] + [InlineData("Tee-Object -Variable f -InputObject C:/sensitive.txt")] + [InlineData("Tee-Object -Vari f -InputObject C:/sensitive.txt")] + [InlineData("Tee-Object -Va f -InputObject C:/sensitive.txt")] + [InlineData("Tee-Object -V f -InputObject C:/sensitive.txt")] + [InlineData("tee -Variable:f -InputObject C:/sensitive.txt")] + [InlineData("Import-LocalizedData -BindingVariable f")] + [InlineData("Import-LocalizedData -Bind f")] + [InlineData("Import-LocalizedData -Bi f")] + [InlineData("Import-LocalizedData -Variable f")] + [InlineData("Import-LocalizedData -Vari f")] + [InlineData("Import-LocalizedData -V f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -SessionVariable f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -SV f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -Se f")] + [InlineData("irm -Uri https://example.invalid -SV:f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -ResponseHeadersVariable f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -RHV f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -Resp f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -StatusCodeVariable f")] + [InlineData("Invoke-RestMethod -Uri https://example.invalid -St f")] + [InlineData("Invoke-WebRequest -Uri https://example.invalid -SessionVariable f")] + [InlineData("Invoke-WebRequest -Uri https://example.invalid -SV f")] + [InlineData("Invoke-WebRequest -Uri https://example.invalid -Se f")] + [InlineData("iwr -Uri https://example.invalid -SV:f")] + public void Command_specific_variable_writer_invalidates_a_proved_binding( + string mutation) + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + $"{mutation}; Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Splat_may_supply_a_variable_writer_and_invalidates_a_proved_binding() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "Write-Output C:/sensitive.txt @params; Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("Write-Output C:/sensitive.txt -OutBuffer 1")] + [InlineData("Tee-Object -Verbose -InputObject C:/sensitive.txt")] + [InlineData("Import-LocalizedData -BaseDirectory C:/safe")] + public void Nonwriting_parameter_does_not_invalidate_a_proved_binding( + string commandText) + { + var result = ParseIsolated( + $"foreach ($f in 'safe.txt') {{ }}; {commandText}; Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.True(command.IsComplete); + AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "safe.txt"); + } + + [Fact] + public void Pipeline_variable_writer_fails_the_pipeline_atomically() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "Write-Output C:/sensitive.txt -PipelineVariable f | Remove-Item $f"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Variable_writer_in_current_runspace_substitution_propagates() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "Write-Output $(Write-Output C:/sensitive.txt -OutVariable f); " + + "Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Variable_writer_in_decoded_child_does_not_escape() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + "pwsh -Command 'Write-Output C:/sensitive.txt -OutVariable f'; " + + "Remove-Item $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.True(command.IsComplete); + AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "safe.txt"); + } + + [Theory] + [InlineData("Set-Item Alias:foo Remove-Item")] + [InlineData("Set-Item 'Alias:\\foo' Remove-Item")] + [InlineData("si 'Function:\\foo' { Remove-Item $args }")] + [InlineData("Set-Item 'Variable:\\f' seeded")] + [InlineData("Set-Item Variable:f seeded")] + [InlineData("Set-Item Env:PATH C:\\tools")] + [InlineData("Set-Item -Path:Alias:\\foo -Value:Remove-Item")] + [InlineData("Set-Item -LiteralPath:Function:\\foo -Value:{ Remove-Item $args }")] + [InlineData("Set-Item -Path:Variable:\\f -Value:seeded")] + [InlineData("Set-Item -LiteralPath:Env:\\PATH -Value:C:\\tools")] + [InlineData("Set-Item -Path @('Alias:\\foo') -Value Remove-Item")] + [InlineData("Set-Item -Path $(Write-Output 'Alias:\\foo') -Value Remove-Item")] + [InlineData("Set-Item -Path $env:TARGET -Value Remove-Item")] + [InlineData("Set-Item -LP $env:TARGET -Value Remove-Item")] + [InlineData("Set-Item -LP:$env:TARGET -Value Remove-Item")] + [InlineData("Copy-Item -Path C:/safe -Destination @('Alias:\\foo')")] + public void Prior_provider_mutation_invalidates_later_binding_proof(string mutation) + { + var result = ParseIsolated( + $"{mutation}; foreach ($f in 'value') {{ foo $f }}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("Set-Item -LiteralPath C:/safe/file.txt -Value $env:CONTENT")] + [InlineData("Set-Item C:/safe/file.txt $env:CONTENT")] + [InlineData("Set-Item -LiteralPath C:/safe/file.txt -Value Alias:foo")] + [InlineData("Set-Item -LiteralPath:C:/safe/file.txt -Value:Alias:\\foo")] + [InlineData("New-Item -Path C:/safe/link -ItemType SymbolicLink -Target $env:TARGET")] + [InlineData("New-Item -Path C:/safe/link -ItemType SymbolicLink -Target Alias:\\foo")] + [InlineData("New-Item -Path:C:/safe/link -ItemType:SymbolicLink -Target:$env:TARGET")] + public void Value_operand_does_not_invalidate_a_proved_filesystem_target( + string mutation) + { + var result = ParseIsolated( + $"{mutation}; foreach ($f in 'value') {{ Write-Output $f }}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.True(result.Commands[1].IsComplete); + AssertDomain( + Assert.Single(result.Commands[1].EffectiveArguments).Value, + ShellValueDomainKind.Exact, + "value"); + } + + [Fact] + public void Finite_provider_target_invalidates_commands_after_mutating_visit() + { + var result = ParseIsolated( + "foreach ($f in @('C:/safe','Alias:\\foo')) { " + + "Set-Item -LP $f -Value Remove-Item; foo $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(result.Commands[1].EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("Set-Location Alias:; New-Item -Name foo -Value Remove-Item")] + [InlineData("Set-Location Function:; New-Item -Name foo -Value Remove-Item")] + [InlineData("Set-Location Alias:; Set-Item foo Remove-Item")] + [InlineData("Set-Location $env:TARGET; Set-Item foo Remove-Item")] + public void Unproved_or_nonfilesystem_location_success_invalidates_later_proofs( + string mutation) + { + var result = ParseIsolated( + $"{mutation}; foreach ($f in 'value') {{ foo $f }}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, command.WorkingDirectory.Kind); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("Set-Item Alias:git Remove-Item; git child.txt")] + [InlineData("Set-Location Alias:; New-Item -Name git -Value Remove-Item; git child.txt")] + [InlineData("Set-Location Function:; New-Item -Name git -Value Remove-Item; git child.txt")] + [InlineData("Import-PSSession $session -CommandName git -AllowClobber; git child.txt")] + [InlineData("Import-Alias aliases.csv -Force; git child.txt")] + [InlineData("New-Module -ScriptBlock $script; git child.txt")] + public void Observed_command_resolution_mutation_invalidates_plain_continuations( + string source) + { + var result = ParseIsolated(source); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.False(result.Commands.Last().IsComplete); + } + + [Theory] + [InlineData("Import-PSSession $session -CommandName git -AllowClobber")] + [InlineData("Import-Alias aliases.csv -Force")] + [InlineData("New-Module -ScriptBlock $script")] + public void Command_resolution_mutation_propagates_through_current_runspace_substitution( + string mutation) + { + var result = ParseIsolated( + $"Write-Output $({mutation}); git child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.False(result.Commands.Last().IsComplete); + } + + [Theory] + [InlineData("Import-PSSession $session -CommandName git -AllowClobber")] + [InlineData("Import-Alias aliases.csv -Force")] + [InlineData("New-Module -ScriptBlock $script")] + public void Command_resolution_mutation_in_decoded_child_does_not_escape( + string mutation) + { + var result = ParseIsolated( + $"pwsh -Command '{mutation}'; git child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.True(result.Commands.Last().IsComplete); + } + + [Theory] + [InlineData("Invoke-Expression $code")] + [InlineData("iex -Command:$code")] + [InlineData("& 'iex' $code")] + [InlineData("Microsoft.PowerShell.Utility\\Invoke-Expression $code")] + public void Dynamic_invoke_expression_invalidates_current_runspace_state( + string invocation) + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { }; " + + $"{invocation}; git $f"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.False(command.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, command.WorkingDirectory.Kind); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Dynamic_invoke_expression_inside_foreach_fails_atomically() + { + var result = ParseIsolated( + "foreach ($f in 'safe.txt') { Invoke-Expression $code; git $f }"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("state mutation", result.UnparseableReason!); + } + + [Fact] + public void Nonfilesystem_location_failure_partition_retains_incoming_state() + { + var result = ParseIsolated( + "Set-Location Alias: || Get-Item child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands.Last(); + Assert.True(command.IsComplete); + AssertDomain(command.WorkingDirectory, ShellValueDomainKind.Exact, "C:/work"); + Assert.Contains(command.Clause.Args, argument => + argument.Raw == "child.txt" && + argument.Resolved == "C:/work/child.txt"); + } + + [Fact] + public void Prior_directory_stack_mutation_invalidates_binding_and_cwd_proofs() + { + var result = ParseIsolated( + "Push-Location C:\\other; foreach ($f in 'value') { Write-Output $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.False(result.Commands[1].IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(result.Commands[1].EffectiveArguments).Value.Kind); + } + + [Fact] + public void Pipeline_location_transfer_fails_atomically_until_pipeline_state_is_modeled() + { + var result = ParseIsolated( + "foreach ($f in 'C:\\target') { Set-Location $f | Get-Location }"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Nested_concrete_transition_budget_overflow_fails_atomically() + { + var a = LiteralArray("a", 17); + var b = LiteralArray("b", 17); + var c = LiteralArray("c", 15); + var result = ParseIsolated( + $"foreach ($a in {a}) {{ foreach ($b in {b}) {{ " + + $"foreach ($c in {c}) {{ Write-Output \"$a$b$c\" }} }} }}"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("exceeded limits", result.UnparseableReason!); } [Fact] @@ -489,6 +1108,10 @@ private static void AssertDomain( Assert.Equal(values, domain.Values); } + private static string LiteralArray(string prefix, int count) => + "@(" + string.Join(",", Enumerable.Range(1, count) + .Select(index => $"'{prefix}{index:00}'")) + ")"; + private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; private static string NestedLoops(int depth) diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs index 660ab12..b0ae9ca 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs @@ -93,7 +93,7 @@ public void Group_collapses_leading_and_trailing_newlines_without_admitting_a_li [Fact] public void Set_location_propagates_out_of_a_current_scope_group() { - var result = Parse("(Set-Location C:\\sensitive); Remove-Item child.txt"); + var result = Parse("(Set-Location C:\\sensitive) && Remove-Item child.txt"); var remove = result.Clauses.Last(); Assert.Contains( @@ -101,6 +101,158 @@ public void Set_location_propagates_out_of_a_current_scope_group() arg => arg.IsCwdAttribution && arg.Resolved == "C:/sensitive"); } + [Fact] + public void Set_location_failure_partition_rebases_compatibility_to_incoming_cwd() + { + var result = Parse("Set-Location C:\\target || Get-Item child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal( + ShellValueDomainKind.Exact, + result.Commands[1].WorkingDirectory.Kind); + Assert.Equal( + "C:/work", + Assert.Single(result.Commands[1].WorkingDirectory.Values)); + var clause = result.Clauses[1]; + Assert.Same(clause, result.Commands[1].Clause); + Assert.Contains(clause.Args, argument => + argument.Raw == "child.txt" && + argument.Resolved == "C:/work/child.txt"); + Assert.Contains(clause.Elements, element => + element.Value == "child.txt" && + element.Resolved == "C:/work/child.txt"); + Assert.Contains(clause.Args, argument => + argument.IsCwdAttribution && argument.Resolved == "C:/work"); + } + + [Fact] + public void Exact_failure_rebase_preserves_absolute_path_and_rewrites_redirect() + { + var result = Parse( + "Set-Location C:\\target || Get-Item . C:\\work > result.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var clause = result.Clauses[1]; + Assert.Contains(clause.Args, argument => + argument.Raw == "." && argument.Resolved == "C:/work"); + Assert.Contains(clause.Args, argument => + argument.Raw == "C:\\work" && argument.Resolved == "C:/work"); + var redirect = Assert.Single(clause.Redirects); + Assert.False(redirect.IsDynamicSkip); + Assert.Equal("C:/work/result.txt", redirect.Target); + } + + [Theory] + [InlineData("pwsh -Command 'Get-Item child.txt > out.txt'")] + [InlineData("pwsh -EncodedCommand RwBlAHQALQBJAHQAZQBtACAAYwBoAGkAbABkAC4AdAB4AHQAIAA+ACAAbwB1AHQALgB0AHgAdAA=")] + public void Exact_failure_rebase_crosses_decoded_child_host_boundary(string invocation) + { + var result = Parse($"Set-Location C:\\target || {invocation}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + var clause = result.Clauses[1]; + Assert.Same(clause, command.Clause); + Assert.Equal(ShellValueDomainKind.Exact, command.WorkingDirectory.Kind); + Assert.Equal("C:/work", Assert.Single(command.WorkingDirectory.Values)); + Assert.Contains(clause.Args, argument => + argument.Raw == "child.txt" && + argument.Resolved == "C:/work/child.txt"); + Assert.Contains(clause.Elements, element => + element.Value == "child.txt" && + element.Resolved == "C:/work/child.txt"); + Assert.Contains(clause.Args, argument => + argument.IsCwdAttribution && argument.Resolved == "C:/work"); + var redirect = Assert.Single(clause.Redirects); + Assert.False(redirect.IsDynamicSkip); + Assert.Equal("C:/work/out.txt", redirect.Target); + } + + [Theory] + [InlineData("Get-Item child.txt > out.txt")] + [InlineData("pwsh -Command 'Get-Item child.txt > out.txt'")] + [InlineData("pwsh -EncodedCommand RwBlAHQALQBJAHQAZQBtACAAYwBoAGkAbABkAC4AdAB4AHQAIAA+ACAAbwB1AHQALgB0AHgAdAA=")] + public void Dynamic_provider_failure_promotes_only_static_compatibility_paths( + string invocation) + { + var result = Parse($"Set-Location Alias: || {invocation}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + var clause = result.Clauses[1]; + Assert.Same(clause, command.Clause); + Assert.Equal(ShellValueDomainKind.Exact, command.WorkingDirectory.Kind); + Assert.Equal("C:/work", Assert.Single(command.WorkingDirectory.Values)); + Assert.Contains(clause.Args, argument => + argument.Raw == "child.txt" && + argument.Kind == ArgKind.Literal && + argument.Resolved == "C:/work/child.txt"); + Assert.Contains(clause.Elements, element => + element.Value == "out.txt" && + element.Kind == ArgKind.Literal && + element.Resolved == "C:/work/out.txt"); + var redirect = Assert.Single(clause.Redirects); + Assert.False(redirect.IsDynamicSkip); + Assert.Equal("C:/work/out.txt", redirect.Target); + Assert.False(command.IsComplete); + } + + [Theory] + [InlineData("Get-Item $name > $out")] + [InlineData("pwsh -Command 'Get-Item $name > $out'")] + public void Dynamic_provider_failure_does_not_promote_runtime_values( + string invocation) + { + var result = Parse($"Set-Location Alias: || {invocation}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var clause = result.Clauses[1]; + Assert.Contains(clause.Args, argument => + argument.Raw == "$name" && argument.Resolved is null); + Assert.Contains(clause.Elements, element => + element.Value == "$out" && element.Resolved is null); + Assert.True(Assert.Single(clause.Redirects).IsDynamicSkip); + } + + [Theory] + [InlineData("Get-Item a,b")] + [InlineData("pwsh -Command 'Get-Item a,b'")] + [InlineData("Remove-Item safe.txt,C:/sensitive.txt")] + [InlineData("pwsh -Command 'Remove-Item safe.txt,C:/sensitive.txt'")] + [InlineData("Get-Item 'a','b'")] + [InlineData("Get-Item \"a\",\"b\"")] + [InlineData("pwsh -Command \"Get-Item 'a','b'\"")] + [InlineData("pwsh -EncodedCommand RwBlAHQALQBJAHQAZQBtACAAJwBhACcALAAnAGIAJwA=")] + [InlineData("Remove-Item 'safe.txt','C:/sensitive.txt'")] + public void Dynamic_provider_failure_does_not_collapse_argument_lists_to_one_path( + string invocation) + { + var result = Parse($"Set-Location Alias: || {invocation}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + Assert.False(command.IsComplete); + Assert.DoesNotContain(command.Clause.Args, argument => + argument.Raw.IndexOf(',') >= 0 && argument.Resolved is not null); + Assert.DoesNotContain(command.Clause.Elements, element => + element.Value.IndexOf(',') >= 0 && element.Resolved is not null); + } + + [Theory] + [InlineData("Get-Item 'a,b'")] + [InlineData("pwsh -Command \"Get-Item 'a,b'\"")] + public void Dynamic_provider_failure_promotes_a_quoted_comma_filename( + string invocation) + { + var result = Parse($"Set-Location Alias: || {invocation}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + Assert.True(command.IsComplete); + Assert.Contains(command.Clause.Args, argument => + argument.Resolved == "C:/work/a,b"); + } + [Theory] [InlineData("(Get-Date; Get-Process)")] [InlineData("Get-Date | (Get-Process)")] @@ -267,15 +419,15 @@ public void Operator_entering_static_wrapper_is_carried_by_its_first_leaf(string } [Fact] - public void Invoke_expression_exports_location_changes_to_following_commands() + public void Invoke_expression_location_changes_remain_conservative_until_remapping() { var result = Parse( - "iex 'Set-Location C:\\sensitive'; Remove-Item child.txt"); + "iex 'Set-Location C:\\sensitive' && Remove-Item child.txt"); var remove = result.Clauses.Last(); Assert.Contains( remove.Args, - arg => arg.IsCwdAttribution && arg.Resolved == "C:/sensitive"); + arg => arg.IsCwdAttribution && arg.Kind == ArgKind.DynamicSkip); } [Fact] @@ -465,7 +617,7 @@ public void Interpolated_command_word_remains_dynamic_while_exposing_its_subexpr } [Fact] - public void Subexpression_location_changes_propagate_before_outer_resolution() + public void Subexpression_location_failure_joins_sanitize_outer_compatibility() { var result = Parse( "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt"); @@ -473,13 +625,43 @@ public void Subexpression_location_changes_propagate_before_outer_resolution() Assert.False(result.IsUnparseable); Assert.Equal(new[] { "Set-Location", "Get-Location", "Write-Output", "Get-Item" }, result.Commands.Select(CommandVerb)); - Assert.Contains(result.Clauses[1].Args, - argument => argument.IsCwdAttribution && argument.Resolved == "C:/sensitive"); - Assert.Contains(result.Clauses[2].Args, - argument => argument.IsCwdAttribution && argument.Resolved == "C:/sensitive"); + Assert.All( + result.Clauses.Skip(1), + clause => Assert.Contains( + clause.Args, + argument => argument.IsCwdAttribution && + argument.Kind == ArgKind.DynamicSkip)); Assert.Contains(result.Clauses[3].Args, - argument => argument.Raw == "child.txt" && - argument.Resolved == "C:/sensitive/child.txt"); + argument => argument.Raw == "child.txt" && argument.Resolved is null); + Assert.Equal( + "C:/work", + Assert.Single(result.Commands[0].WorkingDirectory.Values)); + Assert.All( + result.Commands.Skip(1), + command => Assert.Equal( + ShellValueDomainKind.Unknown, + command.WorkingDirectory.Kind)); + } + + [Fact] + public void Subexpression_and_if_exposes_exact_success_cwd_without_leaking_it_to_outer_flow() + { + var result = Parse( + "Write-Output $(Set-Location C:\\sensitive && Get-Location); Get-Item child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(4, result.Commands.Count); + Assert.Equal( + "C:/work", + Assert.Single(result.Commands[0].WorkingDirectory.Values)); + Assert.Equal( + "C:/sensitive", + Assert.Single(result.Commands[1].WorkingDirectory.Values)); + Assert.All( + result.Commands.Skip(2), + command => Assert.Equal( + ShellValueDomainKind.Unknown, + command.WorkingDirectory.Kind)); } [Fact] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index a275d22..6fe01de 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -946,6 +946,41 @@ public void Bash_runtime_cd_operand_count_and_option_status_match_transfer_gramm Lines(output)); } + [Fact] + public void PowerShell_variable_writer_parameters_overwrite_existing_bindings() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$f='safe'; Write-Output sensitive -OutV f | Out-Null; \"out=<$f>\"; " + + "$f='safe'; Write-Output pipeline -Pi f | ForEach-Object { \"pipeline=<$f>\" }; " + + "$f='safe'; Tee-Object -V f -InputObject tee | Out-Null; \"tee=<$f>\"; " + + "$f='safe'; Write-Output inline -ov:f | Out-Null; \"inline=<$f>\"; " + + "$f='safe'; Write-Output en \u2013OutVariable f | Out-Null; \"en=<$f>\"; " + + "$f='safe'; Write-Output em \u2014OutVariable f | Out-Null; \"em=<$f>\"; " + + "$f='safe'; Write-Output bar \u2015OutVariable f | Out-Null; \"bar=<$f>\""); + + Assert.Equal( + new[] + { + "out=", + "pipeline=", + "tee=", + "inline=", + "en=", + "em=", + "bar=", + }, + Lines(output)); + } + private static bool IsAvailable(string executable) { try diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index ea77fd4..b211b97 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -221,25 +221,25 @@ private static string NestIex(string inner, int depth) "A backtick line continuation joins two physical lines."), // ---- Set-Location propagation (§13: ≥10) ---- - E("setloc_cd_then_git", "cd C:\\repo; git status", + E("setloc_cd_then_git", "cd C:\\repo && git status", "cd attributes C:/repo to the subsequent clause."), - E("setloc_setlocation_then_gci", "Set-Location C:\\src; Get-ChildItem", + E("setloc_setlocation_then_gci", "Set-Location C:\\src && Get-ChildItem", "Set-Location attribution to the next clause."), - E("setloc_two_cd", "cd C:\\a; cd C:\\b; gci", + E("setloc_two_cd", "cd C:\\a && cd C:\\b && gci", "A later Set-Location replaces the attributed cwd."), E("setloc_dynamic_target", "cd $repo; gci", "A dynamic cd target yields dynamic attribution."), E("setloc_dash", "cd -; gci", "Set-Location - (previous location) is not statically knowable."), - E("setloc_path_param", "Set-Location -Path C:\\proj; rm temp.txt", + E("setloc_path_param", "Set-Location -Path C:\\proj && rm temp.txt", "Set-Location target taken from -Path."), - E("setloc_through_group", "(cd C:\\sensitive); Remove-Item *", + E("setloc_through_group", "(cd C:\\sensitive) && Remove-Item *", "Attribution propagates through a group — ( ) is not a subshell."), - E("setloc_tilde", "cd ~; gci", + E("setloc_tilde", "cd ~ && gci", "cd ~ attributes the home directory."), - E("setloc_no_arg", "Set-Location; gci", + E("setloc_no_arg", "Set-Location && gci", "Set-Location with no target attributes the home directory."), - E("setloc_relative_after_literal", "cd C:\\repo; cd sub; gci", + E("setloc_relative_after_literal", "cd C:\\repo && cd sub && gci", "A relative cd resolves under the prior attributed cwd."), E("setloc_non_fs_drive", "cd HKLM:; gci", "cd to a non-FileSystem PSDrive yields dynamic attribution."), @@ -367,7 +367,7 @@ private static string NestIex(string inner, int depth) "The powershell host name is also recognized."), E("recursion_command_noprofile", "pwsh -NoProfile -Command \"Get-Date\"", "A switch before -Command does not block recursion."), - E("recursion_command_setlocation", "pwsh -Command \"cd C:\\x; gci\"", + E("recursion_command_setlocation", "pwsh -Command \"cd C:\\x && gci\"", "Set-Location attribution works inside a recursed command."), E("recursion_command_nested", "pwsh -Command { pwsh -Command { Get-Date } }", "Two-level -Command recursion."), @@ -537,9 +537,9 @@ private static string NestIex(string inner, int depth) "Literal concatenation is not evaluated and collapses to one DynamicSkip arg."), Oos("iex_pipeline_dynamic", "Get-Content script.ps1 | Invoke-Expression", "Pipeline-fed expression code is valid PowerShell but safe-fails as unparseable."), - E("iex_inherits_location", "Set-Location C:\\a; iex 'Remove-Item child.txt'", + E("iex_inherits_location", "Set-Location C:\\a && iex 'Remove-Item child.txt'", "A static payload inherits the caller's effective location."), - E("iex_exports_location", "iex 'Set-Location C:\\b'; Remove-Item child.txt", + E("iex_exports_location", "iex 'Set-Location C:\\b' && Remove-Item child.txt", "A location change inside iex affects following outer clauses."), Oos("iex_recursion_depth_overflow", NestIex("Get-Date", 6), "Six nested static expression strings exceed the shared depth-five cap."), @@ -551,7 +551,7 @@ private static string NestIex(string inner, int depth) "Unicode variable interpolation cannot hide a clean inner verb."), E("iex_comma_array_dynamic", "Invoke-Expression Write-Output,OTHER", "An unquoted comma array is computed rather than one static scalar string."), - E("iex_dynamic_location", "Set-Location C:\\safe; iex $code; Remove-Item child.txt", + E("iex_dynamic_location", "Set-Location C:\\safe && iex $code; Remove-Item child.txt", "Dynamic current-scope code invalidates location attribution for following paths."), E("dynamic_interpolated_iex_name", "& \"i$part\" $code", "An interpolated call-operator command name is dynamic and cannot bypass iex handling."), @@ -561,7 +561,7 @@ private static string NestIex(string inner, int depth) "A backtick newline escape in a bare static payload surfaces both commands."), E("iex_herestring_backtick_newline", "iex @\"\nWrite-Output ok`nGet-Date\n\"@", "An expandable here-string decodes its backtick newline before recursion."), - E("iex_backtick_location", "Set-Location C:\\safe; iex Write-Output` ok`nSet-Location` C:\\evil; Remove-Item child.txt", + E("iex_backtick_location", "Set-Location C:\\safe && iex Write-Output` ok`nSet-Location` C:\\evil && Remove-Item child.txt", "A hidden escaped-newline location change is surfaced and propagated."), E("iex_command_colon_inline", "iex -Command:Get-Date", "The exact colon-form -Command parameter binds an inline static payload."), @@ -575,7 +575,7 @@ private static string NestIex(string inner, int depth) "Decoded form-feed whitespace separates the inner path argument."), E("iex_unicode_whitespace_path", "iex \"Remove-Item`u{2003}C:\\x\"", "Decoded Unicode whitespace separates the inner path argument."), - E("iex_vertical_tab_location", "Set-Location C:\\safe; iex \"Set-Location`vC:\\evil\"; Remove-Item child.txt", + E("iex_vertical_tab_location", "Set-Location C:\\safe && iex \"Set-Location`vC:\\evil\" && Remove-Item child.txt", "Decoded vertical-tab whitespace preserves an inner location change."), E("iex_command_colon_comment", "iex -Command:#comment", "A comment after an empty colon value leaves -Command without a payload."), @@ -593,13 +593,13 @@ private static string NestIex(string inner, int depth) "An unsupported module-qualified inner cmdlet safe-fails instead of hiding its identity."), Oos("iex_module_qualified_location", "Set-Location C:\\safe; iex 'Microsoft.PowerShell.Management\\Set-Location C:\\evil'; Remove-Item child.txt", "An unsupported module-qualified location mutation safe-fails instead of preserving stale cwd."), - E("dynamic_command_location", "Set-Location C:\\safe; & \"i$part\" $code; Get-Item child.txt", + E("dynamic_command_location", "Set-Location C:\\safe && & \"i$part\" $code; Get-Item child.txt", "Any dynamic command invalidates following current-scope location attribution."), E("quoted_expression_command", "\"iex\" 'Get-Date'", "A quoted expression without the call operator is not a command invocation."), Oos("iex_quoted_inner_expression", "Set-Location C:\\safe; iex '\"Set-Location\" C:\\evil'; Get-Item child.txt", "A quoted inner expression is valid outer syntax but not a supported command invocation."), - E("iex_escape_character", "Set-Location C:\\safe; iex \"S`et-Location C:\\evil\"; Get-Item child.txt", + E("iex_escape_character", "Set-Location C:\\safe && iex \"S`et-Location C:\\evil\"; Get-Item child.txt", "The backtick e escape becomes ESC and cannot spoof Set-Location."), E("iex_invalid_unicode_empty", "iex \"Get-`u{}Date\"", "An empty Unicode escape is a PowerShell syntax error."), @@ -746,8 +746,8 @@ private static string NestIex(string inner, int depth) "& $(Write-Output Get-Date) argument", "The call operator exposes the producer and retains an incomplete dynamic invocation."), S("v03_substitution_current_scope_cwd", - "Write-Output $(Set-Location C:\\sensitive; Get-Location); Get-Item child.txt", - "Subexpression location changes affect its consumer and following current-scope commands."), + "Write-Output $(Set-Location C:\\sensitive && Get-Location); Get-Item child.txt", + "A subexpression success continuation sees the target while its failure-aware outer join stays unknown."), S("v03_substitution_redirect", "Get-Content > $(Join-Path C:\\temp out.txt)", "A redirect subexpression is visible while the outer redirect remains incomplete."),