diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 9440bdc..b294443 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -295,7 +295,19 @@ priorities. remaining loop cases and Netclaw approval matrix after implementation. The non-loop state engine is now implemented for lists, pipelines, substitutions, subshells, and decoded wrappers; loop iteration state and - removal of the temporary mutation rejection remain next. + removal of the temporary mutation rejection remain next. An adversarial + pre-implementation review halted the first loop-state draft: parser-time + binding frames could not model zero-iteration persistence, correlated + nested iterables, special Bash variables, or candidate-derived `cd` + options. The corrected contract now requires an explicit + `BashInitialStateMode`, a conservative supported scalar-name boundary, + analyzer-owned persistent bindings, parameterized ordered plans, complete + argument provenance/effective-argv transfer, occurrence-fact joins, and + unreachable flow partitions. Implement that contract before enabling any + cwd-changing loop body. Corpus-pin `HOME`, `RANDOM`, `LINENO`, `PATH`, + `CDPATH`, `IFS`, 32/33 ordered visits, zero iterations, nested + correlation, wrapped transfers, wrapper mapping, substitutions, and + pipelines in the same vertical slice. - [ ] Complete PowerShell `$()` discovery in `foreach` expressions and add the Netclaw approval-matrix cases. The simple-command slice is delivered for ordinary, adjacent, quoted, here-string, redirect, standalone, diff --git a/SPEC.md b/SPEC.md index 238822c..c240b53 100644 --- a/SPEC.md +++ b/SPEC.md @@ -107,10 +107,20 @@ public sealed class PwshParser : IShellParser /// (added v0.2.0). HomeDirectory / WorkingDirectory live here. public abstract record ShellParserOptions { ... } +/// Declares which ambient Bash variable facts the caller can prove. +public enum BashInitialStateMode +{ + Unknown, + IsolatedNonInteractive, +} + /// Configuration knobs for BashParser. As of v0.2.0 a sealed /// record deriving from ShellParserOptions; the v0.1 object-initializer /// shape is unchanged. -public sealed record BashParserOptions : ShellParserOptions; +public sealed record BashParserOptions : ShellParserOptions +{ + public BashInitialStateMode InitialStateMode { get; init; } +} /// Configuration knobs for PwshParser (v0.2.0). Empty — the /// resolver knobs live on ShellParserOptions. @@ -230,6 +240,36 @@ public sealed record ParsedCommand } ``` +`BashInitialStateMode.Unknown` is the default. In this mode the parser does +not publish bounded loop-variable facts: an ambient shell may already have +made the binding readonly, integer-valued, a nameref, exported, or otherwise +semantically significant. A Bash loop whose safety depends on such a binding +is therefore unparseable rather than being analyzed as an ordinary scalar. + +`BashInitialStateMode.IsolatedNonInteractive` is an explicit caller assertion, +not a parser discovery. It means the complete source is executed by a newly +spawned non-interactive Bash process, no profile or `BASH_ENV` / `ENV` startup +content is loaded, and no inherited environment entry carries the loop-bound +name. A consumer may select this mode only when its execution path enforces +those conditions. Supplying this option while executing in a reused, +interactive, startup-scripted, or uncontrolled environment invalidates the +analysis. + +Recognized variable-state mutation in the analyzed source invalidates isolated +mode for every later region that can observe it. In particular, a decoded +`bash -c` child after `export` is analyzed with unknown initial variable state; +the option is not blindly copied into the child. Cwd-only state changes retain +the caller's initial-variable assertion. + +Even in isolated mode, the v0.3 bounded loop grammar accepts only ordinary +lowercase scalar binding names matching `[a-z][a-z0-9_]*`, excluding +`auto_resume` and `histchars`. `_`, uppercase names, and every name outside +that boundary fail the complete loop region closed. This deliberately excludes +Bash magic variables and resolver- or executable-identity-sensitive names such +as `RANDOM`, `LINENO`, `HOME`, `PATH`, `CDPATH`, and `IFS`. The boundary is +extend-only: a later version may add a proved variable-state model or +additional explicitly reviewed ordinary names. + For a successful result, every authored simple command appears once in `Syntax`, once in `Commands`, and once in `Clauses`, with all three projections referencing the identical in-memory `Clause` instance. Serialization is not @@ -534,6 +574,76 @@ simple-command leaves do not independently increment it. Exceeding 16 structural containers or 5 decoded-command wrapper recursions makes the entire result unparseable. +#### Bash bounded loop state + +Loop bindings are analyzer-owned shell state; they are not lexical parser +frames. A nonempty Bash loop leaves its final assigned value visible after +`done`, a loop that executes zero times preserves the incoming value, and a +same-name nested loop does not restore an outer value. v0.3 may continue to +reject nested active-name reuse until that overwrite behavior is implemented; +it must never model the construct as lexical shadowing. + +The Bash front end retains every iterable word and every resolver-relevant +argument fragment as parser-owned internal provenance. The abstract-state pass +then evaluates the iterable once from its incoming variable state and creates +one of these internal plans: + +- `Never` for an explicit empty iterable; +- an ordered, duplicate-preserving sequence for at most 32 concrete + iterations; or +- `ZeroOrMore` / `OneOrMore` fixed-point analysis when cardinality or an + ordered sequence is not bounded. + +The public `FiniteSet` is only a value summary. It is never used as an +iteration plan: `a b a` performs three state transitions and leaves an exact +final binding of `a`; 33 authored values use widening even when every value is +the same. An iterable that depends on an outer binding is evaluated separately +for each concrete outer visit so correlated nested state is not flattened into +an artificial cross-product. + +Each concrete iteration assigns its candidate into the analyzer variable map, +re-evaluates the complete effective argument vector for every body occurrence, +and carries the joined reachable cwd and variable state into the next +iteration. This re-evaluation includes state-transfer option grammar. For +example, a loop-derived `cd` argument may become `-P`, `--`, `-`, or an +operand; the analyzer may not substitute only an operand string while retaining +authored flag classification. Effective argument facts at one authored +occurrence join the values from every reachable visit. + +Bash flow retains separate reachable success and failure states. `&&` analyzes +only a reachable success continuation, `||` only a reachable failure +continuation, and sequence operators consume their join. A missing partition +is unreachable and must not be replaced with the joined input merely to +populate exact facts. Structurally present but unreachable commands remain in +the syntax/occurrence projection with conservative facts. An empty loop exits +successfully without a body transition; a known nonempty loop exposes the +final body's exit status; a zero-or-more loop joins its zero path with every +reachable normal exit. Bounded fixed-point analysis widens differing cwd or +variable values to `Unknown` rather than selecting one path. + +`cd` and `chdir` use the effective argument vector for the current visit. +`pushd` and `popd` may be recognized only with unknown success cwd until the +directory stack is modeled. Variable mutators (`read`, `unset`, `printf -v`, +`export`, `declare`, and equivalents), `eval`, `source` / `.`, and +execution-bearing `trap` make the complete loop region unparseable. The same is +true for `break`, `continue`, `return`, `exit`, and `exec` until their transfers +are implemented. Recognition recursively unwraps statically proved `command` +and `builtin` dispatch; a wrapper must not bypass the rejection. + +Substitutions and subshells inherit the current variable/cwd state but discard +their state changes on exit. Decoded Bash command wrappers inherit invocation +cwd but no loop binding unless export is separately proved. Pipeline stages +enter from the same pipeline input; possible `lastpipe` leakage joins the full +cwd and variable state, independently of conservative `pipefail` exit +partitioning. + +Compatibility arguments always retain authored loop-variable spelling. A +variable-derived path that is not independently exact keeps or becomes +`DynamicSkip`, and a relative path whose reachable visit cwds disagree loses +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. + ### Explicit redirect analysis (v0.3) Occurrence-specific redirect analysis is additive. The existing `Redirect` @@ -1035,12 +1145,17 @@ bash_else := "else" bash_script(stop = "fi") list_sep := ";" | NEWLINE list_terminator := ";" | NEWLINE+ -binding_name := shell_identifier +binding_name := supported_scalar_binding +supported_scalar_binding := [a-z][a-z0-9_]* + except "auto_resume" and "histchars" iterable_word := word | quoted_string | supported_substitution ``` The supported stable-v0.3 set is the existing simple-command grammar plus -`for name in words`, `while` / `until`, and `if` / `elif` / `else`. Every fully +`for name in words`, `while` / `until`, and `if` / `elif` / `else`. Bash +accepts additional shell identifiers as loop variables, but this bounded +grammar fails them closed for the initial-state reasons specified in §2. +Every fully delimited `$()` command substitution in a supported simple-command argument, redirect value, iterable, or expanding heredoc body is recursively parsed and exposes its inner commands; its produced value remains `Unknown`. A nested diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 47c7254..3e25751 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -169,6 +169,35 @@ are: scope, cwd, or redirects. A structurally complete occurrence may still have an unknown value; those are separate facts. +Bash loop-variable proofs also require an execution-environment assertion. +`BashInitialStateMode.Unknown` is the safe default and makes a bounded `for` +region unparseable: the parser cannot discover whether an ambient variable is +readonly, integer-valued, a nameref, exported, or shell-owned. Select +`IsolatedNonInteractive` only when the same component that calls the parser +also enforces all of these execution conditions: + +- the source is the complete input to a newly spawned non-interactive Bash; +- no profile, `BASH_ENV`, or `ENV` startup content can run; and +- no inherited environment entry carries a loop-bound name. + +```csharp +var parser = new BashParser(new BashParserOptions +{ + WorkingDirectory = workingDirectory, + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, +}); +``` + +Do not select the mode merely because a command *looks* self-contained. A +consumer that parses under isolated assumptions but executes in a reused or +startup-scripted shell has invalidated the authorization proof. Stable v0.3 +also fails uppercase, underscore-prefixed, and Bash-owned lowercase loop names +closed; `HOME`, `RANDOM`, `LINENO`, `PATH`, `CDPATH`, and `IFS` are intentionally +outside the first bounded scalar grammar. The parser also downgrades a decoded +`bash -c` child's initial state after a preceding variable mutation such as +`export`; resolver-only option cloning for an exact cwd retains the independent +variable-state assertion. + Heredoc and Bash here-string bodies are stdin data, not implicit child commands or filesystem paths. Authorize any command substitutions surfaced from an expanding heredoc as normal occurrences, then let executable-specific policy diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index feead7b..0d2bc1a 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -406,6 +406,23 @@ shell rules prove the resulting argument boundary. Unquoted Bash expansion, PowerShell object-valued pipelines, indirect expansion, mutation, and cross-product explosion remain unknown until separately specified. +Bash loop binding also requires a proved initial shell-variable environment. +`BashParserOptions.InitialStateMode` defaults to `Unknown`; bounded Bash loop +analysis is unavailable in that mode because an ambient binding may be +readonly, integer-valued, a nameref, exported, or otherwise stateful. +`IsolatedNonInteractive` is a caller assertion that the complete source runs in +a new non-interactive Bash process without profile, `BASH_ENV`, or `ENV` +startup content and without an inherited entry for the bound name. The v0.3 +positive grammar is deliberately limited to lowercase ordinary scalar names +matching `[a-z][a-z0-9_]*`, excluding `auto_resume` and `histchars`. Names +outside that boundary fail the whole loop region closed. This excludes Bash +magic and identity-sensitive bindings including `RANDOM`, `LINENO`, `HOME`, +`PATH`, `CDPATH`, and `IFS` without attempting an incomplete denylist. +Recognized source-level variable mutation invalidates isolated mode in every +later scope that can observe it. A decoded `bash -c` after `export` therefore +enters with `Unknown` initial variable state, while a cwd-only transfer retains +the caller's variable-state assertion. + Effective values are shell facts, not executable semantics. The analysis must preserve both the authored shell classification and each proved effective value. PowerShell does not retroactively turn a string value such as `-Force` @@ -457,6 +474,11 @@ visible to later inner occurrences but do not leak on wrapper exit. This v0.3 occurrence rule corrects the old compatibility attribution shortcut without requiring v0.2 leaves to invent a new exact path. +Decoded-wrapper cloning must remap every parser-owned loop plan and argument +provenance fact to the cloned syntax/Clause references. Losing a side-table +entry is not permission to analyze one representative iteration; the wrapper +region fails closed if the mapping cannot be proved complete. + Exact and finite Bash `for ... in` domains are analyzed in authored iteration order within the 32-candidate cap. Each iteration consumes the joined reachable state from the preceding iteration, and the loop exit joins every reachable @@ -471,13 +493,38 @@ wrapped builtin forms such as `builtin break` and `command exit`. `eval`, unless every executable region and state transfer is discovered. The internal loop plan is distinct from the public value-domain summary. It -retains ordered per-word candidates including duplicates and a cardinality of -`Never`, `OneOrMore`, or `ZeroOrMore`. Thus `a b a` has a final exact binding of -`a`, while an explicit empty iterable has no body transition at all. All -reachable visits to one authored body occurrence join their input facts. If an -ordered iteration sequence exceeds the candidate budget, the analyzer uses a -bounded fixed point and widening; it does not select the last retained distinct -candidate as the post-loop binding. +retains an executable word plan parameterized by the incoming analyzer binding +map, plus a cardinality of `Never`, `OneOrMore`, or `ZeroOrMore`. Evaluating the +plan preserves every concrete candidate in authored order, including +duplicates. Thus `a b a` has a final exact binding of `a`, while an explicit +empty iterable has no body transition and preserves the incoming binding. All +reachable visits to one authored body occurrence join their input facts. The +32-candidate cap applies to ordered iterations, not distinct public values: 33 +authored `a` words use a bounded fixed point and widening rather than selecting +one retained value. An inner iterable is re-evaluated for each concrete outer +binding so nested correlation is not flattened before execution-state +analysis. + +The analyzer, not the structural parser, owns the variable map and binding +lifetime. Bash loop assignment is not lexical scope: a nonempty loop leaves the +last value after `done`, zero iterations preserve the incoming value, and a +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. + +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 +coordinate, and applies state-transfer grammar to the effective argv. This is +required for `cd "$f"`: a candidate may be `-P`, `--`, `-`, or an operand even +though the authored expansion was not lexed as an option. Operand-only string +substitution is not an acceptable shortcut. + +An unreachable success or failure partition stays unreachable. `&&` and `||` +must not replace a missing input partition with `JoinedState` to manufacture +facts for a structurally present but unreachable continuation. Such commands +remain projected with conservative facts. This is required before the analyzer +can expose the one-sided success result of an explicit empty loop. Bash pipeline stages enter from the same pipeline input state. Ordinary stage state does not leak, but the analyzer cannot assume the last stage is isolated 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 9391f36..555d866 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 @@ -133,6 +133,41 @@ retain their existing meanings. - **THEN** a PSDrive remains unknown without a proved drive-to-provider mapping - **THEN** quoted syntax does not turn either target into native-style literal text +### Requirement: Bash loop proofs require an explicit initial-state contract +`BashParserOptions.InitialStateMode` SHALL default to `Unknown`. A Bash loop +whose binding semantics depend on an unknown ambient shell SHALL fail closed +rather than publishing an ordinary-scalar proof. + +`IsolatedNonInteractive` SHALL be an explicit caller assertion that the entire +source runs in a newly spawned non-interactive Bash process, no profile or +`BASH_ENV` / `ENV` startup content is loaded, and no inherited environment +entry carries the bound name. The v0.3 bounded grammar SHALL accept only names +matching `[a-z][a-z0-9_]*`, excluding `auto_resume` and `histchars`, under that +mode. All other binding names SHALL make the complete loop region unparseable. +Recognized source-level variable mutation SHALL invalidate isolated mode for +later scopes that can observe it. A decoded Bash wrapper after `export` SHALL +not inherit the original isolated assertion; cwd-only mutation SHALL not erase +the independently proved variable-state mode. + +#### Scenario: Unknown ambient variable state fails closed +- **WHEN** Bash parses `for f in a; do printf '%s' "$f"; done` with the default initial-state mode +- **THEN** it does not assume that `f` is an ordinary writable scalar +- **THEN** the complete result is unparseable + +#### Scenario: Isolated ordinary scalar is eligible +- **WHEN** the caller selects `IsolatedNonInteractive` and parses `for f in a; do printf '%s' "$f"; done` +- **THEN** the bounded loop analyzer may prove `f` exact + +#### Scenario: Magic and resolver-sensitive names fail closed +- **WHEN** isolated-mode Bash parses a loop binding named `HOME`, `RANDOM`, `LINENO`, `PATH`, `CDPATH`, `IFS`, `_`, `auto_resume`, or `histchars` +- **THEN** the complete loop region is unparseable +- **THEN** no compatibility path or effective argument is published from an ordinary-scalar assumption + +#### Scenario: Outer export invalidates a decoded loop environment +- **WHEN** isolated-mode Bash parses `export f=ambient; bash -c 'for f in a; do printf %s "$f"; done'` +- **THEN** the decoded child enters with unknown initial variable state +- **THEN** the complete result is unparseable rather than publishing an isolated scalar proof + ### Requirement: Shell values use explicit proof domains The analysis SHALL classify a policy-relevant shell value as exact, finite, bounded symbolic pattern, or unknown, and SHALL NOT present a weaker proof as a @@ -144,11 +179,11 @@ no values and SHALL contain a non-empty pattern and covering directory. The parser SHALL NOT emit any other member combination. #### Scenario: One literal value -- **WHEN** a loop binds a variable from the single literal `a.txt` +- **WHEN** an eligible isolated-mode loop binds a variable from the single literal `a.txt` - **THEN** the binding domain is exact with value `a.txt` #### Scenario: Finite literal values -- **WHEN** Bash parses `for f in a.txt b.txt; do rm -- "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in a.txt b.txt; do rm -- "$f"; done` - **THEN** the binding domain is the finite set `a.txt`, `b.txt` #### Scenario: Runtime-produced values @@ -162,12 +197,12 @@ domain of 32 candidates remains finite; a domain that would contain 33 or more becomes unknown rather than being truncated. #### Scenario: Glob is not enumerated -- **WHEN** Bash parses `for f in /tmp/*.txt; do rm -- "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in /tmp/*.txt; do rm -- "$f"; done` - **THEN** the parser does not read `/tmp` - **THEN** it exposes a pattern with conservative covering directory `/tmp` #### Scenario: Dynamic glob root is unknown -- **WHEN** Bash parses `for f in "$ROOT"/*.txt; do rm -- "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in "$ROOT"/*.txt; do rm -- "$f"; done` - **THEN** the dynamic root prevents a static covering-directory proof - **THEN** the iterable value is unknown @@ -231,12 +266,12 @@ the shell's binding rules and interpret every native candidate through a complete executable-aware grammar before reusing authorization. #### Scenario: Finite value injects an rm option -- **WHEN** Bash parses `for f in -rf /tmp/x; do rm "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in -rf /tmp/x; do rm "$f"; done` - **THEN** the finite domain preserves `-rf` and `/tmp/x` - **THEN** the parser does not claim that quoting makes `-rf` a non-option #### Scenario: Explicit option terminator -- **WHEN** Bash parses `for f in -rf /tmp/x; do rm -- "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in -rf /tmp/x; do rm -- "$f"; done` - **THEN** the authored `--` remains visible before the effective candidate - **THEN** the consumer may account for it using rm semantics @@ -273,14 +308,27 @@ Exact and finite Bash `for ... in` domains SHALL be analyzed in authored iteration order, including duplicates, within the candidate cap. The analyzer SHALL retain independent internal cardinality of `Never`, `OneOrMore`, or `ZeroOrMore`; the public finite-set summary SHALL NOT be used as an ordered -iteration plan. Pattern, unknown, and over-budget domains SHALL use a bounded +iteration plan. The cap SHALL count ordered concrete iterations, not distinct +public values. Pattern, unknown, and over-budget domains SHALL use a bounded conservative fixed point and SHALL NOT be represented by one arbitrarily -selected iteration. Every reachable visit to one authored occurrence SHALL -join its input facts. A transfer such as `break`, `continue`, `return`, `exit`, -or `exec`, including statically wrapped builtin forms, SHALL make the containing -region unparseable until the analyzer implements that transfer explicitly. -`eval`, `source` / `.`, and execution-bearing `trap` SHALL likewise fail closed -unless all executable regions and transfers are discovered. +selected iteration. An inner iterable SHALL be evaluated from each current +outer binding rather than from a flattened public summary. + +The analyzer SHALL own loop-variable lifetime and SHALL re-evaluate every +argument's complete shell-value provenance for each concrete visit. Effective +argument facts at one authored occurrence SHALL join across reachable visits. +State transfers such as `cd` SHALL parse the complete effective argv, including +candidate-derived options and option terminators, rather than substituting only +an operand. A transfer such as `break`, `continue`, `return`, `exit`, or `exec`, +including recursively wrapped builtin forms, SHALL make the containing region +unparseable until the analyzer implements that transfer explicitly. `eval`, +`source` / `.`, execution-bearing `trap`, and mutation of tracked bindings +SHALL likewise fail closed unless all executable regions and transfers are +discovered. + +An unreachable success or failure partition SHALL remain unreachable. The +analyzer SHALL NOT substitute a joined state for a missing `&&` or `||` +partition merely to publish exact continuation facts. #### Scenario: Branch-dependent cwd - **WHEN** one branch changes cwd to `/a` and another changes cwd to `/b` @@ -310,15 +358,36 @@ unless all executable regions and transfers are discovered. - **THEN** the post-loop state includes the pre-loop possibility #### Scenario: Proved empty loop does not mutate state -- **WHEN** Bash parses `for f in; do cd /tmp; done; pwd` +- **WHEN** isolated-mode Bash parses `for f in; do cd /tmp; done; pwd` - **THEN** the internal iteration cardinality is `Never` - **THEN** the following `pwd` retains the exact incoming cwd #### Scenario: Duplicate iteration values retain order -- **WHEN** Bash parses `for f in a b a; do :; done; printf '%s' "$f"` +- **WHEN** isolated-mode Bash parses `for f in a b a; do :; done; printf '%s' "$f"` - **THEN** the internal iteration plan retains `a`, `b`, `a` in that order - **THEN** the following use of `f` has exact effective value `a` +#### 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 +- **THEN** it uses bounded fixed-point analysis instead of treating one distinct public value as one visit + +#### Scenario: Loop-derived cd option is rebound from effective argv +- **WHEN** isolated-mode Bash analyzes `for f in -P /tmp; do cd "$f"; done` +- **THEN** the first visit treats `-P` as a `cd` option rather than a path operand +- **THEN** the second visit treats `/tmp` as the operand under the resulting option grammar +- **THEN** no state transfer reuses the authored `$f` flag classification + +#### Scenario: Empty-loop failure continuation is unreachable +- **WHEN** isolated-mode Bash parses `for f in; do false; done || cat relative.txt` +- **THEN** the empty loop has only a reachable success exit +- **THEN** `cat` remains structurally visible but receives no fabricated exact cwd or binding facts from a failure fallback + +#### Scenario: Same-name loop binding is not lexical shadowing +- **WHEN** a nested Bash loop reuses its active outer binding name +- **THEN** v0.3 either implements the inner assignment as overwriting shell state or makes the whole region unparseable +- **THEN** it never restores an outer value through parser-frame pop semantics + #### Scenario: Isolated shell scope - **WHEN** a supported subshell or scope-isolated group changes cwd - **THEN** that cwd does not leak into the enclosing continuation @@ -341,7 +410,7 @@ unless all executable regions and transfers are discovered. - **THEN** the following outer `pwd` uses `/outer` #### Scenario: Decoded Bash wrapper does not inherit an unexported loop binding -- **WHEN** Bash parses `for f in a; do bash -c 'printf "%s" "$f"'; done` +- **WHEN** isolated-mode Bash parses `for f in a; do bash -c 'printf "%s" "$f"'; done` - **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 diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md index 3578b7e..07e1888 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md @@ -20,7 +20,7 @@ it SHALL NOT omit the marker and thereby remove a v0.2 consumer's fail-closed signal. #### Scenario: Old consumer sees loop body command -- **WHEN** Bash fully parses `for f in a b; do rm "$f"; done` +- **WHEN** isolated-mode Bash fully parses `for f in a b; do rm "$f"; done` - **THEN** the compatibility clauses include the authored `rm` command - **THEN** its authored variable argument remains conservatively dynamic rather than being silently replaced diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md index c9302b5..7178e83 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md @@ -11,7 +11,7 @@ of whether the command is top-level or nested. - **THEN** the collection does not predict which branch will run #### Scenario: Loop body occurrence is not multiplied -- **WHEN** Bash parses `for f in a b c; do echo "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in a b c; do echo "$f"; done` - **THEN** the authored `echo` command appears once - **THEN** its possible effective values are represented by analysis facts rather than three duplicated occurrences @@ -100,7 +100,7 @@ partial command and compatibility result. - **THEN** `Remove-Item` is identified as a loop-body occurrence #### Scenario: Pipeline stage nested in a loop body -- **WHEN** Bash parses `for f in a b; do printf '%s\n' "$f" | sort; done` +- **WHEN** isolated-mode Bash parses `for f in a b; do printf '%s\n' "$f" | sort; done` - **THEN** `printf` and `sort` have the immediate role pipeline stage - **THEN** their ancestry also identifies the enclosing loop body @@ -110,7 +110,7 @@ iterator, substitution, or nested command in the occurrence collection even when the produced value is unknown. #### Scenario: Bash command substitution iterable -- **WHEN** Bash supports and parses `for f in $(find /tmp -type f); do rm "$f"; done` +- **WHEN** isolated-mode Bash supports and parses `for f in $(find /tmp -type f); do rm "$f"; done` - **THEN** both `find` and `rm` appear in the occurrence collection - **THEN** the value produced by `find` is not presented as exact or finite @@ -153,7 +153,7 @@ structural collection. For an embedded simple-command value this is the iterator-command collection. #### Scenario: Iterator precedes body -- **WHEN** Bash parses `for f in $(find .); do rm "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in $(find .); do rm "$f"; done` - **THEN** the `find` occurrence precedes the `rm` occurrence #### Scenario: Branches preserve authored order diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md index f29aeea..0af7d0b 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md @@ -106,13 +106,13 @@ loop as a typed loop node with its binding name, authored iterable expression, and nested body block. #### Scenario: Literal Bash loop -- **WHEN** Bash parses `for f in a.txt b.txt; do rm -- "$f"; done` +- **WHEN** isolated-mode Bash parses `for f in a.txt b.txt; do rm -- "$f"; done` - **THEN** the root contains one loop node binding `f` - **THEN** the iterable preserves `a.txt` and `b.txt` in source order - **THEN** the body contains one simple command for `rm -- "$f"` #### Scenario: Nested Bash loop -- **WHEN** Bash parses `for d in a b; do for f in x y; do echo "$d/$f"; done; done` +- **WHEN** isolated-mode Bash parses `for d in a b; do for f in x y; do echo "$d/$f"; done; done` - **THEN** the outer loop body contains the inner loop node - **THEN** the `echo` command remains nested beneath both loops @@ -159,7 +159,7 @@ Each direct-source structural node SHALL carry a source range into content SHALL report an unavailable outer range unless an exact mapping exists. #### Scenario: Direct loop span -- **WHEN** Bash parses a direct `for` loop +- **WHEN** isolated-mode Bash parses a direct supported `for` loop - **THEN** the loop range starts at `for` and ends after `done` #### Scenario: Decoded wrapper span diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 7376007..48e8b4f 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -82,6 +82,15 @@ compatibility paths and retain `` after conservative joins. - [ ] 6.5c Carry ordered loop binding and cwd state through zero-or-more iterations, then remove the temporary loop-mutation rejection. + - [x] 6.5c.1 Correct the contract after adversarial review: require an + explicit isolated initial-state mode and supported scalar-name boundary; + make the analyzer own persistent bindings, parameterized ordered plans, + full argument provenance/effective-argv transfer, occurrence-fact joins, + and unreachable exit partitions. + - [ ] 6.5c.2 Implement that corrected contract and corpus-pin `HOME`, + `RANDOM`, `LINENO`, `PATH`, `CDPATH`, `IFS`, 32/33 ordered visits, + zero-iteration state, loop-derived `cd` options, nested correlation, + wrapped transfers, wrapper mapping, substitutions, and pipelines. - The first static-value slice deliberately leaves occurrence cwd Unknown and rejects loop shell-state mutation, nested active-binding reuse, or loops reached after recognized prior shell-state mutation. A separate diff --git a/src/ShellSyntaxTree/BashParser.cs b/src/ShellSyntaxTree/BashParser.cs index 01bd37a..2321542 100644 --- a/src/ShellSyntaxTree/BashParser.cs +++ b/src/ShellSyntaxTree/BashParser.cs @@ -22,7 +22,7 @@ public BashParser() : this(new BashParserOptions()) /// /// Create a parser with the supplied options. /// - /// Resolver knobs (home / working directory). + /// Resolver and initial-state analysis options. public BashParser(BashParserOptions options) { if (options is null) diff --git a/src/ShellSyntaxTree/BashParserOptions.cs b/src/ShellSyntaxTree/BashParserOptions.cs index 6dcf088..d58244f 100644 --- a/src/ShellSyntaxTree/BashParserOptions.cs +++ b/src/ShellSyntaxTree/BashParserOptions.cs @@ -5,6 +5,19 @@ // ----------------------------------------------------------------------- namespace ShellSyntaxTree; +/// Declares which ambient Bash variable facts the caller can prove. +public enum BashInitialStateMode +{ + /// No safe assumption is made about ambient Bash variable state. + Unknown, + + /// + /// The source runs in a new non-interactive Bash process without startup + /// content or an inherited environment entry for a loop-bound name. + /// + IsolatedNonInteractive, +} + /// /// Configuration knobs for . The resolver knobs /// ( / @@ -13,4 +26,11 @@ namespace ShellSyntaxTree; /// with v0.1 — new BashParserOptions { HomeDirectory = ... } still /// compiles. See SPEC.POWERSHELL.md §2. /// -public sealed record BashParserOptions : ShellParserOptions; +public sealed record BashParserOptions : ShellParserOptions +{ + /// + /// Gets the caller-proved initial Bash shell-state contract. The default + /// fails bounded loop-variable analysis closed. + /// + public BashInitialStateMode InitialStateMode { get; init; } +} diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 94ae539..45e86a8 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -115,6 +115,7 @@ private sealed class StructuralCoordinator private int _subshellDepth; private int _loopDepth; private bool _hasUnmodeledShellStateMutation; + private bool _hasUnmodeledVariableStateMutation; internal StructuralCoordinator( string source, @@ -126,7 +127,8 @@ internal StructuralCoordinator( int sourceStart, int sourceLength, BashLoopBindingContext? bindings = null, - bool hasUnmodeledShellStateMutation = false) + bool hasUnmodeledShellStateMutation = false, + bool hasUnmodeledVariableStateMutation = false) { _source = source; _tokens = tokens; @@ -138,6 +140,7 @@ internal StructuralCoordinator( _sourceLength = sourceLength; _bindings = bindings ?? new BashLoopBindingContext(); _hasUnmodeledShellStateMutation = hasUnmodeledShellStateMutation; + _hasUnmodeledVariableStateMutation = hasUnmodeledVariableStateMutation; } internal CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) => @@ -422,9 +425,12 @@ private bool TryParseCommand( return false; } + var innerOptions = _hasUnmodeledVariableStateMutation + ? _options with { InitialStateMode = BashInitialStateMode.Unknown } + : _options; var innerResult = ParseInternal( innerCommand!, - _options, + innerOptions, _bashCDepth + 1, _structuralDepth + _subshellDepth + _loopDepth + 1, markBashCWrapped: true); @@ -478,6 +484,7 @@ private bool TryParseCommand( { HomeDirectory = _options.HomeDirectory, WorkingDirectory = _attribution.ResolvedCwd, + InitialStateMode = _options.InitialStateMode, }; } else if (_attribution.IsDynamic) @@ -516,6 +523,8 @@ private bool TryParseCommand( } _hasUnmodeledShellStateMutation |= isPotentialStateMutation; + _hasUnmodeledVariableStateMutation |= + IsPotentialVariableStateMutation(emitted); if (!TryParseCommandSubstitutions( substitutionFragments, @@ -593,6 +602,18 @@ private bool TryParseForIn( } var bindingToken = _tokens[_position++]; + if (_options.InitialStateMode != BashInitialStateMode.IsolatedNonInteractive) + { + error = "Bash for-in requires a proved isolated non-interactive initial state"; + return false; + } + + if (!IsSupportedScalarBinding(bindingToken.Value)) + { + error = "Bash for-in binding is outside the supported ordinary-scalar boundary"; + return false; + } + if (_bindings.Contains(bindingToken.Value)) { error = "nested Bash for-in binding reuse requires state propagation"; @@ -768,6 +789,7 @@ private bool TryParseSubshell( var open = _tokens[_position++]; var outerMutationState = _hasUnmodeledShellStateMutation; + var outerVariableMutationState = _hasUnmodeledVariableStateMutation; _attribution.PushForSubshell(); _subshellDepth++; var parsed = TryParseList( @@ -779,6 +801,7 @@ private bool TryParseSubshell( _subshellDepth--; _attribution.PopForSubshell(); _hasUnmodeledShellStateMutation = outerMutationState; + _hasUnmodeledVariableStateMutation = outerVariableMutationState; if (!parsed) { @@ -885,6 +908,7 @@ private BashParserOptions CurrentOptions() => { HomeDirectory = _options.HomeDirectory, WorkingDirectory = _attribution.ResolvedCwd, + InitialStateMode = _options.InitialStateMode, } : _options; @@ -1153,7 +1177,8 @@ private bool TryParseSubstitutionBody( sourceStart, sourceLength, _bindings.Clone(), - _hasUnmodeledShellStateMutation); + _hasUnmodeledShellStateMutation, + _hasUnmodeledVariableStateMutation); if (!coordinator.TryParse(out body, out error)) { return false; @@ -1356,6 +1381,17 @@ private static bool IsPotentialBindingMutation(Clause clause) return false; } + private static bool IsPotentialVariableStateMutation(Clause clause) + { + if (clause.Verb.Tokens.Count > 0 && + clause.Verb.Tokens[0] is "cd" or "chdir" or "pushd" or "popd") + { + return false; + } + + return IsPotentialBindingMutation(clause); + } + private static bool IsBashIdentifier(string value) { if (value.Length == 0 || !IsBashIdentifierStart(value[0])) @@ -1374,6 +1410,28 @@ private static bool IsBashIdentifier(string value) return true; } + private static bool IsSupportedScalarBinding(string value) + { + if (value.Length == 0 || value[0] < 'a' || value[0] > 'z' || + value is "auto_resume" or "histchars") + { + return false; + } + + for (var index = 1; index < value.Length; index++) + { + var character = value[index]; + if ((character < 'a' || character > 'z') && + (character < '0' || character > '9') && + character != '_') + { + return false; + } + } + + return true; + } + private sealed class ClauseReferenceComparer : IEqualityComparer { internal static ClauseReferenceComparer Instance { get; } = new(); diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index 1b01e1f..febb18c 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -466,6 +466,7 @@ private static void AssertClauseElementInvariants(ParsedCommand parsed, string c { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }), "powershell" => new PwshParser(new PwshParserOptions { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/230_v03_for_home_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/230_v03_for_home_binding_rejected.json new file mode 100644 index 0000000..47c9b50 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/230_v03_for_home_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects HOME binding", + "input": "for HOME in /tmp; do cat \"$HOME/x\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "Binding HOME must not retain the configured compatibility home path or publish ordinary-scalar effective facts." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/231_v03_for_random_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/231_v03_for_random_binding_rejected.json new file mode 100644 index 0000000..ee1fd14 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/231_v03_for_random_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects RANDOM binding", + "input": "for RANDOM in 7; do printf '%s' \"$RANDOM\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "RANDOM has Bash-owned read semantics and cannot inherit an ordinary scalar proof." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/232_v03_for_lineno_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/232_v03_for_lineno_binding_rejected.json new file mode 100644 index 0000000..97026fc --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/232_v03_for_lineno_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects LINENO binding", + "input": "for LINENO in 7; do printf '%s' \"$LINENO\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "LINENO remains shell-computed and cannot inherit the authored iterable value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/233_v03_for_path_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/233_v03_for_path_binding_rejected.json new file mode 100644 index 0000000..a85171c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/233_v03_for_path_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects PATH binding", + "input": "for PATH in /tmp/bin; do git status; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "PATH assignment changes executable identity and is outside bounded loop-variable analysis." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/234_v03_for_cdpath_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/234_v03_for_cdpath_binding_rejected.json new file mode 100644 index 0000000..dac6d43 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/234_v03_for_cdpath_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects CDPATH binding", + "input": "for CDPATH in /tmp; do cd child; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "CDPATH changes cd operand resolution and is outside bounded loop-variable analysis." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/235_v03_for_ifs_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/235_v03_for_ifs_binding_rejected.json new file mode 100644 index 0000000..574c2ef --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/235_v03_for_ifs_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects IFS binding", + "input": "for IFS in x; do printf '%s' \"$IFS\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "IFS changes shell splitting semantics and is outside bounded loop-variable analysis." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/236_v03_for_auto_resume_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/236_v03_for_auto_resume_binding_rejected.json new file mode 100644 index 0000000..2c1e787 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/236_v03_for_auto_resume_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects auto_resume binding", + "input": "for auto_resume in exact; do printf '%s' \"$auto_resume\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "The lowercase Bash-owned auto_resume variable is explicitly outside the supported scalar boundary." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/237_v03_for_underscore_binding_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/237_v03_for_underscore_binding_rejected.json new file mode 100644 index 0000000..16b2f88 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/237_v03_for_underscore_binding_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash for-in rejects underscore binding", + "input": "for _ in exact; do printf '%s' \"$_\"; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "ordinary-scalar boundary" + }, + "notes": "The Bash-owned underscore parameter is not an ordinary scalar binding." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/238_v03_for_wrapper_export_state_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/238_v03_for_wrapper_export_state_rejected.json new file mode 100644 index 0000000..00361b4 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/238_v03_for_wrapper_export_state_rejected.json @@ -0,0 +1,9 @@ +{ + "name": "v0.3 Bash decoded for-in rejects outer exported state", + "input": "export f=ambient; bash -c 'for f in a; do printf %s \"$f\"; done'", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "isolated non-interactive initial state" + }, + "notes": "A decoded wrapper cannot reuse isolated loop assumptions after the outer source changes inherited variable state." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs index ec6b6e5..bc076dc 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs @@ -405,6 +405,7 @@ private static void ValidateWorkingDirectory( { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }), DesignShell.PowerShell => new PwshParser(new PwshParserOptions { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs index 147c3d1..de9820a 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs @@ -30,6 +30,7 @@ private static ParsedCommand Parse(string input) { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }); return parser.Parse(input); } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs index 3a4d671..eb1249d 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs @@ -11,6 +11,55 @@ namespace ShellSyntaxTree.Tests.Parsing; /// Pins the bounded Bash for name in words vertical slice. public class BashForInStructuralTests { + [Fact] + public void Default_initial_state_fails_loop_binding_closed() + { + var result = new BashParser(new BashParserOptions + { + HomeDirectory = "/home/test", + WorkingDirectory = "/work", + }).Parse("for f in a; do printf '%s' \"$f\"; done"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("isolated non-interactive initial state", result.UnparseableReason!); + } + + [Theory] + [InlineData("HOME")] + [InlineData("RANDOM")] + [InlineData("LINENO")] + [InlineData("PATH")] + [InlineData("CDPATH")] + [InlineData("IFS")] + [InlineData("_")] + [InlineData("auto_resume")] + [InlineData("histchars")] + [InlineData("MixedCase")] + [InlineData("_private")] + public void Nonordinary_loop_binding_names_fail_atomically(string binding) + { + var result = Parse($"for {binding} in value; do printf '%s' \"${binding}\"; done"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("ordinary-scalar boundary", result.UnparseableReason!); + } + + [Theory] + [InlineData("f")] + [InlineData("file")] + [InlineData("file_2")] + [InlineData("for")] + public void Ordinary_scalar_binding_names_remain_eligible(string binding) + { + var result = Parse($"for {binding} in value; do printf '%s' \"${binding}\"; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + } + [Fact] public void Literal_iterable_emits_one_body_occurrence_with_finite_effective_value() { @@ -399,6 +448,29 @@ public void Static_bash_c_preserves_decoded_loop_facts_without_outer_source_span "b"); } + [Fact] + public void Decoded_loop_fails_after_outer_variable_state_mutation() + { + var result = Parse( + "export f=ambient; bash -c 'for f in a; do printf %s \"$f\"; done'"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("isolated non-interactive initial state", result.UnparseableReason!); + } + + [Fact] + public void Decoded_nonloop_command_remains_visible_after_outer_export() + { + var result = Parse("export f=ambient; bash -c 'printf ok'"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal( + new[] { "export", "printf" }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + } + private static ClauseElement EffectiveValueElement( ParsedCommand result, EffectiveArgument effective) => @@ -409,6 +481,7 @@ private static ParsedCommand Parse(string input) => { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }).Parse(input); private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs index d715962..8c070a5 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs @@ -1081,6 +1081,7 @@ private static ParsedCommand Parse(string input) { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }); return parser.Parse(input); } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 8f7bd15..0bec198 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -705,6 +705,76 @@ public void Bash_nested_loop_binding_reuse_does_not_restore_the_outer_value() Assert.Equal(new[] { "after=", "after=" }, Lines(output)); } + [Fact] + public void Bash_home_loop_binding_changes_expansion_semantics() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "HOME=/home/original; for HOME in /tmp; do printf '<%s>\\n' \"$HOME/x\"; done"); + + Assert.Equal("", output); + } + + [Fact] + public void Bash_magic_loop_bindings_do_not_read_back_the_authored_value() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var random = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "for RANDOM in not-a-number; do printf '%s' \"$RANDOM\"; done"); + var lineNumber = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "for LINENO in 7; do printf '%s' \"$LINENO\"; done"); + + Assert.NotEqual("not-a-number", random); + Assert.All(random, character => Assert.InRange(character, '0', '9')); + Assert.NotEqual("7", lineNumber); + Assert.True(int.TryParse(lineNumber, out var parsedLineNumber)); + Assert.True(parsedLineNumber > 0); + } + + [Fact] + public void Bash_identity_and_resolution_bindings_change_body_semantics() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "saved_path=$PATH; for PATH in /definitely/missing; do " + + "if command -v git >/dev/null; then printf found; else printf missing; fi; done; " + + "PATH=$saved_path; " + + "for IFS in :; do value=a:b; printf '<%s>\\n' $value; done; " + + "root=$(mktemp -d); mkdir -p \"$root/search/child\"; cd \"$root\"; " + + "for CDPATH in \"$root/search\"; do cd child >/dev/null && " + + "test \"$PWD\" = \"$root/search/child\" && printf cdpath; done"); + + Assert.Equal(new[] { "missing", "", "cdpath" }, Lines(output)); + } + [Fact] public void Bash_globskipdots_can_expand_dot_prefixed_globs_to_parent_traversal() { diff --git a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs index 0cdcbec..d502dc5 100644 --- a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs @@ -190,12 +190,20 @@ public void BashParserOptions_has_expected_shape() // object-initializer shape stays source-compatible with v0.1. AssertInitProperty(t, "HomeDirectory", typeof(string), nullable: true); AssertInitProperty(t, "WorkingDirectory", typeof(string), nullable: true); + AssertInitProperty(t, "InitialStateMode", typeof(BashInitialStateMode)); var declaredProps = DeclaredInstanceProps(t) .Where(p => p.Name != "EqualityContract") .Select(p => p.Name) .ToArray(); - Assert.Empty(declaredProps); + Assert.Equal(new[] { "InitialStateMode" }, declaredProps); + } + + [Fact] + public void BashInitialStateMode_has_expected_values() + { + Assert.Equal(0, (int)BashInitialStateMode.Unknown); + Assert.Equal(1, (int)BashInitialStateMode.IsolatedNonInteractive); } // -------- PwshParserOptions -------- @@ -492,6 +500,7 @@ public void Public_namespace_contains_only_expected_types() nameof(Arg), nameof(ArgKind), nameof(BashParser), + nameof(BashInitialStateMode), nameof(BashParserOptions), nameof(Clause), nameof(ClauseElement), diff --git a/tools/PwshCorpusTool/Program.cs b/tools/PwshCorpusTool/Program.cs index b663541..2f6f72a 100644 --- a/tools/PwshCorpusTool/Program.cs +++ b/tools/PwshCorpusTool/Program.cs @@ -30,6 +30,7 @@ { HomeDirectory = "/home/test", WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, }); if (args.Length == 0)