From 400fba059a5b2fcbb833e5eb7e2e29eb1ba61d5d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 11:30:18 +0000 Subject: [PATCH] Implement Bash loop effective cwd transfers --- IMPLEMENTATION_PLAN.md | 44 +- .../v0-3-structured-shell-analysis/design.md | 13 + .../specs/bounded-shell-analysis/spec.md | 30 +- .../v0-3-structured-shell-analysis/tasks.md | 11 +- .../Bash/Parsing/BashAbstractStateAnalyzer.cs | 402 +++++++++++++++++- .../Bash/Parsing/BashCwdInvocationGrammar.cs | 141 ++++++ .../Internal/Bash/Parsing/BashLoopAnalysis.cs | 120 ++++++ .../Bash/Parsing/BashStructuralCoordinator.cs | 27 +- .../222_v03_nested_dispatch_cwd_mutator.json | 8 +- ...or_effective_cd_physical_sanitization.json | 28 ++ ...3_for_effective_cd_terminator_control.json | 28 ++ .../245_v03_for_wrapped_effective_cd.json | 32 ++ ...03_for_post_binding_mutation_rejected.json | 10 + ...247_v03_for_static_invalid_cd_failure.json | 24 ++ .../248_v03_for_option_after_operand.json | 25 ++ ...49_v03_for_unquoted_cd_arity_rejected.json | 10 + ...250_v03_for_pattern_cd_arity_rejected.json | 10 + .../DesignCorpus/v0.3/bash.json | 4 +- .../Parsing/BashForInStructuralTests.cs | 324 +++++++++++++- .../Parsing/BashStructuralProjectionTests.cs | 20 +- .../Parsing/ShellValueOracleTests.cs | 48 +++ 21 files changed, 1293 insertions(+), 66 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/243_v03_for_effective_cd_physical_sanitization.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/244_v03_for_effective_cd_terminator_control.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/245_v03_for_wrapped_effective_cd.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/246_v03_for_post_binding_mutation_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/247_v03_for_static_invalid_cd_failure.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/248_v03_for_option_after_operand.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/249_v03_for_unquoted_cd_arity_rejected.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/250_v03_for_pattern_cd_arity_rejected.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 47f3328..5df59a7 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -283,35 +283,21 @@ priorities. leaves preserve authored dynamic operands. Loop binding and cwd mutation fail closed, loops reached after recognized prior shell-state mutation fail closed, and occurrence cwd remains Unknown. -- [ ] Design and implement structure-aware Bash abstract-state analysis before - enabling cwd-changing loop bodies or claiming the complete `for ... in` - vertical slice. The parse-order attribution model cannot soundly publish - occurrence cwd across pipelines, conditional lists, substitutions, and - repeated iterations. The design now requires internal success/failure - flow partitions, failure-aware `cd`, conservative `lastpipe` / `pipefail`, - ordered duplicate-preserving loop plans, inherited but isolated - decoded-wrapper state, and dynamic fail-closed compatibility attribution - whenever cwd joins to Unknown. The analyzer now owns persistent loop - bindings, ordered and empty iteration, occurrence-fact joins, unreachable - flow partitions, substitution inheritance, and explicit decoded-wrapper - remapping of loop plans and argument provenance. Unknown-cardinality - loops use bounded fixed-point widening, and a 4096-transition global - budget fails nested cross-products atomically. Static bodies retain an - exact incoming cwd when no transfer can change it. Keep OpenSpec task 6.5 - open for full effective-argv transfer and removal of the temporary loop - mutation rejection, then add the Netclaw approval matrix. 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 across these two analyzer slices. +- [x] Design and implement structure-aware Bash abstract-state analysis for the + complete bounded `for ... in` state slice. The analyzer owns + success/failure partitions, failure-aware cwd transfer, conservative + pipeline state, ordered duplicate-preserving loop plans, persistent loop + bindings, empty iteration, occurrence-fact joins, substitution isolation, + and explicit decoded-wrapper remapping. Unknown-cardinality loops use + bounded fixed-point widening, a 4096-transition global budget fails nested + cross-products atomically, and complete effective argv is re-evaluated for + every visit. Loop-derived `cd` options, terminators, invalid/multiple + operands, recursive exact `command` / `builtin` dispatch, physical-path + compatibility sanitation, and post-loop binding mutation are pinned by + unit tests, native Bash oracles, the design corpus, and executable corpus. + All unmodeled mutations, dynamic dispatch, and control transfers remain + fail closed. Next add the Netclaw approval matrix before calling the Bash + consumer integration complete. - [ ] 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/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 305c2d5..a52e18c 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -526,6 +526,19 @@ 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. +Static dispatch is interpreted from the same ordered element stream. Exact +`command -p`, `command --`, and `builtin --` wrappers may recurse to `cd` or +`chdir`; `command -v` / `-V` is a nonmutating query. Invalid or dynamic wrapper +grammar does not dispatch. If a rebound physical option changes which authored +element is the operand, every compatibility resolution made unsafe by that +visit is cleared by element coordinate in both `Clause.Args` and +`Clause.Elements`. The first operand ends option recognition; every later word +is a second operand and therefore an exact failure. Unknown quoted one-word +values may conservatively produce unknown cwd on success, but unquoted field +splitting or globbing of a tracked loop binding leaves argv cardinality +unproved and fails the region atomically. Ambient dynamic values retain the +older compatibility contract's conservative unknown-state behavior. + 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 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 0bfe22a..ba8cb35 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 @@ -319,7 +319,18 @@ 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`, +an operand. Exact `command` options `-p` and `--` and the exact `builtin --` +delimiter SHALL be recursively unwrapped; `command -v` / `-V` SHALL remain a +nonmutating query, while invalid or dynamic wrapper grammar SHALL fail closed. +When effective option grammar makes an authored path resolution unsafe, the +compatibility `Arg` and corresponding `ClauseElement` resolutions SHALL both be +cleared without rewriting their authored spelling or flag classification. +Option recognition SHALL stop at the first operand. An exact invalid option or +second operand SHALL have no success partition, and a tracked loop-binding +expansion whose argv cardinality is not proved SHALL make the containing region +unparseable. Ambient dynamic values retain the compatibility contract's +conservative unknown-state behavior. 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 @@ -388,6 +399,23 @@ partition merely to publish exact continuation facts. - **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: Loop-derived physical option sanitizes compatibility paths +- **WHEN** isolated-mode Bash analyzes `for f in -P; do cd "$f" ./sub && cat file.txt; done` +- **THEN** the effective argv is `cd -P ./sub` +- **THEN** both compatibility projections clear the authored `/work/sub` resolution +- **THEN** the reached `cat` has unknown cwd and no exact relative-path resolution +- **WHEN** the loop candidate is `--` instead +- **THEN** `./sub` remains the exact logical operand and the reached path resolves under `/work/sub` + +#### Scenario: Exact dispatch wrappers preserve cwd transfer grammar +- **WHEN** a loop body invokes `command -p -- builtin -- cd "$f"` +- **THEN** the analyzer recursively proves the wrapper grammar +- **THEN** it interprets only the words after `cd` as the effective transfer argv +- **WHEN** `command -v` or `command -V` is used +- **THEN** the wrapper is a query and does not mutate cwd +- **WHEN** a wrapper option is invalid or dynamic +- **THEN** the loop remains fail closed + #### 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 diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 0280f68..daa7dc9 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -95,15 +95,16 @@ wrapper provenance in unit tests and the Bash corpus. Use bounded fixed-point widening for unknown cardinality and fail atomically after 4096 total loop-body transitions. - - [ ] 6.5c.3 Re-parse each visit's complete effective argv for state + - [x] 6.5c.3 Re-parse each visit's complete effective argv for state transfers, including loop-derived `cd` options and wrapped dispatch; carry those transfers through the bounded fixed point, then remove only the temporary mutation rejections whose transfers are fully modeled. - The analyzer now publishes exact incoming cwd for reached loop occurrences - when no modeled transfer can disagree. It still rejects loop shell-state - mutation, nested active-binding reuse, and loops reached after recognized - prior shell-state mutation until 6.5c.3 reclassifies complete effective argv - and models the corresponding repeated transfers. + when no modeled transfer can disagree. Complete effective `cd` argv, + recursive exact `command` / `builtin` dispatch, failure-only invalid forms, + physical-path sanitation, and persistent post-loop binding protection are + implemented. It still rejects every unmodeled shell-state mutation, + control transfer, nested active-binding reuse, and dynamic dispatch. - [ ] 6.6 Cover empty iterables, separators, multiline bodies, redirects, pipelines, nested loops, and wrapper boundaries. - [ ] 6.7 Add adversarial cases for option injection, mutation, unquoted expansion, indirect expansion, substitutions, and cap overflow. - [ ] 6.8 Add sanitized Bash corpus entries and Netclaw allow/prompt/deny integration cases. diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs index 93f3ddf..60ac0f0 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs @@ -26,6 +26,8 @@ internal sealed class BashAbstractStateAnalyzer new(ClauseReferenceComparer.Instance); private readonly Dictionary> _effectiveArguments = new(ClauseReferenceComparer.Instance); + private readonly Dictionary> _cwdResolutionSanitization = + new(ClauseReferenceComparer.Instance); private readonly List _rewrittenForInPlans = new(); private bool _isComplete = true; private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; @@ -133,9 +135,22 @@ private BashFlowResult AnalyzeSimple( clearBindings: false); } + if (input.Bindings.HasBindings && + IsPotentialPersistentBindingMutation(simple.Clause)) + { + _isComplete = false; + return new BashFlowResult(null, null); + } + RecordInput(simple.Clause, input); RecordEffectiveArguments(simple, input); - if (!TryGetCwdTransfer(simple.Clause, input, out var success)) + var cwdTransfer = AnalyzeEffectiveCwdTransfer(simple, input); + if (cwdTransfer is BashFlowResult effectiveFlow) + { + return effectiveFlow; + } + + if (!TryGetLegacyCwdTransfer(simple.Clause, input, out var success)) { return BashFlowResult.Both(input); } @@ -567,7 +582,357 @@ private Dictionary GetEffectiveArguments(Clause clause) return accumulated; } - private bool TryGetCwdTransfer( + private void RecordCwdResolutionSanitization( + Clause clause, + IReadOnlyList elementIndices) + { + if (!_cwdResolutionSanitization.TryGetValue(clause, out var accumulated)) + { + accumulated = new HashSet(); + _cwdResolutionSanitization.Add(clause, accumulated); + } + + foreach (var elementIndex in elementIndices) + { + accumulated.Add(elementIndex); + } + } + + private BashFlowResult? AnalyzeEffectiveCwdTransfer( + SimpleCommandSyntax simple, + BashAbstractState input) + { + var dispatchKind = BashCwdInvocationGrammar.Classify( + simple.Clause, + out var argumentElementIndices); + if (dispatchKind == BashDispatchKind.Query) + { + return BashFlowResult.Both(input); + } + + if (dispatchKind != BashDispatchKind.CwdTransfer) + { + return null; + } + + if (!TryGetCwdArgumentValues( + simple, + argumentElementIndices, + out var argumentValues)) + { + _isComplete = false; + return new BashFlowResult(null, null); + } + + foreach (var argumentValue in argumentValues) + { + if (input.Bindings.ReferencesTrackedBinding(argumentValue) && + !HasProvedSingleWordCardinality(argumentValue)) + { + _isComplete = false; + return new BashFlowResult(null, null); + } + } + + var referencedBindings = input.Bindings.FindReferencedBindingNames(argumentValues); + var alternativeResult = input.Bindings.EnumerateExactAlternatives( + ShellAnalysisLimits.MaxValueCandidates, + referencedBindings, + out var bindingAlternatives); + if (alternativeResult == BashBindingAlternativeResult.ExceededLimit) + { + _isComplete = false; + return new BashFlowResult(null, null); + } + + if (alternativeResult == BashBindingAlternativeResult.Unknown) + { + RecordCwdResolutionSanitization(simple.Clause, argumentElementIndices); + return new BashFlowResult(input.WithUnknownCwd(), input); + } + + BashAbstractState? success = null; + BashAbstractState? failure = null; + foreach (var bindings in bindingAlternatives) + { + BuildEffectiveCwdArguments( + bindings, + argumentElementIndices, + argumentValues, + input, + out var arguments); + + BashFlowResult visit; + if (arguments is null) + { + RecordCwdResolutionSanitization( + simple.Clause, + argumentElementIndices); + visit = new BashFlowResult(input.WithUnknownCwd(), input); + } + else + { + visit = AnalyzeExactCwdArguments(simple.Clause, input, arguments); + } + + success = BashAbstractState.JoinNullable(success, visit.OnSuccess); + failure = BashAbstractState.JoinNullable(failure, visit.OnFailure); + } + + return new BashFlowResult(success, failure); + } + + private bool TryGetCwdArgumentValues( + SimpleCommandSyntax simple, + IReadOnlyList argumentElementIndices, + out IReadOnlyList values) + { + var ordered = new List(argumentElementIndices.Count); + var sourceFacts = _factsFactory(simple); + foreach (var elementIndex in argumentElementIndices) + { + ShellValueElementProvenance? provenance = null; + foreach (var candidate in sourceFacts.ValueProvenance) + { + if (candidate.ClauseElementIndex == elementIndex) + { + provenance = candidate; + break; + } + } + + if (provenance is null) + { + values = Array.Empty(); + return false; + } + + ordered.Add(provenance.Value.Value); + } + + values = ordered; + return true; + } + + private void BuildEffectiveCwdArguments( + BashLoopBindingContext bindings, + IReadOnlyList argumentElementIndices, + IReadOnlyList argumentValues, + BashAbstractState input, + out IReadOnlyList? arguments) + { + var exact = new List(argumentElementIndices.Count); + for (var index = 0; index < argumentElementIndices.Count; index++) + { + var dependsOnBinding = bindings.TryAnalyzeEffectiveValue( + argumentValues[index], + out var domain); + if (!dependsOnBinding) + { + domain = bindings.AnalyzeWordForTransfer(argumentValues[index]); + } + + if (domain.Kind != ShellValueDomainKind.Exact && + TryResolveKnownHomeWord(argumentValues[index], input, out var homeWord)) + { + domain = new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { homeWord }, + }; + } + + if (domain.Kind != ShellValueDomainKind.Exact || domain.Values.Count != 1) + { + arguments = null; + return; + } + + exact.Add(new EffectiveCwdArgument( + argumentElementIndices[index], + domain.Values[0])); + } + + arguments = exact; + } + + private bool TryResolveKnownHomeWord( + ShellValue value, + BashAbstractState input, + out string resolvedValue) + { + var resolved = BashResolver.Resolve( + value, + treatAsPath: true, + OptionsFor(input), + workingDirectoryUnknown: input.WorkingDirectory is null, + consumer: ShellResolutionConsumer.BashArgument); + if (resolved.Kind == ArgKind.Tilde && resolved.Resolved is not null) + { + resolvedValue = resolved.Resolved; + return true; + } + + resolvedValue = ""; + return false; + } + + private static bool HasProvedSingleWordCardinality(ShellValue value) + { + foreach (var fragment in value.Fragments) + { + if (fragment.Cardinality != ShellValueCardinality.ExactlyOne || + (fragment.AllowedTransforms & + (ShellLexicalTransform.FieldSplit | ShellLexicalTransform.Glob)) != 0) + { + return false; + } + } + + return true; + } + + private BashFlowResult AnalyzeExactCwdArguments( + Clause clause, + BashAbstractState input, + IReadOnlyList arguments) + { + var optionsEnded = false; + var physical = false; + EffectiveCwdArgument? operand = null; + foreach (var argument in arguments) + { + if (operand is not null) + { + return new BashFlowResult(null, input); + } + + if (!optionsEnded && argument.Value == "--") + { + optionsEnded = true; + continue; + } + + if (!optionsEnded && + argument.Value.Length > 1 && + argument.Value[0] == '-' && + argument.Value != "-") + { + if (!IsSupportedCdOption( + argument.Value, + out var requiresPhysicalResolution)) + { + return new BashFlowResult(null, input); + } + + physical |= requiresPhysicalResolution; + continue; + } + + operand = argument; + if (physical) + { + RecordCwdResolutionSanitization( + clause, + new[] { argument.ElementIndex }); + } + } + + if (operand is null) + { + var home = physical || string.IsNullOrEmpty(_options.HomeDirectory) + ? input.WithUnknownCwd() + : input.WithCwd(_options.HomeDirectory, true); + return new BashFlowResult(home, input); + } + + if (physical || + operand.Value.Value == "-" || + IsCdPathSearchCandidate(operand.Value.Value)) + { + return new BashFlowResult(input.WithUnknownCwd(), input); + } + + var resolved = BashResolver.Resolve( + operand.Value.Value, + treatAsPath: true, + OptionsFor(input), + workingDirectoryUnknown: input.WorkingDirectory is null, + isLiteralBytes: true); + var success = resolved.Resolved is null + ? input.WithUnknownCwd() + : input.WithCwd(resolved.Resolved, true); + return new BashFlowResult(success, input); + } + + private static bool IsSupportedCdOption( + string argument, + out bool requiresPhysicalResolution) + { + requiresPhysicalResolution = false; + for (var index = 1; index < argument.Length; index++) + { + switch (argument[index]) + { + case 'L': + case 'e': + break; + case 'P': + case '@': + requiresPhysicalResolution = true; + break; + default: + return false; + } + } + + return true; + } + + private static bool IsPotentialPersistentBindingMutation(Clause clause) + { + var dispatchKind = BashCwdInvocationGrammar.Classify(clause, out _); + if (dispatchKind is BashDispatchKind.CwdTransfer or BashDispatchKind.Query) + { + return false; + } + + if (clause.Verb.Tokens.Count == 0) + { + return true; + } + + var verb = clause.Verb.Tokens[0]; + if (verb is "pushd" or "popd") + { + return false; + } + + if (verb is "unset" or "read" or "readarray" or "mapfile" or + "declare" or "typeset" or "local" or "export" or "readonly" or + "let" or "eval" or "." or "source" or "getopts" or "set" or + "trap" or "command" or "builtin") + { + return true; + } + + if (!string.Equals(verb, "printf", StringComparison.Ordinal)) + { + return false; + } + + foreach (var argument in clause.Args) + { + if (string.Equals(argument.Raw, "-v", StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private bool TryGetLegacyCwdTransfer( Clause clause, BashAbstractState input, out BashAbstractState success) @@ -905,7 +1270,8 @@ private Clause RewriteClause( var dependency = FindArgumentDependency( dependencies, authoredArgumentIndex); - var clearResolution = authoredArgumentIndex == clearCdTargetIndex; + var clearResolution = authoredArgumentIndex == clearCdTargetIndex || + IsArgumentResolutionSanitized(clause, authoredArgumentIndex); var rebased = clearResolution ? null : RebaseResolution( @@ -936,7 +1302,8 @@ private Clause RewriteClause( var element = clause.Elements[index]; var dependency = FindDependency(dependencies, index); var clearResolution = element.Role == ClauseElementRole.Argument && - argumentElementIndex++ == clearCdTargetIndex; + (argumentElementIndex++ == clearCdTargetIndex || + IsElementResolutionSanitized(clause, index)); var rebased = clearResolution ? null : RebaseResolution( @@ -977,6 +1344,31 @@ private Clause RewriteClause( }; } + private bool IsArgumentResolutionSanitized(Clause clause, int argumentIndex) + { + var currentArgument = 0; + for (var elementIndex = 0; + elementIndex < clause.Elements.Count; + elementIndex++) + { + if (clause.Elements[elementIndex].Role != ClauseElementRole.Argument) + { + continue; + } + + if (currentArgument++ == argumentIndex) + { + return IsElementResolutionSanitized(clause, elementIndex); + } + } + + return false; + } + + private bool IsElementResolutionSanitized(Clause clause, int elementIndex) => + _cwdResolutionSanitization.TryGetValue(clause, out var sanitized) && + sanitized.Contains(elementIndex); + private IReadOnlyList RewriteCompatibilityRedirects( Clause clause, string? parseWorkingDirectory, @@ -1452,6 +1844,8 @@ private IReadOnlyList RewriteBranches( return rewritten; } + private readonly record struct EffectiveCwdArgument(int ElementIndex, string Value); + private readonly struct BashFlowResult { internal BashFlowResult(BashAbstractState? onSuccess, BashAbstractState? onFailure) diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs new file mode 100644 index 0000000..14d8f6a --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCwdInvocationGrammar.cs @@ -0,0 +1,141 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Bash.Parsing; + +internal static class BashCwdInvocationGrammar +{ + internal static BashDispatchKind Classify( + Clause clause, + out IReadOnlyList cwdArgumentElementIndices) + { + cwdArgumentElementIndices = Array.Empty(); + var words = new List(); + for (var index = 0; index < clause.Elements.Count; index++) + { + if (clause.Elements[index].Role != ClauseElementRole.Redirect) + { + words.Add(index); + } + } + + var wordIndex = 0; + while (wordIndex < words.Count) + { + var element = clause.Elements[words[wordIndex]]; + if (!IsStaticWord(element)) + { + return BashDispatchKind.None; + } + + if (element.Value == "command") + { + wordIndex++; + var optionsEnded = false; + while (wordIndex < words.Count && !optionsEnded) + { + var option = clause.Elements[words[wordIndex]]; + if (!IsStaticWord(option)) + { + return BashDispatchKind.None; + } + + if (option.Value == "--") + { + optionsEnded = true; + wordIndex++; + continue; + } + + if (option.Value.Length <= 1 || option.Value[0] != '-') + { + break; + } + + var query = false; + for (var optionIndex = 1; + optionIndex < option.Value.Length; + optionIndex++) + { + switch (option.Value[optionIndex]) + { + case 'p': + break; + case 'v': + case 'V': + query = true; + break; + default: + return BashDispatchKind.None; + } + } + + wordIndex++; + if (query) + { + return BashDispatchKind.Query; + } + } + + continue; + } + + if (element.Value == "builtin") + { + wordIndex++; + if (wordIndex < words.Count) + { + var option = clause.Elements[words[wordIndex]]; + if (!IsStaticWord(option)) + { + return BashDispatchKind.None; + } + + if (option.Value == "--") + { + wordIndex++; + } + else if (option.Value.Length > 1 && option.Value[0] == '-') + { + return BashDispatchKind.None; + } + } + + continue; + } + + if (element.Value is not ("cd" or "chdir")) + { + return BashDispatchKind.None; + } + + var arguments = new int[words.Count - wordIndex - 1]; + for (var argumentIndex = 0; + argumentIndex < arguments.Length; + argumentIndex++) + { + arguments[argumentIndex] = words[wordIndex + argumentIndex + 1]; + } + + cwdArgumentElementIndices = arguments; + return BashDispatchKind.CwdTransfer; + } + + return BashDispatchKind.None; + } + + private static bool IsStaticWord(ClauseElement element) => + element.Kind == ArgKind.Literal; +} + +internal enum BashDispatchKind +{ + None, + Query, + CwdTransfer, +} diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashLoopAnalysis.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashLoopAnalysis.cs index a4b7a85..e2f6ce5 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashLoopAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashLoopAnalysis.cs @@ -18,6 +18,13 @@ internal enum BashIterationCardinality ZeroOrMore, } +internal enum BashBindingAlternativeResult +{ + Exact, + Unknown, + ExceededLimit, +} + internal sealed record BashLoopWord( ShellValue? Value, bool HasUnmodeledBraceExpansion); @@ -72,6 +79,103 @@ private BashLoopBindingContext Clone() internal BashLoopBindingContext WithoutBindings() => new(); + internal bool HasBindings => _bindings.Count > 0; + + internal BashBindingAlternativeResult EnumerateExactAlternatives( + int maximumCount, + IReadOnlyList bindingNames, + out IReadOnlyList alternatives) + { + var current = new List { new() }; + foreach (var binding in _bindings) + { + if (!ContainsName(bindingNames, binding.Name)) + { + continue; + } + + if (binding.Domain.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet) || + binding.Domain.Values.Count == 0) + { + alternatives = Array.Empty(); + return BashBindingAlternativeResult.Unknown; + } + + if (current.Count > maximumCount / binding.Domain.Values.Count) + { + alternatives = Array.Empty(); + return BashBindingAlternativeResult.ExceededLimit; + } + + var next = new List( + current.Count * binding.Domain.Values.Count); + foreach (var state in current) + { + foreach (var value in binding.Domain.Values) + { + next.Add(state.WithBinding( + binding.Name, + new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { value }, + })); + } + } + + current = next; + } + + alternatives = current; + return BashBindingAlternativeResult.Exact; + } + + internal IReadOnlyList FindReferencedBindingNames( + IReadOnlyList values) + { + var names = new List(); + foreach (var binding in _bindings) + { + foreach (var value in values) + { + if (ReferencesBinding(value, binding.Name)) + { + names.Add(binding.Name); + break; + } + } + } + + return names; + } + + internal bool ReferencesTrackedBinding(ShellValue value) + { + foreach (var binding in _bindings) + { + if (ReferencesBinding(value, binding.Name)) + { + return true; + } + } + + return false; + } + + private static bool ContainsName(IReadOnlyList names, string expected) + { + foreach (var name in names) + { + if (string.Equals(name, expected, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + internal bool StateEquals(BashLoopBindingContext other) { if (_bindings.Count != other._bindings.Count) @@ -422,6 +526,22 @@ internal bool TryAnalyzeEffectiveValue( return true; } + internal ShellValueDomain AnalyzeWordForTransfer(ShellValue value) + { + if (IsEntirelyLiteral(value)) + { + return new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { value.Decoded }, + }; + } + + return TryAnalyzeEffectiveValue(value, out var domain) + ? domain + : ShellValueDomain.Unknown; + } + private bool TryComposeCandidates( ShellValue value, IReadOnlyList bindings, diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 1ec68dc..4cd3d7b 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -542,16 +542,23 @@ private bool TryParseCommand( IsCommandStringWrapped = _markBashCWrapped, }; var emitted = AttachAttributionArg(clause, _attribution); + var dispatchKind = BashCwdInvocationGrammar.Classify( + emitted, + out _); var isPotentialStateMutation = IsPotentialBindingMutation(emitted); - if (_activeLoopBindings.Count > 0 && isPotentialStateMutation) + var isModeledCwdTransfer = dispatchKind == BashDispatchKind.CwdTransfer; + if (_activeLoopBindings.Count > 0 && + isPotentialStateMutation && + !isModeledCwdTransfer) { error = "Bash loop state mutation or control transfer is not supported for bounded analysis"; return false; } - _hasUnmodeledShellStateMutation |= isPotentialStateMutation; + _hasUnmodeledShellStateMutation |= + isPotentialStateMutation && !isModeledCwdTransfer; _hasUnmodeledVariableStateMutation |= - IsPotentialVariableStateMutation(emitted); + IsPotentialVariableStateMutation(emitted) && !isModeledCwdTransfer; if (!TryParseCommandSubstitutions( substitutionFragments, @@ -601,7 +608,7 @@ private bool TryParseForIn( { command = null; error = null; - if (_attribution.HasAttribution || _hasUnmodeledShellStateMutation) + if (_hasUnmodeledShellStateMutation) { error = "Bash for-in after prior shell-state mutation requires structure-aware state analysis"; return false; @@ -1418,6 +1425,12 @@ private static bool TryFindDependencies( private static bool IsPotentialBindingMutation(Clause clause) { + var dispatchKind = BashCwdInvocationGrammar.Classify(clause, out _); + if (dispatchKind == BashDispatchKind.Query) + { + return false; + } + if (clause.Verb.Tokens.Count == 0) { return true; @@ -1459,8 +1472,10 @@ private static bool IsPotentialBindingMutation(Clause clause) private static bool IsPotentialVariableStateMutation(Clause clause) { - if (clause.Verb.Tokens.Count > 0 && - clause.Verb.Tokens[0] is "cd" or "chdir" or "pushd" or "popd") + var dispatchKind = BashCwdInvocationGrammar.Classify(clause, out _); + if (dispatchKind is BashDispatchKind.CwdTransfer or BashDispatchKind.Query || + clause.Verb.Tokens.Count > 0 && + clause.Verb.Tokens[0] is "pushd" or "popd") { return false; } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/222_v03_nested_dispatch_cwd_mutator.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/222_v03_nested_dispatch_cwd_mutator.json index bbf9a19..6bb5139 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/222_v03_nested_dispatch_cwd_mutator.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/222_v03_nested_dispatch_cwd_mutator.json @@ -1,5 +1,5 @@ { - "name": "v0.3 nested static dispatch mutator fails closed", + "name": "v0.3 nested static dispatch cwd transfer", "input": "command builtin cd /outer && cat relative.txt", "expected": { "isUnparseable": false, @@ -16,12 +16,12 @@ "operator": "AndIf", "verb": ["cat"], "args": [ - { "raw": "relative.txt", "kind": "Literal", "isPath": true }, - { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + { "raw": "relative.txt", "kind": "Literal", "isPath": true, "resolved": "/outer/relative.txt" }, + { "raw": "/outer", "kind": "Literal", "isPath": true, "resolved": "/outer", "isCwdAttribution": true } ], "redirects": [] } ] }, - "notes": "Static command and builtin dispatch layers may reach a current-shell cd; the analyzer scans through every wrapper and does not retain the old cwd." + "notes": "Static command and builtin dispatch layers are recursively proved, so the analyzer applies the complete cd argv and retains the exact successful cwd." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/243_v03_for_effective_cd_physical_sanitization.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/243_v03_for_effective_cd_physical_sanitization.json new file mode 100644 index 0000000..e63c956 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/243_v03_for_effective_cd_physical_sanitization.json @@ -0,0 +1,28 @@ +{ + "name": "v0.3 Bash effective cd physical-option sanitation", + "input": "for f in -P; do cd \"$f\" ./sub && cat file.txt; done", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "\"$f\"", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false }, + { "raw": "./sub", "kind": "Literal", "isPath": true, "resolved": "__NULL__", "isFlag": false } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cat"], + "args": [ + { "raw": "file.txt", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false }, + { "raw": "", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "The rebound -P option makes physical resolution unprovable, clears the authored ./sub resolution, and propagates unknown cwd to the success continuation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/244_v03_for_effective_cd_terminator_control.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/244_v03_for_effective_cd_terminator_control.json new file mode 100644 index 0000000..3b66d4a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/244_v03_for_effective_cd_terminator_control.json @@ -0,0 +1,28 @@ +{ + "name": "v0.3 Bash effective cd option-terminator control", + "input": "for f in --; do cd \"$f\" ./sub && cat file.txt; done", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "\"$f\"", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false }, + { "raw": "./sub", "kind": "Literal", "isPath": true, "resolved": "/work/sub", "isFlag": false } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cat"], + "args": [ + { "raw": "file.txt", "kind": "Literal", "isPath": true, "resolved": "/work/sub/file.txt", "isFlag": false }, + { "raw": "/work/sub", "kind": "Literal", "isPath": true, "resolved": "/work/sub", "isFlag": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "The rebound -- terminator leaves ./sub as the sole logical operand and preserves exact downstream cwd and path facts." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/245_v03_for_wrapped_effective_cd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/245_v03_for_wrapped_effective_cd.json new file mode 100644 index 0000000..72a6976 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/245_v03_for_wrapped_effective_cd.json @@ -0,0 +1,32 @@ +{ + "name": "v0.3 Bash recursively wrapped effective cd", + "input": "for f in /tmp; do command -p -- builtin -- cd \"$f\" && cat file.txt; done", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command"], + "args": [ + { "raw": "-p", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": true }, + { "raw": "--", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": true }, + { "raw": "builtin", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": false }, + { "raw": "--", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": true }, + { "raw": "cd", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": false }, + { "raw": "\"$f\"", "kind": "EnvVar", "isPath": false, "resolved": "__NULL__", "isFlag": false } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cat"], + "args": [ + { "raw": "file.txt", "kind": "Literal", "isPath": true, "resolved": "/tmp/file.txt", "isFlag": false }, + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp", "isFlag": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "Exact command and builtin wrapper options are recursively classified before the loop-derived cd argv is interpreted." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/246_v03_for_post_binding_mutation_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/246_v03_for_post_binding_mutation_rejected.json new file mode 100644 index 0000000..9b5729c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/246_v03_for_post_binding_mutation_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash post-loop binding mutation rejected", + "input": "for f in a; do :; done; unset f; echo \"$f\"", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "invalid parser-owned facts" + }, + "notes": "Loop bindings persist after done; an unsupported later unset cannot leave a stale exact effective value.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/247_v03_for_static_invalid_cd_failure.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/247_v03_for_static_invalid_cd_failure.json new file mode 100644 index 0000000..811b8e6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/247_v03_for_static_invalid_cd_failure.json @@ -0,0 +1,24 @@ +{ + "name": "v0.3 Bash static invalid cd is failure-only", + "input": "for f in x; do cd -Z || pwd; done", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "-Z", "kind": "Literal", "isPath": false, "resolved": "__NULL__", "isFlag": true } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["pwd"], + "args": [], + "redirects": [] + } + ] + }, + "notes": "Complete static argv interpretation makes an invalid cd option failure-only, so the OR continuation retains the incoming cwd." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/248_v03_for_option_after_operand.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/248_v03_for_option_after_operand.json new file mode 100644 index 0000000..101fc39 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/248_v03_for_option_after_operand.json @@ -0,0 +1,25 @@ +{ + "name": "v0.3 Bash option-shaped word after cd operand", + "input": "for f in --; do cd ./a \"$f\" || pwd; done", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "./a", "kind": "Literal", "isPath": true, "resolved": "/work/a", "isFlag": false }, + { "raw": "\"$f\"", "kind": "DynamicSkip", "isPath": false, "resolved": "__NULL__", "isFlag": false } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["pwd"], + "args": [], + "redirects": [] + } + ] + }, + "notes": "Once ./a is the operand, the rebound -- is a second operand rather than an option terminator; the exact invocation is failure-only." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/249_v03_for_unquoted_cd_arity_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/249_v03_for_unquoted_cd_arity_rejected.json new file mode 100644 index 0000000..fd1f78f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/249_v03_for_unquoted_cd_arity_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash unquoted cd argv arity rejected", + "input": "for f in 'a b'; do cd $f && pwd; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "invalid parser-owned facts" + }, + "notes": "Unquoted loop expansion may field-split into multiple argv words, so transfer analysis fails atomically rather than inventing one unknown operand.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/250_v03_for_pattern_cd_arity_rejected.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/250_v03_for_pattern_cd_arity_rejected.json new file mode 100644 index 0000000..ef33b98 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/250_v03_for_pattern_cd_arity_rejected.json @@ -0,0 +1,10 @@ +{ + "name": "v0.3 Bash pattern-domain cd argv arity rejected", + "input": "for f in *.txt; do cd $f; done", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "invalid parser-owned facts" + }, + "notes": "A pattern-domain loop binding expanded unquoted has unproved argv cardinality and fails before the analyzer takes its conservative unknown-domain shortcut.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index 6c253cc..20191e0 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -314,9 +314,9 @@ { "id": "bash-for-empty-iterable-preserves-cwd", "concern": "A proved empty loop has no state transition", - "compatibilityProjectionLanded": false, + "compatibilityProjectionLanded": true, "input": "for f in; do cd /tmp; done; pwd", - "current": { "isUnparseable": true, "reasonContains": "mutation" }, + "current": { "isUnparseable": false }, "desired": { "syntax": [ { "id": "root", "kind": "Block", "slot": "Root" }, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs index 6b3cfb2..a598070 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashForInStructuralTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Linq; +using ShellSyntaxTree.Internal.Bash.Parsing; using Xunit; namespace ShellSyntaxTree.Tests.Parsing; @@ -138,10 +139,6 @@ public void Relative_static_glob_uses_the_original_parser_working_directory() } [Theory] - [InlineData("cd /a || cd /b; for f in *.txt; do rm -- \"$f\" rel.txt; done")] - [InlineData("cd /a | cat; for f in *.txt; do rm -- \"$f\" rel.txt; done")] - [InlineData("command cd /a; for f in x; do rm -- \"$f\" rel.txt; done")] - [InlineData("builtin cd /a; for f in x; do rm -- \"$f\" rel.txt; done")] [InlineData("eval \"cd /a\"; for f in x; do rm -- \"$f\" rel.txt; done")] [InlineData("trap \"f=x\" DEBUG; for f in a b; do echo \"$f\"; done")] public void Loop_after_prior_shell_state_mutation_fails_atomically(string source) @@ -173,6 +170,276 @@ public void Unbounded_iterables_keep_the_body_value_unknown(string source) Assert.Equal(ShellValueDomainKind.Unknown, effective.Value.Kind); } + [Fact] + public void Loop_derived_cd_operand_flows_through_success_continuation() + { + var result = Parse("for f in /tmp; do cd \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/tmp"); + } + + [Fact] + public void Wrapped_loop_derived_cd_operand_uses_the_same_transfer_grammar() + { + var result = Parse( + "for f in /tmp; do command builtin cd \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/tmp"); + } + + [Fact] + public void Loop_derived_cd_option_terminator_without_operand_uses_home() + { + var result = Parse("for f in --; do cd \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain( + result.Commands[1].WorkingDirectory, + ShellValueDomainKind.Exact, + "/home/test"); + } + + [Theory] + [InlineData("-L")] + [InlineData("-e")] + public void Logical_loop_derived_cd_option_without_operand_uses_home( + string candidate) + { + var result = Parse($"for f in {candidate}; do cd \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain( + result.Commands[1].WorkingDirectory, + ShellValueDomainKind.Exact, + "/home/test"); + } + + [Fact] + public void Invalid_loop_derived_cd_option_has_no_success_continuation() + { + var result = Parse("for f in -Z; do cd \"$f\"; done || pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + } + + [Theory] + [InlineData("cd -Z")] + [InlineData("cd ./a ./b")] + [InlineData("command -- cd -Z")] + public void Static_invalid_cd_argv_is_failure_only_inside_loop(string command) + { + var result = Parse($"for f in x; do {command} || pwd; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + } + + [Fact] + public void Multiple_effective_cd_operands_have_no_success_continuation() + { + var result = Parse("for f in /tmp; do cd \"$f\" /other; done || pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + } + + [Fact] + public void Multiple_effective_cd_operands_preserve_exact_failure_paths() + { + var result = Parse( + "for f in /other; do cd \"$f\" ./sub || cat file.txt; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + Assert.Equal( + "/work/file.txt", + Assert.Single( + result.Clauses[1].Args, + argument => argument.Raw == "file.txt").Resolved); + } + + [Theory] + [InlineData("--")] + [InlineData("-P")] + public void Option_shaped_word_after_operand_is_a_second_operand(string candidate) + { + var result = Parse( + $"for f in {candidate}; do cd ./a \"$f\" || pwd; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + Assert.Equal( + "/work/a", + Assert.Single(result.Clauses[0].Args, argument => argument.Raw == "./a").Resolved); + Assert.Equal( + "/work/a", + Assert.Single( + result.Clauses[0].Elements, + element => element.Raw == "./a").Resolved); + } + + [Fact] + public void Unquoted_loop_binding_with_unknown_argv_arity_fails_atomically() + { + var result = Parse("for f in 'a b'; do cd $f && pwd; done"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Theory] + [InlineData("for f in *.txt; do cd $f; done")] + [InlineData("for f in 'a b'; do cd \"$UNKNOWN\" $f; done")] + public void Every_effective_argv_word_is_preflighted_for_tracked_unknown_arity( + string source) + { + var result = Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Theory] + [InlineData("-P")] + [InlineData("-@")] + [InlineData("-")] + public void Physical_or_oldpwd_loop_derived_cd_values_keep_success_cwd_unknown( + string candidate) + { + var result = Parse($"for f in {candidate}; do cd \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + } + + [Fact] + public void Modeled_cd_before_loop_no_longer_invalidates_loop_analysis() + { + var result = Parse("cd /a && for f in x; do pwd; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/a"); + } + + [Theory] + [InlineData("command -- cd")] + [InlineData("command -p cd")] + [InlineData("builtin -- cd")] + [InlineData("command -p -- builtin -- cd")] + public void Static_dispatch_options_preserve_loop_derived_cwd_transfer( + string dispatch) + { + var result = Parse( + $"for f in /tmp; do {dispatch} \"$f\"; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/tmp"); + } + + [Theory] + [InlineData("command -v cd")] + [InlineData("command -V cd")] + [InlineData("command -pv cd")] + public void Command_query_options_do_not_mutate_loop_state(string query) + { + var result = Parse($"for f in a; do {query}; done && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain(result.Commands[1].WorkingDirectory, ShellValueDomainKind.Exact, "/work"); + } + + [Theory] + [InlineData("command -Z cd")] + [InlineData("builtin -p cd")] + public void Invalid_dispatch_options_remain_rejected_in_loop(string dispatch) + { + var result = Parse($"for f in a; do {dispatch} \"$f\"; done"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Loop_derived_physical_option_clears_stale_operand_resolution() + { + var result = Parse( + "for f in -P; do cd \"$f\" ./sub && cat file.txt; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var cd = result.Clauses[0]; + Assert.Null(Assert.Single(cd.Args, argument => argument.Raw == "./sub").Resolved); + Assert.Null(Assert.Single( + cd.Elements, + element => element.Raw == "./sub").Resolved); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + Assert.Null(Assert.Single( + result.Clauses[1].Args, + argument => argument.Raw == "file.txt").Resolved); + Assert.Contains( + result.Clauses[1].Args, + argument => argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + } + + [Fact] + public void Wrapped_loop_derived_physical_option_clears_stale_operand_resolution() + { + var result = Parse( + "for f in -P; do command -p -- builtin -- cd \"$f\" ./sub && " + + "cat file.txt; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Null(Assert.Single( + result.Clauses[0].Elements, + element => element.Raw == "./sub").Resolved); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + } + + [Fact] + public void Loop_derived_option_terminator_retains_logical_operand_resolution() + { + var result = Parse( + "for f in --; do cd \"$f\" ./sub && cat file.txt; done"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal( + "/work/sub", + Assert.Single(result.Clauses[0].Args, argument => argument.Raw == "./sub").Resolved); + Assert.Equal( + "/work/sub", + Assert.Single( + result.Clauses[0].Elements, + element => element.Raw == "./sub").Resolved); + AssertDomain( + result.Commands[1].WorkingDirectory, + ShellValueDomainKind.Exact, + "/work/sub"); + Assert.Equal( + "/work/sub/file.txt", + Assert.Single( + result.Clauses[1].Args, + argument => argument.Raw == "file.txt").Resolved); + } + + [Theory] + [InlineData("unset f")] + [InlineData("command unset f")] + [InlineData("builtin unset f")] + public void Post_loop_binding_mutation_fails_atomically(string mutation) + { + var result = Parse( + $"for f in a; do :; done; {mutation}; echo \"$f\""); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + [Fact] public void Iterator_substitution_precedes_body_and_does_not_see_new_binding() { @@ -425,6 +692,48 @@ public void Nested_concrete_loop_product_exceeding_analysis_budget_fails_atomica Assert.Contains("exceeded limits", result.UnparseableReason!); } + [Fact] + public void Effective_argv_binding_alternative_cap_is_exact_and_never_truncated() + { + var exact = new BashLoopBindingContext() + .WithBinding("a", FiniteDomain(4)) + .WithBinding("b", FiniteDomain(8)); + var overflow = new BashLoopBindingContext() + .WithBinding("a", FiniteDomain(4)) + .WithBinding("b", FiniteDomain(9)); + + Assert.Equal( + BashBindingAlternativeResult.Exact, + exact.EnumerateExactAlternatives( + ShellAnalysisLimits.MaxValueCandidates, + new[] { "a", "b" }, + out var exactAlternatives)); + Assert.Equal(ShellAnalysisLimits.MaxValueCandidates, exactAlternatives.Count); + Assert.Equal( + BashBindingAlternativeResult.ExceededLimit, + overflow.EnumerateExactAlternatives( + ShellAnalysisLimits.MaxValueCandidates, + new[] { "a", "b" }, + out var overflowAlternatives)); + Assert.Empty(overflowAlternatives); + } + + [Theory] + [InlineData("cd")] + [InlineData("command -- cd")] + public void Unreferenced_unknown_binding_does_not_poison_static_cwd_transfer( + string dispatch) + { + var result = Parse( + $"for f in \"$UNKNOWN\"; do :; done; {dispatch} /tmp && pwd"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + AssertDomain( + result.Commands[2].WorkingDirectory, + ShellValueDomainKind.Exact, + "/tmp"); + } + [Fact] public void Pipelines_compose_with_loop_ancestry_without_synthetic_operators() { @@ -449,7 +758,6 @@ public void Pipelines_compose_with_loop_ancestry_without_synthetic_operators() [InlineData("for f in a; do printf -v f x; done")] [InlineData("for f in a; do eval 'f=x'; done")] [InlineData("for f in a; do source script.sh; done")] - [InlineData("for f in a; do cd /tmp; done")] [InlineData("for f in a b; do trap 'f=x' DEBUG; echo \"$f\"; done")] [InlineData("for f in a; do command unset f; echo \"$f\"; done")] [InlineData("for f in a; do builtin unset f; echo \"$f\"; done")] @@ -643,6 +951,12 @@ private static ParsedCommand Parse(string input) => private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; + private static ShellValueDomain FiniteDomain(int count) => new() + { + Kind = ShellValueDomainKind.FiniteSet, + Values = Enumerable.Range(0, count).Select(index => $"v{index}").ToArray(), + }; + private static void AssertDomain( ShellValueDomain actual, ShellValueDomainKind expectedKind, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs index 8c070a5..29bef46 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs @@ -560,27 +560,27 @@ public void Exact_failure_partition_promotes_paths_blocked_only_by_parse_order_c [InlineData("builtin builtin cd /outer && cat relative.txt")] [InlineData("command builtin cd /outer && cat relative.txt")] [InlineData("builtin command cd /outer && cat relative.txt")] - public void Nested_dispatch_wrapped_cwd_mutation_fails_closed(string source) + public void Nested_dispatch_wrapped_cwd_uses_the_effective_argv(string source) { var result = Parse(source); - Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); - Assert.Null(result.Clauses[1].Args[0].Resolved); - Assert.Contains(result.Clauses[1].Args, argument => - argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + Assert.Equal( + "/outer", + Assert.Single(result.Commands[1].WorkingDirectory.Values)); + Assert.Equal("/outer/relative.txt", result.Clauses[1].Args[0].Resolved); } [Theory] [InlineData("command cd /outer && cat relative.txt")] [InlineData("builtin cd /outer && cat relative.txt")] - public void Dispatch_wrapped_cwd_mutation_fails_closed(string source) + public void Dispatch_wrapped_cwd_uses_the_effective_argv(string source) { var result = Parse(source); - Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); - Assert.Null(result.Clauses[1].Args[0].Resolved); - Assert.Contains(result.Clauses[1].Args, argument => - argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + Assert.Equal( + "/outer", + Assert.Single(result.Commands[1].WorkingDirectory.Values)); + Assert.Equal("/outer/relative.txt", result.Clauses[1].Args[0].Resolved); } [Fact] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 0bec198..f82b8fd 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -826,6 +826,54 @@ public void Bash_runtime_rejects_escaped_contextual_loop_keywords(string source) Assert.NotEqual(0, result.ExitCode); } + [Theory] + [InlineData("command -- cd /tmp")] + [InlineData("command -p cd /tmp")] + [InlineData("builtin -- cd /tmp")] + [InlineData("command -p -- builtin -- cd /tmp")] + public void Bash_runtime_dispatch_wrappers_preserve_cd_in_process(string dispatch) + { + if (!IsNativeBashAvailable()) + { + return; + } + + var output = Run("bash", "--noprofile", "--norc", "-c", $"cd /; {dispatch}; pwd"); + + Assert.Equal("/tmp", output); + } + + [Fact] + public void Bash_runtime_cd_operand_count_and_option_status_match_transfer_grammar() + { + if (!IsNativeBashAvailable()) + { + return; + } + + var output = Run( + "bash", + "--noprofile", + "--norc", + "-c", + "HOME=/tmp; cd /; cd --; pwd; " + + "cd -Z >/dev/null 2>&1 || printf 'invalid\\n'; " + + "cd / /tmp >/dev/null 2>&1 || printf 'multiple\\n'; " + + "cd /; cd /tmp -- >/dev/null 2>&1 || printf 'terminator-after-operand\\n'; " + + "cd /; cd /tmp -P >/dev/null 2>&1 || printf 'option-after-operand\\n'"); + + Assert.Equal( + new[] + { + "/tmp", + "invalid", + "multiple", + "terminator-after-operand", + "option-after-operand", + }, + Lines(output)); + } + private static bool IsAvailable(string executable) { try