diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 0fcd967..9440bdc 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -293,6 +293,9 @@ priorities. decoded-wrapper state, and dynamic fail-closed compatibility attribution whenever cwd joins to Unknown. Keep OpenSpec task 6.5 open, then add the 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. - [ ] 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/specs/bounded-shell-analysis/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md index 5234c09..9391f36 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 @@ -297,6 +297,14 @@ unless all executable regions and transfers are discovered. - **THEN** the `pwd` cwd is unknown because `cd` may fail and `;` still continues - **THEN** the analyzer does not publish `/maybe` as the sole cwd +#### Scenario: Bash cd environment and physical resolution stay explicit +- **WHEN** Bash parses a bare relative `cd sub` without proved `CDPATH` and `cdable_vars` state +- **THEN** the successful cwd is unknown rather than a lexical `/sub` guess +- **WHEN** Bash parses `cd ./sub` or `cd ../sub` with an exact incoming cwd +- **THEN** the successful cwd may remain exact because those operands bypass directory search +- **WHEN** Bash parses `cd -P`, `cd -@`, `pushd`, or `popd` without the required filesystem or directory-stack facts +- **THEN** the successful cwd is unknown + #### Scenario: Zero-iteration loop path - **WHEN** a loop may execute zero times and its body changes cwd - **THEN** the post-loop state includes the pre-loop possibility 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 ad80daa..3578b7e 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 @@ -47,6 +47,22 @@ signal. - **THEN** the clause contains a synthetic `` `DynamicSkip` attribution argument with `Resolved=null` - **THEN** no synthetic cwd-attribution argument selects one possible exact path +#### Scenario: Cwd rebasing preserves resolver-owned operand semantics +- **WHEN** an executable-specific rule transforms an authored path operand such as curl `@../request.json` +- **THEN** outcome-sensitive cwd rebasing uses the retained logical resolver operand rather than reconstructing it from `Arg.Raw` or `ClauseElement.Value` +- **THEN** split and equals-form options update their exact corresponding Arg and ClauseElement coordinates +- **THEN** decoded wrappers retain the same resolver provenance while inheriting their invocation cwd + +#### Scenario: Exact outcome partition recovers a cwd-blocked path +- **WHEN** parse-order attribution initially makes a relative argument or redirect dynamic, but outcome analysis later proves its execution cwd exact +- **THEN** retained path-slot provenance permits an exact compatibility path without rescanning decoded text +- **THEN** a sibling partition whose cwd remains unknown keeps the operand dynamic + +#### Scenario: Dynamic redirect preserves authored target spelling +- **WHEN** a quoted relative redirect target becomes dynamic after cwd outcomes join +- **THEN** its compatibility `Redirect.Target` retains the target-only authored spelling including quotes +- **THEN** the redirect is marked `IsDynamicSkip=true` and its ClauseElement exact resolution is cleared + ### Requirement: Unparseable results are never authorization evidence When `ParsedCommand.IsUnparseable=true`, `Commands` and `Clauses` SHALL be empty. `Syntax` MAY contain partial diagnostic evidence, and the consumer guide diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 08d105e..7376007 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -77,6 +77,11 @@ conservative `lastpipe` / `pipefail`, ordered iteration plans, decoded-wrapper inheritance, and dynamic fail-closed compatibility sanitization before implementing the state pass. + - [x] 6.5b Apply outcome-sensitive cwd analysis to existing Bash lists, + pipelines, substitutions, subshells, and decoded wrappers; rebase exact + 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. - 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/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs new file mode 100644 index 0000000..b29fb12 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashAbstractStateAnalyzer.cs @@ -0,0 +1,1155 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using ShellSyntaxTree.Internal.Resolving; + +namespace ShellSyntaxTree.Internal.Bash.Parsing; + +/// +/// Computes execution-order Bash cwd facts after structural parsing. Parser +/// attribution remains a lexical construction aid; this pass owns the facts +/// exposed to security consumers. +/// +internal sealed class BashAbstractStateAnalyzer +{ + private readonly BashParserOptions _options; + private readonly Func _factsFactory; + private readonly Dictionary _inputs = + new(ClauseReferenceComparer.Instance); + + private BashAbstractStateAnalyzer( + BashParserOptions options, + Func factsFactory) + { + _options = options; + _factsFactory = factsFactory; + } + + internal static bool TryAnalyze( + ShellBlockSyntax syntax, + BashParserOptions options, + Func factsFactory, + out ShellBlockSyntax analyzedSyntax, + out Func analyzedFacts) + { + var analyzer = new BashAbstractStateAnalyzer(options, factsFactory); + var initial = new BashAbstractState( + options.WorkingDirectory ?? Environment.CurrentDirectory, + hasCompatibilityAttribution: false); + analyzer.AnalyzeBlock(syntax, initial); + + var facts = new Dictionary( + ClauseReferenceComparer.Instance); + analyzedSyntax = analyzer.RewriteBlock(syntax, facts); + analyzedFacts = simple => facts.TryGetValue(simple.Clause, out var value) + ? value + : new CommandOccurrenceFacts(); + return true; + } + + private BashFlowResult AnalyzeNode(ShellSyntaxNode node, BashAbstractState input) => + node switch + { + ShellBlockSyntax block => AnalyzeBlock(block, input), + SimpleCommandSyntax simple => AnalyzeSimple(simple, input), + CommandListSyntax list => AnalyzeList(list, input), + PipelineSyntax pipeline => AnalyzePipeline(pipeline, input), + GroupSyntax group => AnalyzeGroup(group, input), + CommandSubstitutionSyntax substitution => AnalyzeIsolatedBody( + substitution.Body, + input, + resetCompatibilityAttribution: true), + ForEachSyntax => BashFlowResult.Both(input), + ConditionLoopSyntax => BashFlowResult.Both(input.WithUnknownCwd()), + ConditionalSyntax conditional => AnalyzeConditional(conditional, input), + ConditionalBranchSyntax branch => AnalyzeBranch(branch, input), + _ => BashFlowResult.Both(input.WithUnknownCwd()), + }; + + private BashFlowResult AnalyzeBlock(ShellBlockSyntax block, BashAbstractState input) + { + var flow = BashFlowResult.Both(input); + foreach (var statement in block.Statements) + { + flow = AnalyzeNode(statement, flow.JoinedState); + } + + return flow; + } + + private BashFlowResult AnalyzeSimple( + SimpleCommandSyntax simple, + BashAbstractState input) + { + foreach (var substitution in simple.Substitutions) + { + AnalyzeIsolatedBody( + substitution.Body, + input, + resetCompatibilityAttribution: true); + } + + RecordInput(simple.Clause, input); + if (!TryGetCwdTransfer(simple.Clause, input, out var success)) + { + return BashFlowResult.Both(input); + } + + return new BashFlowResult(success, input); + } + + private BashFlowResult AnalyzeList(CommandListSyntax list, BashAbstractState input) + { + var flow = AnalyzeNode(list.Items[0].Command, input); + for (var index = 1; index < list.Items.Count; index++) + { + var item = list.Items[index]; + switch (item.Operator) + { + case CompoundOperator.AndIf: + { + var right = AnalyzeNode(item.Command, flow.OnSuccess ?? flow.JoinedState); + flow = new BashFlowResult( + right.OnSuccess, + BashAbstractState.JoinNullable(flow.OnFailure, right.OnFailure)); + break; + } + case CompoundOperator.OrIf: + { + var right = AnalyzeNode(item.Command, flow.OnFailure ?? flow.JoinedState); + flow = new BashFlowResult( + BashAbstractState.JoinNullable(flow.OnSuccess, right.OnSuccess), + right.OnFailure); + break; + } + case CompoundOperator.Sequence: + flow = AnalyzeNode(item.Command, flow.JoinedState); + break; + default: + return BashFlowResult.Both(flow.JoinedState.WithUnknownCwd()); + } + } + + return flow; + } + + private BashFlowResult AnalyzePipeline( + PipelineSyntax pipeline, + BashAbstractState input) + { + BashFlowResult? last = null; + foreach (var stage in pipeline.Stages) + { + last = AnalyzeNode(stage, input); + } + + // lastpipe controls whether the last stage can leak state; pipefail + // independently controls which exit partition receives it. With no + // proved shell options, both partitions conservatively receive the + // join of isolated and current-scope outcomes. + var joined = BashAbstractState.Join(input, last!.Value.JoinedState); + return BashFlowResult.Both(joined); + } + + private BashFlowResult AnalyzeGroup(GroupSyntax group, BashAbstractState input) + { + if (group.GroupKind == ShellGroupKind.CurrentScope) + { + return AnalyzeBlock(group.Body, input); + } + + return AnalyzeIsolatedBody( + group.Body, + input, + resetCompatibilityAttribution: IsDecodedWrapper(group.Body)); + } + + private BashFlowResult AnalyzeIsolatedBody( + ShellBlockSyntax body, + BashAbstractState input, + bool resetCompatibilityAttribution) + { + var childInput = resetCompatibilityAttribution + ? input.WithoutCompatibilityAttribution() + : input; + var inner = AnalyzeBlock(body, childInput); + return new BashFlowResult( + inner.OnSuccess is null ? null : input, + inner.OnFailure is null ? null : input); + } + + private BashFlowResult AnalyzeConditional( + ConditionalSyntax conditional, + BashAbstractState input) + { + BashAbstractState? success = null; + BashAbstractState? failure = input; + foreach (var branch in conditional.Branches) + { + var branchFlow = AnalyzeBranch(branch, failure ?? input.WithUnknownCwd()); + success = BashAbstractState.JoinNullable(success, branchFlow.OnSuccess); + failure = BashAbstractState.JoinNullable(failure, branchFlow.OnFailure); + } + + if (conditional.Else is not null) + { + var elseFlow = AnalyzeBlock(conditional.Else, failure ?? input.WithUnknownCwd()); + success = BashAbstractState.JoinNullable(success, elseFlow.OnSuccess); + failure = elseFlow.OnFailure; + } + + return new BashFlowResult(success, failure); + } + + private BashFlowResult AnalyzeBranch( + ConditionalBranchSyntax branch, + BashAbstractState input) + { + var condition = AnalyzeBlock(branch.Condition, input); + var body = AnalyzeBlock(branch.Body, condition.OnSuccess ?? condition.JoinedState); + return new BashFlowResult( + body.OnSuccess, + BashAbstractState.JoinNullable(condition.OnFailure, body.OnFailure)); + } + + private void RecordInput(Clause clause, BashAbstractState input) + { + if (_inputs.TryGetValue(clause, out var prior)) + { + _inputs[clause] = BashAbstractState.Join(prior, input); + } + else + { + _inputs.Add(clause, input); + } + } + + private bool TryGetCwdTransfer( + Clause clause, + BashAbstractState input, + out BashAbstractState success) + { + success = input; + var verb = FirstVerb(clause); + if (verb is null) + { + return false; + } + + if (verb is "command" or "builtin") + { + var dispatched = DispatchedVerb(clause); + if (dispatched is "cd" or "chdir" or "pushd" or "popd" or + "eval" or "." or "source" or "trap") + { + success = input.WithUnknownCwd(); + return true; + } + + return false; + } + + if (verb is "eval" or "." or "source" or "trap" or "pushd" or "popd") + { + success = input.WithUnknownCwd(); + return true; + } + + if (verb is not ("cd" or "chdir")) + { + return false; + } + + if (!TryGetCdOperand( + clause, + out _, + out var target, + out var resolutionMustBeUnknown)) + { + if (resolutionMustBeUnknown) + { + success = input.WithUnknownCwd(); + return true; + } + + success = string.IsNullOrEmpty(_options.HomeDirectory) + ? input.WithUnknownCwd() + : new BashAbstractState(_options.HomeDirectory, true); + return true; + } + + if (resolutionMustBeUnknown) + { + success = input.WithUnknownCwd(); + return true; + } + + if (target.Kind == ArgKind.Tilde && target.Resolved is not null) + { + success = new BashAbstractState(target.Resolved, true); + return true; + } + + if (target.Kind != ArgKind.Literal) + { + success = input.WithUnknownCwd(); + return true; + } + + var value = ArgumentValue(clause, target); + var options = OptionsFor(input); + var resolved = BashResolver.Resolve( + value, + treatAsPath: true, + options, + workingDirectoryUnknown: input.WorkingDirectory is null, + isLiteralBytes: true); + success = resolved.Resolved is null + ? input.WithUnknownCwd() + : new BashAbstractState(resolved.Resolved, true); + return true; + } + + private static string? DispatchedVerb(Clause clause) + { + var words = new List(clause.Verb.Tokens.Count + clause.Args.Count); + foreach (var token in clause.Verb.Tokens) + { + words.Add(token); + } + + foreach (var argument in clause.Args) + { + if (!argument.IsCwdAttribution && !argument.IsFlag) + { + words.Add(ArgumentValue(clause, argument)); + } + } + + var index = 0; + while (index < words.Count && words[index] is "command" or "builtin") + { + index++; + } + + return index < words.Count ? words[index] : null; + } + + private static bool TryGetCdOperand( + Clause clause, + out int argumentIndex, + out Arg target, + out bool resolutionMustBeUnknown) + { + argumentIndex = -1; + target = null!; + resolutionMustBeUnknown = false; + var optionsEnded = false; + var physical = false; + var current = 0; + foreach (var argument in clause.Args) + { + if (argument.IsCwdAttribution) + { + continue; + } + + var raw = argument.Raw; + if (!optionsEnded && raw == "--") + { + optionsEnded = true; + current++; + continue; + } + + if (!optionsEnded && raw.Length > 1 && raw[0] == '-' && raw != "-") + { + if (raw.IndexOf('P') >= 0 || raw.IndexOf('@') >= 0) + { + physical = true; + } + + current++; + continue; + } + + argumentIndex = current; + target = argument; + var value = ArgumentValue(clause, argument); + resolutionMustBeUnknown = physical || + value == "-" || + target.Kind == ArgKind.Literal && IsCdPathSearchCandidate(value); + return true; + } + + resolutionMustBeUnknown = physical; + return false; + } + + private static bool IsCdPathSearchCandidate(string value) + { + if (value is "." or ".." || + value.StartsWith("./", StringComparison.Ordinal) || + value.StartsWith("../", StringComparison.Ordinal) || + BashResolver.IsRootedPath(value)) + { + return false; + } + + const string fileSystemPrefix = "filesystem::"; + return !value.StartsWith(fileSystemPrefix, StringComparison.Ordinal) || + !BashResolver.IsRootedPath(value.Substring(fileSystemPrefix.Length)); + } + + private ShellBlockSyntax RewriteBlock( + ShellBlockSyntax block, + Dictionary facts) + { + var statements = new ShellSyntaxNode[block.Statements.Count]; + for (var index = 0; index < statements.Length; index++) + { + statements[index] = RewriteNode(block.Statements[index], facts); + } + + return block with { Statements = statements }; + } + + private ShellSyntaxNode RewriteNode( + ShellSyntaxNode node, + Dictionary facts) => + node switch + { + ShellBlockSyntax block => RewriteBlock(block, facts), + SimpleCommandSyntax simple => RewriteSimple(simple, facts), + PipelineSyntax pipeline => pipeline with + { + Stages = RewriteNodes(pipeline.Stages, facts), + }, + CommandListSyntax list => list with + { + Items = RewriteItems(list.Items, facts), + }, + GroupSyntax group => group with { Body = RewriteBlock(group.Body, facts) }, + ForEachSyntax forEach => forEach with + { + IteratorCommands = RewriteBlock(forEach.IteratorCommands, facts), + Body = RewriteBlock(forEach.Body, facts), + }, + ConditionLoopSyntax loop => loop with + { + Condition = RewriteBlock(loop.Condition, facts), + Body = RewriteBlock(loop.Body, facts), + }, + ConditionalSyntax conditional => conditional with + { + Branches = RewriteBranches(conditional.Branches, facts), + Else = conditional.Else is null ? null : RewriteBlock(conditional.Else, facts), + }, + ConditionalBranchSyntax branch => branch with + { + Condition = RewriteBlock(branch.Condition, facts), + Body = RewriteBlock(branch.Body, facts), + }, + CommandSubstitutionSyntax substitution => substitution with + { + Body = RewriteBlock(substitution.Body, facts), + }, + _ => node, + }; + + private SimpleCommandSyntax RewriteSimple( + SimpleCommandSyntax simple, + Dictionary facts) + { + var substitutions = new CommandSubstitutionSyntax[simple.Substitutions.Count]; + for (var index = 0; index < substitutions.Length; index++) + { + substitutions[index] = (CommandSubstitutionSyntax)RewriteNode( + simple.Substitutions[index], + facts); + } + + var sourceFacts = _factsFactory(simple); + if (!_inputs.TryGetValue(simple.Clause, out var input)) + { + facts.Add(simple.Clause, sourceFacts); + return simple with { Substitutions = substitutions }; + } + + var clause = RewriteClause(simple.Clause, input, sourceFacts.CwdPathDependencies); + facts.Add(clause, new CommandOccurrenceFacts + { + EffectiveArguments = sourceFacts.EffectiveArguments, + WorkingDirectory = input.ToDomain(), + Redirects = RewriteRedirectFacts(sourceFacts.Redirects, clause), + CwdPathDependencies = sourceFacts.CwdPathDependencies, + IsComplete = sourceFacts.IsComplete, + }); + return simple with + { + Clause = clause, + Substitutions = substitutions, + }; + } + + private Clause RewriteClause( + Clause clause, + BashAbstractState input, + IReadOnlyList dependencies) + { + var parseWorkingDirectory = OriginalParseWorkingDirectory(clause, input); + var directVerb = FirstVerb(clause); + var clearCdTargetIndex = directVerb is "cd" or "chdir" && TryGetCdOperand( + clause, + out var cdTargetIndex, + out _, + out var cdResolutionMustBeUnknown) && + cdResolutionMustBeUnknown + ? cdTargetIndex + : -1; + var authoredArgs = new List(clause.Args.Count); + var authoredArgumentIndex = 0; + foreach (var argument in clause.Args) + { + if (!argument.IsCwdAttribution) + { + var dependency = FindArgumentDependency( + dependencies, + authoredArgumentIndex); + var clearResolution = authoredArgumentIndex == clearCdTargetIndex; + var rebased = clearResolution + ? null + : RebaseResolution( + argument.Resolved, + argument.IsPath, + argument.Kind, + dependency?.LogicalValue ?? ArgumentValue(clause, argument), + dependency?.ParseWorkingDirectory ?? parseWorkingDirectory, + input, + dependency?.DependsOnWorkingDirectory); + var promote = !clearResolution && + dependency?.DependsOnWorkingDirectory == true && + rebased is not null; + authoredArgs.Add(argument with + { + Kind = promote ? ArgKind.Literal : argument.Kind, + IsPath = promote || argument.IsPath, + Resolved = rebased, + }); + authoredArgumentIndex++; + } + } + + var elements = new ClauseElement[clause.Elements.Count]; + var argumentElementIndex = 0; + for (var index = 0; index < elements.Length; index++) + { + var element = clause.Elements[index]; + var dependency = FindDependency(dependencies, index); + var clearResolution = element.Role == ClauseElementRole.Argument && + argumentElementIndex++ == clearCdTargetIndex; + var rebased = clearResolution + ? null + : RebaseResolution( + element.Resolved, + element.IsPath, + element.Kind, + dependency?.LogicalValue ?? element.Value, + dependency?.ParseWorkingDirectory ?? parseWorkingDirectory, + input, + dependency?.DependsOnWorkingDirectory); + var promote = !clearResolution && + dependency?.DependsOnWorkingDirectory == true && + rebased is not null; + elements[index] = element with + { + Kind = promote ? ArgKind.Literal : element.Kind, + IsPath = promote || element.IsPath, + Resolved = rebased, + }; + } + + var redirects = RewriteCompatibilityRedirects( + clause, + parseWorkingDirectory, + input, + dependencies); + + if (input.HasCompatibilityAttribution) + { + authoredArgs.Add(CreateAttribution(input)); + } + + return clause with + { + Args = authoredArgs.ToArray(), + Elements = elements, + Redirects = redirects, + }; + } + + private IReadOnlyList RewriteCompatibilityRedirects( + Clause clause, + string? parseWorkingDirectory, + BashAbstractState input, + IReadOnlyList dependencies) + { + if (clause.Redirects.Count == 0) + { + return clause.Redirects; + } + + var redirectElements = new List(clause.Redirects.Count); + foreach (var element in clause.Elements) + { + if (element.Role == ClauseElementRole.Redirect) + { + redirectElements.Add(element); + } + } + + var redirects = new Redirect[clause.Redirects.Count]; + for (var index = 0; index < redirects.Length; index++) + { + var redirect = clause.Redirects[index]; + if (index >= redirectElements.Count) + { + redirects[index] = redirect; + continue; + } + + var element = redirectElements[index]; + var elementIndex = IndexOfElement(clause.Elements, element); + var dependency = FindDependency(dependencies, elementIndex); + if (redirect.IsDynamicSkip && dependency is null) + { + redirects[index] = redirect; + continue; + } + + var target = RebaseResolution( + redirect.IsDynamicSkip ? null : redirect.Target, + element.IsPath, + element.Kind, + dependency?.LogicalValue ?? element.Value, + dependency?.ParseWorkingDirectory ?? parseWorkingDirectory, + input, + dependency?.DependsOnWorkingDirectory); + redirects[index] = target is null + ? redirect with + { + Target = dependency?.AuthoredValue ?? element.Value, + IsDynamicSkip = true, + } + : redirect with + { + Target = target, + IsDynamicSkip = false, + }; + } + + return redirects; + } + + private static IReadOnlyList RewriteRedirectFacts( + IReadOnlyList source, + Clause clause) + { + if (source.Count == 0) + { + return source; + } + + var rewritten = new RedirectAnalysis[source.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + var fact = source[index]; + if (!fact.IsPathRelevant || + fact.RedirectIndex < 0 || + fact.RedirectIndex >= clause.Redirects.Count) + { + rewritten[index] = fact; + continue; + } + + var redirect = clause.Redirects[fact.RedirectIndex]; + rewritten[index] = fact with + { + Target = redirect.IsDynamicSkip + ? ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { redirect.Target }, + }, + }; + } + + return rewritten; + } + + private string? RebaseResolution( + string? resolved, + bool isPath, + ArgKind kind, + string authored, + string? parseWorkingDirectory, + BashAbstractState input, + bool? dependsOnWorkingDirectory) + { + if (dependsOnWorkingDirectory.HasValue) + { + if (!dependsOnWorkingDirectory.Value) + { + return resolved; + } + + if (resolved is not null && + parseWorkingDirectory is not null && + string.Equals( + input.WorkingDirectory, + parseWorkingDirectory, + StringComparison.Ordinal)) + { + return resolved; + } + + if (input.WorkingDirectory is null) + { + return null; + } + + return BashResolver.Resolve( + authored, + treatAsPath: true, + OptionsFor(input), + workingDirectoryUnknown: false, + isLiteralBytes: true).Resolved; + } + + if (!isPath || kind != ArgKind.Literal || resolved is null || + parseWorkingDirectory is null) + { + return resolved; + } + + if (parseWorkingDirectory is not null && + string.Equals( + input.WorkingDirectory, + parseWorkingDirectory, + StringComparison.Ordinal)) + { + return resolved; + } + + var suffix = string.Empty; + var hasRelativeSuffix = parseWorkingDirectory is not null && + TryGetRelativeSuffix(resolved, parseWorkingDirectory, out suffix); + if (dependsOnWorkingDirectory is null && + !hasRelativeSuffix && + IsResolverRootedLiteral(authored)) + { + return resolved; + } + + if (input.WorkingDirectory is null) + { + return null; + } + + return BashResolver.Resolve( + hasRelativeSuffix ? suffix : authored, + treatAsPath: true, + OptionsFor(input), + workingDirectoryUnknown: false, + isLiteralBytes: true).Resolved; + } + + private static bool IsResolverRootedLiteral(string authored) + { + if (BashResolver.IsRootedPath(authored)) + { + return true; + } + + const string fileSystemPrefix = "filesystem::"; + return authored.StartsWith(fileSystemPrefix, StringComparison.Ordinal) && + BashResolver.IsRootedPath(authored.Substring(fileSystemPrefix.Length)); + } + + private string? OriginalParseWorkingDirectory( + Clause clause, + BashAbstractState input) + { + foreach (var argument in clause.Args) + { + if (!argument.IsCwdAttribution) + { + continue; + } + + return argument.Kind == ArgKind.Literal ? argument.Resolved : null; + } + + return clause.IsCommandStringWrapped + ? _options.WorkingDirectory ?? Environment.CurrentDirectory + : input.WorkingDirectory ?? + _options.WorkingDirectory ?? + Environment.CurrentDirectory; + } + + private static CwdPathDependency? FindDependency( + IReadOnlyList dependencies, + int elementIndex) + { + if (elementIndex < 0) + { + return null; + } + + foreach (var dependency in dependencies) + { + if (dependency.ClauseElementIndex == elementIndex) + { + return dependency; + } + } + + return null; + } + + private static CwdPathDependency? FindArgumentDependency( + IReadOnlyList dependencies, + int argumentIndex) + { + foreach (var dependency in dependencies) + { + if (dependency.ClauseArgumentIndex == argumentIndex) + { + return dependency; + } + } + + return null; + } + + private static int IndexOfElement( + IReadOnlyList elements, + ClauseElement expected) + { + for (var index = 0; index < elements.Count; index++) + { + if (object.ReferenceEquals(elements[index], expected)) + { + return index; + } + } + + return -1; + } + + private static bool TryGetRelativeSuffix( + string resolved, + string workingDirectory, + out string suffix) + { + var normalizedResolved = resolved.Replace('\\', '/').TrimEnd('/'); + var normalizedWorkingDirectory = workingDirectory.Replace('\\', '/').TrimEnd('/'); + if (string.Equals( + normalizedResolved, + normalizedWorkingDirectory, + StringComparison.Ordinal)) + { + suffix = "."; + return true; + } + + var prefix = normalizedWorkingDirectory + "/"; + if (normalizedResolved.StartsWith(prefix, StringComparison.Ordinal)) + { + suffix = normalizedResolved.Substring(prefix.Length); + return true; + } + + suffix = string.Empty; + return false; + } + + private BashParserOptions OptionsFor(BashAbstractState state) => new() + { + HomeDirectory = _options.HomeDirectory, + WorkingDirectory = state.WorkingDirectory ?? _options.WorkingDirectory, + }; + + private static Arg CreateAttribution(BashAbstractState state) => + state.WorkingDirectory is null + ? new Arg + { + Raw = "", + Kind = ArgKind.DynamicSkip, + IsCwdAttribution = true, + } + : new Arg + { + Raw = state.WorkingDirectory, + Resolved = state.WorkingDirectory, + Kind = ArgKind.Literal, + IsPath = true, + IsCwdAttribution = true, + }; + + private static string? FirstVerb(Clause clause) => + clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]; + + private static string? FirstPositionalValue(Clause clause) + { + var argument = FirstPositionalArgument(clause); + return argument is null ? null : ArgumentValue(clause, argument); + } + + private static Arg? FirstPositionalArgument(Clause clause) + { + foreach (var argument in clause.Args) + { + if (!argument.IsCwdAttribution && !argument.IsFlag) + { + return argument; + } + } + + return null; + } + + private static bool ContainsAuthoredArgument(Clause clause, string raw) + { + foreach (var argument in clause.Args) + { + if (!argument.IsCwdAttribution && + string.Equals(argument.Raw, raw, StringComparison.Ordinal)) + { + return true; + } + } + + return false; + } + + private static string ArgumentValue(Clause clause, Arg expected) + { + var argumentIndex = 0; + foreach (var argument in clause.Args) + { + if (argument.IsCwdAttribution) + { + continue; + } + + if (object.ReferenceEquals(argument, expected)) + { + var current = 0; + foreach (var element in clause.Elements) + { + if (element.Role != ClauseElementRole.Argument) + { + continue; + } + + if (current == argumentIndex) + { + return element.Value; + } + + current++; + } + + return argument.Raw; + } + + argumentIndex++; + } + + return expected.Raw; + } + + private static bool IsDecodedWrapper(ShellBlockSyntax body) + { + var stack = new Stack(); + stack.Push(body); + while (stack.Count > 0) + { + var node = stack.Pop(); + switch (node) + { + case SimpleCommandSyntax simple: + return simple.Clause.IsCommandStringWrapped; + case ShellBlockSyntax block: + PushReverse(stack, block.Statements); + break; + case CommandListSyntax list: + for (var index = list.Items.Count - 1; index >= 0; index--) + { + stack.Push(list.Items[index].Command); + } + + break; + case PipelineSyntax pipeline: + PushReverse(stack, pipeline.Stages); + break; + case GroupSyntax group: + stack.Push(group.Body); + break; + case ForEachSyntax forEach: + stack.Push(forEach.Body); + stack.Push(forEach.IteratorCommands); + break; + case CommandSubstitutionSyntax substitution: + stack.Push(substitution.Body); + break; + } + } + + return false; + } + + private static void PushReverse( + Stack stack, + IReadOnlyList nodes) + { + for (var index = nodes.Count - 1; index >= 0; index--) + { + stack.Push(nodes[index]); + } + } + + private IReadOnlyList RewriteNodes( + IReadOnlyList nodes, + Dictionary facts) + { + var rewritten = new ShellSyntaxNode[nodes.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + rewritten[index] = RewriteNode(nodes[index], facts); + } + + return rewritten; + } + + private IReadOnlyList RewriteItems( + IReadOnlyList items, + Dictionary facts) + { + var rewritten = new CommandListItemSyntax[items.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + rewritten[index] = items[index] with + { + Command = RewriteNode(items[index].Command, facts), + }; + } + + return rewritten; + } + + private IReadOnlyList RewriteBranches( + IReadOnlyList branches, + Dictionary facts) + { + var rewritten = new ConditionalBranchSyntax[branches.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + rewritten[index] = (ConditionalBranchSyntax)RewriteNode(branches[index], facts); + } + + return rewritten; + } + + private readonly struct BashFlowResult + { + internal BashFlowResult(BashAbstractState? onSuccess, BashAbstractState? onFailure) + { + OnSuccess = onSuccess; + OnFailure = onFailure; + } + + internal BashAbstractState? OnSuccess { get; } + + internal BashAbstractState? OnFailure { get; } + + internal BashAbstractState JoinedState => + BashAbstractState.JoinNullable(OnSuccess, OnFailure) ?? + new BashAbstractState(null, true); + + internal static BashFlowResult Both(BashAbstractState state) => new(state, state); + } + + private readonly struct BashAbstractState + { + internal BashAbstractState( + string? workingDirectory, + bool hasCompatibilityAttribution) + { + WorkingDirectory = workingDirectory; + HasCompatibilityAttribution = hasCompatibilityAttribution; + } + + internal string? WorkingDirectory { get; } + + internal bool HasCompatibilityAttribution { get; } + + internal BashAbstractState WithUnknownCwd() => new(null, true); + + internal BashAbstractState WithoutCompatibilityAttribution() => + new(WorkingDirectory, WorkingDirectory is null); + + internal ShellValueDomain ToDomain() => + WorkingDirectory is null + ? ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { WorkingDirectory }, + }; + + internal static BashAbstractState Join( + BashAbstractState left, + BashAbstractState right) => new( + string.Equals( + left.WorkingDirectory, + right.WorkingDirectory, + StringComparison.Ordinal) + ? left.WorkingDirectory + : null, + left.HasCompatibilityAttribution || right.HasCompatibilityAttribution); + + internal static BashAbstractState? JoinNullable( + BashAbstractState? left, + BashAbstractState? right) + { + if (left is null) + { + return right; + } + + if (right is null) + { + return left; + } + + return Join(left.Value, right.Value); + } + } + + private sealed class ClauseReferenceComparer : IEqualityComparer + { + internal static ClauseReferenceComparer Instance { get; } = new(); + + public bool Equals(Clause? x, Clause? y) => object.ReferenceEquals(x, y); + + public int GetHashCode(Clause obj) => RuntimeHelpers.GetHashCode(obj); + } +} diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 644cffa..c1c8bec 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -56,7 +56,7 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) options, bashCDepth: 0, structuralDepth: 0, - markBashCWrapped: false); + markBashCWrapped: false).Command; } /// @@ -69,7 +69,7 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) /// fires only on recursive calls (the outer top-level command doesn't /// pretend to be wrapped). /// - private static ParsedCommand ParseInternal( + private static BashParseResult ParseInternal( string source, BashParserOptions options, int bashCDepth, @@ -457,19 +457,46 @@ private readonly struct ClauseResult { public IReadOnlyList Clauses { get; } + public IReadOnlyList PathResolutions { get; } + public string? Error { get; } - public ClauseResult(IReadOnlyList clauses, string? error) + public ClauseResult( + IReadOnlyList clauses, + IReadOnlyList pathResolutions, + string? error) { Clauses = clauses; + PathResolutions = pathResolutions; Error = error; } - public static ClauseResult Ok(Clause c) => new(new[] { c }, null); + public static ClauseResult Ok( + Clause clause, + IReadOnlyList pathResolutions) => + new(new[] { clause }, pathResolutions, null); - public static ClauseResult Fail(string reason) => new(Array.Empty(), reason); + public static ClauseResult Fail(string reason) => new( + Array.Empty(), + Array.Empty(), + reason); } + private readonly record struct BashPathResolutionSeed( + int ClauseElementIndex, + int? ClauseArgumentIndex, + ShellValue ResolverValue, + ShellResolutionConsumer Consumer, + string AuthoredValue); + + private readonly record struct CwdPathDependencySet( + Clause Clause, + IReadOnlyList Dependencies); + + private readonly record struct BashParseResult( + ParsedCommand Command, + IReadOnlyList CwdPathDependencySets); + private static ClauseResult ParseClauseSegment( Segment segment, string source, BashParserOptions options, bool workingDirectoryUnknown) { @@ -587,6 +614,7 @@ private static ClauseResult ParseClauseSegment( out var emptyArgs, out var emptyRedirects, out var emptyElements, + out var emptyPathResolutions, out var redirectError); if (redirectError is not null) { @@ -602,7 +630,7 @@ private static ClauseResult ParseClauseSegment( Elements = emptyElements, IsSubshell = false, IsCommandStringWrapped = false, - }); + }, emptyPathResolutions); } if (BashVerbs.ControlFlowKeywords.Contains(verbTokens[0])) @@ -626,6 +654,7 @@ private static ClauseResult ParseClauseSegment( out var args, out var redirects, out var elements, + out var pathResolutions, out var argError); if (argError is not null) { @@ -643,7 +672,7 @@ private static ClauseResult ParseClauseSegment( IsCommandStringWrapped = false, }; - return ClauseResult.Ok(clause); + return ClauseResult.Ok(clause, pathResolutions); } private static bool HasStaticCommandIdentity(BashToken token) @@ -697,6 +726,7 @@ private static void ExtractRedirectsAndArgs( out IReadOnlyList args, out IReadOnlyList redirects, out IReadOnlyList elements, + out IReadOnlyList pathResolutions, out string? error) { ExtractRedirectsAndArgs( @@ -712,6 +742,7 @@ private static void ExtractRedirectsAndArgs( out args, out redirects, out elements, + out pathResolutions, out error); } @@ -728,11 +759,13 @@ private static void ExtractRedirectsAndArgs( out IReadOnlyList args, out IReadOnlyList redirects, out IReadOnlyList elements, + out IReadOnlyList pathResolutions, out string? error) { var argList = new List(); var redirectList = new List(); var elementList = new List(); + var pathResolutionList = new List(); var positionalIndex = 0; var i = start; var precedingVerbTokenCount = 0; @@ -775,6 +808,7 @@ private static void ExtractRedirectsAndArgs( args = argList; redirects = redirectList; elements = elementList; + pathResolutions = pathResolutionList; return; } @@ -785,6 +819,7 @@ private static void ExtractRedirectsAndArgs( args = argList; redirects = redirectList; elements = elementList; + pathResolutions = pathResolutionList; return; } @@ -797,7 +832,23 @@ private static void ExtractRedirectsAndArgs( redirectList, workingDirectoryUnknown, precedingVerbTokenCount, - out var redirectElement); + out var redirectElement, + out var redirectResolverValue); + if (redirectResolverValue is not null && + (redirectElement.Resolved is not null || + CanResolveWithKnownCwd( + redirectResolverValue, + options, + ShellResolutionConsumer.BashRedirect))) + { + pathResolutionList.Add(new BashPathResolutionSeed( + elementList.Count, + ClauseArgumentIndex: null, + redirectResolverValue, + ShellResolutionConsumer.BashRedirect, + SourceSlice(source, target))); + } + elementList.Add(redirectElement); i += 2; continue; @@ -811,6 +862,7 @@ private static void ExtractRedirectsAndArgs( args = argList; redirects = redirectList; elements = elementList; + pathResolutions = pathResolutionList; return; } @@ -838,6 +890,7 @@ private static void ExtractRedirectsAndArgs( args = argList; redirects = redirectList; elements = elementList; + pathResolutions = pathResolutionList; return; } @@ -882,6 +935,8 @@ private static void ExtractRedirectsAndArgs( }); Arg valueArg; + ShellValue? adjacentPathResolverValue = null; + var adjacentPathIsResolvable = false; if (fragmentClassification.HasOpaqueFragment || (verbKeyForFlagValuePaths is not null && BashPerVerbRules.ValueOfFlagIsOpaqueCommand( @@ -906,12 +961,19 @@ private static void ExtractRedirectsAndArgs( var adjacentResolverValue = GetResolverValue( fragmentClassification.ResolverValue, adjacentValueForResolution); + adjacentPathResolverValue = adjacentResolverValue; var (adjacentKind, adjacentResolved, adjacentIsPath) = BashResolver.Resolve( adjacentResolverValue, adjacentValueIsPath, options, workingDirectoryUnknown, ShellResolutionConsumer.BashArgument); + adjacentPathIsResolvable = adjacentValueIsPath && + (adjacentResolved is not null || + CanResolveWithKnownCwd( + adjacentResolverValue, + options, + ShellResolutionConsumer.BashArgument)); valueArg = new Arg { Raw = fragmentClassification.ValueRaw, @@ -921,7 +983,19 @@ private static void ExtractRedirectsAndArgs( }; } + var valueArgumentIndex = argList.Count; argList.Add(valueArg); + if (adjacentPathIsResolvable && + adjacentPathResolverValue is not null) + { + pathResolutionList.Add(new BashPathResolutionSeed( + elementList.Count, + valueArgumentIndex, + adjacentPathResolverValue, + ShellResolutionConsumer.BashArgument, + fragmentClassification.ValueRaw)); + } + elementList.Add(CreateCombinedElement( fragmentClassification, precedingVerbTokenCount, @@ -977,6 +1051,7 @@ private static void ExtractRedirectsAndArgs( options, workingDirectoryUnknown, ShellResolutionConsumer.BashArgument); + var valueArgumentIndex = argList.Count; argList.Add(new Arg { Raw = rawValuePart, @@ -984,6 +1059,21 @@ private static void ExtractRedirectsAndArgs( Kind = vKind, IsPath = vIsPath, }); + if (valueIsPath && + (vResolved is not null || + CanResolveWithKnownCwd( + inlineResolverValue, + options, + ShellResolutionConsumer.BashArgument))) + { + pathResolutionList.Add(new BashPathResolutionSeed( + elementList.Count, + valueArgumentIndex, + inlineResolverValue, + ShellResolutionConsumer.BashArgument, + rawValuePart)); + } + elementList.Add(CreateElement( source, t, @@ -1073,6 +1163,7 @@ private static void ExtractRedirectsAndArgs( options, workingDirectoryUnknown, ShellResolutionConsumer.BashArgument); + var argumentIndex = argList.Count; argList.Add(new Arg { Raw = sourceRaw, @@ -1080,6 +1171,21 @@ private static void ExtractRedirectsAndArgs( Kind = kind, IsPath = isPath, }); + if (treatAsPath && + (resolved is not null || + CanResolveWithKnownCwd( + resolverValue, + options, + ShellResolutionConsumer.BashArgument))) + { + pathResolutionList.Add(new BashPathResolutionSeed( + elementList.Count, + argumentIndex, + resolverValue, + ShellResolutionConsumer.BashArgument, + sourceRaw)); + } + elementList.Add(CreateElement( source, t, @@ -1134,6 +1240,7 @@ private static void ExtractRedirectsAndArgs( options, workingDirectoryUnknown, ShellResolutionConsumer.BashArgument); + var argumentIndex = argList.Count; argList.Add(new Arg { Raw = sourceRaw, @@ -1141,6 +1248,21 @@ private static void ExtractRedirectsAndArgs( Kind = kind, IsPath = isPath, }); + if (treatAsPath && + (resolved is not null || + CanResolveWithKnownCwd( + resolverValue, + options, + ShellResolutionConsumer.BashArgument))) + { + pathResolutionList.Add(new BashPathResolutionSeed( + elementList.Count, + argumentIndex, + resolverValue, + ShellResolutionConsumer.BashArgument, + sourceRaw)); + } + elementList.Add(CreateElement( source, t, @@ -1193,6 +1315,7 @@ private static void ExtractRedirectsAndArgs( args = argList; redirects = redirectList; elements = elementList; + pathResolutions = pathResolutionList; error = null; } @@ -1205,8 +1328,10 @@ private static void BuildRedirect( List redirectList, bool workingDirectoryUnknown, int precedingVerbTokenCount, - out ClauseElement element) + out ClauseElement element, + out ShellValue? pathResolverValue) { + pathResolverValue = null; if (target.Kind == BashTokenKind.OpaqueSubstitution) { // Opaque region as redirect target → always DynamicSkip. @@ -1268,6 +1393,7 @@ private static void BuildRedirect( options, workingDirectoryUnknown, ShellResolutionConsumer.BashRedirect); + pathResolverValue = resolverValue; bool isDynamic; string redirectTarget; @@ -1298,6 +1424,17 @@ private static void BuildRedirect( resolved: kind == ArgKind.DynamicSkip ? null : resolved); } + private static bool CanResolveWithKnownCwd( + ShellValue resolverValue, + BashParserOptions options, + ShellResolutionConsumer consumer) => + BashResolver.Resolve( + resolverValue, + treatAsPath: true, + options, + workingDirectoryUnknown: false, + consumer).Resolved is not null; + private static ClauseElement CreateElement( string source, BashToken token, diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs index 75d2217..94ae539 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashStructuralCoordinator.cs @@ -14,7 +14,7 @@ namespace ShellSyntaxTree.Internal.Bash.Parsing; internal static partial class BashCommandParser { - private static ParsedCommand ParseStructured( + private static BashParseResult ParseStructured( string source, IReadOnlyList tokens, BashParserOptions options, @@ -36,38 +36,66 @@ private static ParsedCommand ParseStructured( return StructuralFailure(source, error, syntax); } - if (!ShellSyntaxProjection.TryProject( + if (!BashAbstractStateAnalyzer.TryAnalyze( syntax, + options, coordinator.GetFacts, + out var analyzedSyntax, + out var analyzedFacts) || + !ShellSyntaxProjection.TryProject( + analyzedSyntax, + analyzedFacts, out var projection)) { return StructuralFailure( source, "Bash structural syntax exceeded limits or contained invalid parser-owned facts", - syntax); + analyzedSyntax); } - return new ParsedCommand + var command = new ParsedCommand { Source = source, - Syntax = syntax, + Syntax = analyzedSyntax, Commands = projection.Commands, Clauses = projection.Clauses, }; + return new BashParseResult( + command, + CreateDependencySets(projection.Commands, analyzedFacts)); + } + + private static IReadOnlyList CreateDependencySets( + IReadOnlyList commands, + Func factsFactory) + { + var sets = new CwdPathDependencySet[commands.Count]; + for (var index = 0; index < sets.Length; index++) + { + var clause = commands[index].Clause; + var facts = factsFactory(new SimpleCommandSyntax { Clause = clause }); + sets[index] = new CwdPathDependencySet( + clause, + facts.CwdPathDependencies); + } + + return sets; } - private static ParsedCommand StructuralFailure( + private static BashParseResult StructuralFailure( string source, string? reason, - ShellBlockSyntax? syntax = null) => new() - { - Source = source, - Syntax = syntax ?? new ShellBlockSyntax(), - Commands = Array.Empty(), - Clauses = Array.Empty(), - IsUnparseable = true, - UnparseableReason = reason, - }; + ShellBlockSyntax? syntax = null) => new( + new ParsedCommand + { + Source = source, + Syntax = syntax ?? new ShellBlockSyntax(), + Commands = Array.Empty(), + Clauses = Array.Empty(), + IsUnparseable = true, + UnparseableReason = reason, + }, + Array.Empty()); private sealed class StructuralCoordinator { @@ -394,12 +422,13 @@ private bool TryParseCommand( return false; } - var inner = ParseInternal( + var innerResult = ParseInternal( innerCommand!, _options, _bashCDepth + 1, _structuralDepth + _subshellDepth + _loopDepth + 1, markBashCWrapped: true); + var inner = innerResult.Command; if (inner.IsUnparseable) { error = inner.UnparseableReason; @@ -428,7 +457,11 @@ private bool TryParseCommand( SourceLength = lastToken.SourceStart + lastToken.SourceLength - firstToken.SourceStart, }; - if (!TryRegisterDecodedFacts(inner, body, out error)) + if (!TryRegisterDecodedFacts( + inner, + innerResult.CwdPathDependencySets, + body, + out error)) { command = null; return false; @@ -517,7 +550,11 @@ private bool TryParseCommand( SourceStart = firstSource.SourceStart, SourceLength = sourceEnd - firstSource.SourceStart, }; - RegisterFacts(simple, segmentTokens); + RegisterFacts( + simple, + segmentTokens, + parsed.PathResolutions, + effectiveOptions); command = simple; return true; } @@ -1128,31 +1165,55 @@ private bool TryParseSubstitutionBody( private void RegisterFacts( SimpleCommandSyntax simple, - IReadOnlyList sourceTokens) + IReadOnlyList sourceTokens, + IReadOnlyList pathResolutions, + BashParserOptions parseOptions) { var effective = new List(); + var cwdPathDependencies = new List(); for (var elementIndex = 0; elementIndex < simple.Clause.Elements.Count; elementIndex++) { var element = simple.Clause.Elements[elementIndex]; - if (element.Role != ClauseElementRole.Argument || - !TryGetElementValue(element, sourceTokens, out var value) || - !_bindings.TryAnalyzeEffectiveValue(value, out var domain)) + if (!TryGetElementValue(element, sourceTokens, out var value)) { continue; } - effective.Add(new EffectiveArgument + if (element.Role == ClauseElementRole.Argument && + _bindings.TryAnalyzeEffectiveValue(value, out var domain)) { - ClauseElementIndex = elementIndex, - Value = domain, - }); + effective.Add(new EffectiveArgument + { + ClauseElementIndex = elementIndex, + Value = domain, + }); + } + + } + + foreach (var pathResolution in pathResolutions) + { + var withoutCwd = BashResolver.Resolve( + pathResolution.ResolverValue, + treatAsPath: true, + parseOptions, + workingDirectoryUnknown: true, + consumer: pathResolution.Consumer); + cwdPathDependencies.Add(new CwdPathDependency( + pathResolution.ClauseElementIndex, + pathResolution.ClauseArgumentIndex, + withoutCwd.Resolved is null, + pathResolution.ResolverValue.Decoded, + pathResolution.AuthoredValue, + parseOptions.WorkingDirectory ?? Environment.CurrentDirectory)); } _facts.Add(simple.Clause, new CommandOccurrenceFacts { EffectiveArguments = effective.ToArray(), + CwdPathDependencies = cwdPathDependencies.ToArray(), IsComplete = simple.Clause.Redirects.Count == 0 && !HasUnexpandedCommandString(simple.Clause), }); @@ -1210,6 +1271,7 @@ private void MergeFacts(StructuralCoordinator nested) private bool TryRegisterDecodedFacts( ParsedCommand inner, + IReadOnlyList dependencySets, ShellBlockSyntax clonedBody, out string? error) { @@ -1228,6 +1290,9 @@ private bool TryRegisterDecodedFacts( EffectiveArguments = source.EffectiveArguments, WorkingDirectory = source.WorkingDirectory, Redirects = source.Redirects, + CwdPathDependencies = FindDependencies( + dependencySets, + source.Clause), IsComplete = source.IsComplete, }); } @@ -1236,6 +1301,21 @@ private bool TryRegisterDecodedFacts( return true; } + private static IReadOnlyList FindDependencies( + IReadOnlyList dependencySets, + Clause clause) + { + foreach (var set in dependencySets) + { + if (object.ReferenceEquals(set.Clause, clause)) + { + return set.Dependencies; + } + } + + return Array.Empty(); + } + private static bool IsPotentialBindingMutation(Clause clause) { if (clause.Verb.Tokens.Count == 0) diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs index ad111b8..de8c0cc 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs @@ -749,7 +749,7 @@ private static string NormalizePath(string path) /// which is platform-aware and would, e.g., treat /foo as /// non-rooted on Windows for the purposes of bash path semantics. /// - private static bool IsRootedPath(string token) + internal static bool IsRootedPath(string token) { if (token.Length == 0) { diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs index 348e0bf..13220f2 100644 --- a/src/ShellSyntaxTree/ShellSyntaxProjection.cs +++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs @@ -23,9 +23,26 @@ internal sealed class CommandOccurrenceFacts internal IReadOnlyList Redirects { get; init; } = Array.Empty(); + internal IReadOnlyList CwdPathDependencies { get; init; } = + Array.Empty(); + internal bool IsComplete { get; init; } } +/// +/// Retains resolver-owned path provenance and its exact compatibility +/// coordinates for outcome-sensitive rebasing. Public compatibility DTOs do +/// not contain enough lexical or executable-specific context to reconstruct +/// these facts safely later. +/// +internal readonly record struct CwdPathDependency( + int ClauseElementIndex, + int? ClauseArgumentIndex, + bool DependsOnWorkingDirectory, + string LogicalValue, + string AuthoredValue, + string? ParseWorkingDirectory); + /// One successful projection of a parser-owned syntax tree. internal sealed class ShellProjectionResult { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/120_cd_trailing_forward_slash.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/120_cd_trailing_forward_slash.json index f8e82d0..425d895 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/120_cd_trailing_forward_slash.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/120_cd_trailing_forward_slash.json @@ -8,7 +8,7 @@ "operator": "None", "verb": ["cd"], "args": [ - { "raw": "dir/", "kind": "Literal", "isPath": true, "resolved": "/work/dir" } + { "raw": "dir/", "kind": "Literal", "isPath": true, "resolved": "__NULL__" } ], "redirects": [], "isSubshell": false, @@ -16,5 +16,5 @@ } ] }, - "notes": "v0.1.2 / B3 regression guard: the LooksLikePath heuristic now treats a lone trailing backslash as a non-signal, but a trailing forward slash is a meaningful bash directory hint (`cd dir/` is common). Confirm `dir/` still resolves as a path." + "notes": "A trailing slash remains a path signal, but the bare relative cd operand may be searched through CDPATH or cdable_vars and therefore cannot publish an exact target." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/194_v03_substitution_cwd_isolation.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/194_v03_substitution_cwd_isolation.json index 3be6c9a..ae58c7f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/194_v03_substitution_cwd_isolation.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/194_v03_substitution_cwd_isolation.json @@ -27,10 +27,10 @@ ], "args": [ { - "raw": "/tmp", - "kind": "Literal", - "isPath": true, - "resolved": "/tmp", + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", "isFlag": false, "isCwdAttribution": true } @@ -307,5 +307,5 @@ } ] }, - "notes": "Directory changes inside a substitution do not leak to the following outer command." + "notes": "Directory changes inside a substitution do not leak; semicolon continuation joins cd success and failure, so inner pwd receives dynamic cwd attribution." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/217_v03_cd_sequence_failure_join.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/217_v03_cd_sequence_failure_join.json new file mode 100644 index 0000000..7f77e0e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/217_v03_cd_sequence_failure_join.json @@ -0,0 +1,27 @@ +{ + "name": "v0.3 cd sequence failure joins cwd", + "input": "cd /maybe; cat relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe", "kind": "Literal", "isPath": true, "resolved": "/maybe" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["cat"], + "args": [ + { "raw": "relative.txt", "kind": "Literal", "isPath": true }, + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "A literal cd can fail; semicolon continuation therefore clears false relative resolution and retains the dynamic cwd marker." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/218_v03_lastpipe_cwd_join.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/218_v03_lastpipe_cwd_join.json new file mode 100644 index 0000000..18efe81 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/218_v03_lastpipe_cwd_join.json @@ -0,0 +1,32 @@ +{ + "name": "v0.3 last pipeline stage cwd joins shell-option outcomes", + "input": "printf x | cd /tmp; pwd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["printf", "x"], + "args": [], + "redirects": [] + }, + { + "operator": "Pipe", + "verb": ["cd"], + "args": [ + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["pwd"], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "Without proved lastpipe and pipefail options, the last stage may or may not mutate parent cwd and the following command fails closed." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/219_v03_wrapper_inherits_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/219_v03_wrapper_inherits_cwd.json new file mode 100644 index 0000000..46c5aa9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/219_v03_wrapper_inherits_cwd.json @@ -0,0 +1,44 @@ +{ + "name": "v0.3 decoded wrapper inherits invocation cwd and isolates exit", + "input": "cd /outer && bash -c 'cd /inner && pwd' && pwd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/outer", "kind": "Literal", "isPath": true, "resolved": "/outer" } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cd"], + "args": [ + { "raw": "/inner", "kind": "Literal", "isPath": true, "resolved": "/inner" } + ], + "redirects": [], + "isCommandStringWrapped": true + }, + { + "operator": "AndIf", + "verb": ["pwd"], + "args": [ + { "raw": "/inner", "kind": "Literal", "isPath": true, "resolved": "/inner", "isCwdAttribution": true } + ], + "redirects": [], + "isCommandStringWrapped": true + }, + { + "operator": "AndIf", + "verb": ["pwd"], + "args": [ + { "raw": "/outer", "kind": "Literal", "isPath": true, "resolved": "/outer", "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "The decoded child starts in /outer, changes only its own cwd to /inner, and restores /outer for the outer continuation." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/220_v03_redirect_after_cwd_join.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/220_v03_redirect_after_cwd_join.json new file mode 100644 index 0000000..89c5776 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/220_v03_redirect_after_cwd_join.json @@ -0,0 +1,28 @@ +{ + "name": "v0.3 redirect target after cwd join fails closed", + "input": "cd /maybe; printf x > relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe", "kind": "Literal", "isPath": true, "resolved": "/maybe" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["printf", "x"], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [ + { "direction": "Out", "target": "relative.txt", "isDynamicSkip": true } + ] + } + ] + }, + "notes": "A relative redirect cannot retain one parse-order path after cd success and failure join; both redirect and cwd attribution stay fail closed." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/221_v03_parent_traversal_after_cwd_join.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/221_v03_parent_traversal_after_cwd_join.json new file mode 100644 index 0000000..5406250 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/221_v03_parent_traversal_after_cwd_join.json @@ -0,0 +1,27 @@ +{ + "name": "v0.3 parent traversal after cwd join fails closed", + "input": "cd /maybe/deep; cat ../secret.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe/deep", "kind": "Literal", "isPath": true, "resolved": "/maybe/deep" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["cat"], + "args": [ + { "raw": "../secret.txt", "kind": "Literal", "isPath": true }, + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "Parent traversal can escape the parse-time cwd prefix; outcome joining clears the false exact path instead of attempting string-prefix rebasing." +} 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 new file mode 100644 index 0000000..bbf9a19 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/222_v03_nested_dispatch_cwd_mutator.json @@ -0,0 +1,27 @@ +{ + "name": "v0.3 nested static dispatch mutator fails closed", + "input": "command builtin cd /outer && cat relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["command", "builtin", "cd"], + "args": [ + { "raw": "/outer", "kind": "Literal", "isPath": true, "resolved": "/outer" } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cat"], + "args": [ + { "raw": "relative.txt", "kind": "Literal", "isPath": true }, + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "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." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/223_v03_cdpath_bare_relative.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/223_v03_cdpath_bare_relative.json new file mode 100644 index 0000000..c3bf64c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/223_v03_cdpath_bare_relative.json @@ -0,0 +1,35 @@ +{ + "name": "v0.3 bare relative cd honors unknown CDPATH semantics", + "input": "cd /a && cd sub && pwd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["cd"], + "args": [ + { "raw": "sub", "kind": "Literal", "isPath": true }, + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [] + }, + { + "operator": "AndIf", + "verb": ["pwd"], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [] + } + ] + }, + "notes": "Without an environment contract for CDPATH and cdable_vars, a bare relative cd target and its successful resulting cwd remain unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/224_v03_redirect_only_preserves_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/224_v03_redirect_only_preserves_cwd.json new file mode 100644 index 0000000..54aed3f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/224_v03_redirect_only_preserves_cwd.json @@ -0,0 +1,26 @@ +{ + "name": "v0.3 redirect-only command preserves cwd", + "input": "> out.txt; cat relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.txt", "isDynamicSkip": false } + ] + }, + { + "operator": "Sequence", + "verb": ["cat"], + "args": [ + { "raw": "relative.txt", "kind": "Literal", "isPath": true, "resolved": "/work/relative.txt" } + ], + "redirects": [] + } + ] + }, + "notes": "A supported redirect-only simple command cannot mutate cwd, so its continuation retains exact path resolution and avoids an unnecessary approval prompt." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/225_v03_substitution_failure_partition_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/225_v03_substitution_failure_partition_cwd.json new file mode 100644 index 0000000..cca34b1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/225_v03_substitution_failure_partition_cwd.json @@ -0,0 +1,34 @@ +{ + "name": "v0.3 substitution inherits cd failure-partition cwd", + "input": "cd /maybe || echo \"$(cat rel.txt)\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe", "kind": "Literal", "isPath": true, "resolved": "/maybe" } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": ["cat"], + "args": [ + { "raw": "rel.txt", "kind": "Literal", "isPath": true, "resolved": "/work/rel.txt" } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["echo"], + "args": [ + { "raw": "\"$(cat rel.txt)\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + } + ] + }, + "notes": "The right side of || executes only when cd fails, so its isolated substitution inherits /work rather than the parser's speculative /maybe success cwd." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/226_v03_transformed_path_failure_partition.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/226_v03_transformed_path_failure_partition.json new file mode 100644 index 0000000..d010f1a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/226_v03_transformed_path_failure_partition.json @@ -0,0 +1,28 @@ +{ + "name": "v0.3 transformed path uses failure-partition cwd", + "input": "cd /maybe/deep || curl -d @../request.json https://example.invalid", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe/deep", "kind": "Literal", "isPath": true, "resolved": "/maybe/deep" } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["curl"], + "args": [ + { "raw": "-d", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "@../request.json", "kind": "Literal", "isPath": true, "resolved": "/request.json" }, + { "raw": "https://example.invalid", "kind": "Literal", "isPath": false } + ], + "redirects": [] + } + ] + }, + "notes": "curl strips the authored @ marker before resolving its data-file operand. The retained logical operand ../request.json is rebased under the cd failure partition rather than reconstructed from the public Arg spelling." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/227_v03_quoted_redirect_dynamic_spelling.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/227_v03_quoted_redirect_dynamic_spelling.json new file mode 100644 index 0000000..911e8b8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/227_v03_quoted_redirect_dynamic_spelling.json @@ -0,0 +1,28 @@ +{ + "name": "v0.3 dynamic redirect retains authored quoted spelling", + "input": "cd /maybe; printf x > \"relative file.txt\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/maybe", "kind": "Literal", "isPath": true, "resolved": "/maybe" } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": ["printf", "x"], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [ + { "direction": "Out", "target": "\"relative file.txt\"", "isDynamicSkip": true } + ] + } + ] + }, + "notes": "When cwd outcomes join, the redirect fails closed while preserving the legacy target-only authored spelling, including quotes." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/228_v03_failure_partition_promotes_argument_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/228_v03_failure_partition_promotes_argument_path.json new file mode 100644 index 0000000..15e766f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/228_v03_failure_partition_promotes_argument_path.json @@ -0,0 +1,26 @@ +{ + "name": "v0.3 exact failure partition promotes a cwd-blocked argument path", + "input": "cd \"$TARGET\" || cat rel.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "\"$TARGET\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["cat"], + "args": [ + { "raw": "rel.txt", "kind": "Literal", "isPath": true, "resolved": "/work/rel.txt" } + ], + "redirects": [] + } + ] + }, + "notes": "The cd failure partition proves the original /work cwd. Retained path-slot provenance permits exact promotion even though parse-order attribution initially made the cat operand DynamicSkip." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/229_v03_failure_partition_promotes_redirect_path.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/229_v03_failure_partition_promotes_redirect_path.json new file mode 100644 index 0000000..1639376 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/229_v03_failure_partition_promotes_redirect_path.json @@ -0,0 +1,26 @@ +{ + "name": "v0.3 exact failure partition promotes a cwd-blocked redirect path", + "input": "cd \"$TARGET\" || printf x > out.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "\"$TARGET\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + }, + { + "operator": "OrIf", + "verb": ["printf", "x"], + "args": [], + "redirects": [ + { "direction": "Out", "target": "/work/out.txt", "isDynamicSkip": false } + ] + } + ] + }, + "notes": "The cd failure partition proves the original /work cwd, so a redirect that was dynamic only because of parse-order cwd becomes exact." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json index 747be9d..28bb98b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json @@ -18,7 +18,7 @@ "operator": "AndIf", "verb": ["cd"], "args": [ - { "raw": "sub", "kind": "Literal", "isPath": true, "resolved": "/a/sub" }, + { "raw": "sub", "kind": "Literal", "isPath": true, "resolved": "__NULL__" }, { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } ], "redirects": [], @@ -27,5 +27,5 @@ } ] }, - "notes": "Relative cd target resolves against the active attribution; PR 5 swaps in WorkingDirectory=/a for the second cd." + "notes": "A bare relative cd operand may be resolved through CDPATH or cdable_vars. Its target and resulting cwd therefore remain unknown even though the command inherits /a on entry." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json index 17d332f..5337aae 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json @@ -17,12 +17,14 @@ { "operator": "AndIf", "verb": ["cmd"], - "args": [], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isCommandStringWrapped": false } ] }, - "notes": "`cd -` swaps to previous dir; we don't track that, so cd has only the dash flag (no non-flag positional). Attribution stays unset; cmd receives no synthetic arg." + "notes": "`cd -` swaps to an untracked previous directory, so a successful continuation receives the fail-closed dynamic cwd marker." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json index b6ecff8..44e49c3 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json @@ -1,5 +1,5 @@ { - "name": "cd-attribution: pushd /target && cmd (pushd does not propagate)", + "name": "cwd-attribution: pushd /target && cmd marks cwd unknown", "input": "pushd /target && cmd", "expected": { "isUnparseable": false, @@ -17,12 +17,14 @@ { "operator": "AndIf", "verb": ["cmd"], - "args": [], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isCommandStringWrapped": false } ] }, - "notes": "Locked interpretation #5: pushd parses as a CwdVerb (target IsPath=true) but doesn't propagate attribution. cmd has no synthetic arg." + "notes": "pushd mutates the directory stack and cwd, but its resulting directory is not modeled; the following command retains a fail-closed dynamic attribution marker." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json index a53d761..4711a99 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json @@ -1,5 +1,5 @@ { - "name": "cd-attribution: popd && cmd (popd does not propagate)", + "name": "cwd-attribution: popd && cmd marks cwd unknown", "input": "popd && cmd", "expected": { "isUnparseable": false, @@ -15,12 +15,14 @@ { "operator": "AndIf", "verb": ["cmd"], - "args": [], + "args": [ + { "raw": "\u003Cdynamic-cwd\u003E", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isCommandStringWrapped": false } ] }, - "notes": "Locked interpretation #5: popd parses but doesn't propagate. cmd has no attribution arg." + "notes": "popd can change cwd to an unmodeled directory-stack entry; the following command retains a fail-closed dynamic marker." } diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index f5481af..6c253cc 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -198,7 +198,7 @@ { "id": "bash-joined-cwd-compatibility-fails-closed", "concern": "Joined cwd clears false path resolution but preserves the dynamic attribution signal", - "compatibilityProjectionLanded": false, + "compatibilityProjectionLanded": true, "input": "cd /maybe; cat relative.txt", "current": { "isUnparseable": false, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs index 47776d4..147c3d1 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs @@ -1031,11 +1031,8 @@ public void Cd_relative_path_args_resolve_under_attributed_cwd() } [Fact] - public void Pushd_parses_as_cwd_verb_but_does_not_propagate() + public void Pushd_marks_following_cwd_unknown() { - // Locked interpretation #5: only cd/chdir propagate attribution. - // pushd parses as a CwdVerb (its first positional is path-classified) - // but the next clause receives NO synthetic attribution arg. var result = Parse("pushd /target && cmd"); Assert.False(result.IsUnparseable); Assert.Equal(2, result.Clauses.Count); @@ -1043,8 +1040,11 @@ public void Pushd_parses_as_cwd_verb_but_does_not_propagate() Assert.Equal("/target", result.Clauses[0].Args[0].Resolved); Assert.True(result.Clauses[0].Args[0].IsPath); - // The cmd clause has no synthetic attribution arg. - Assert.Empty(result.Clauses[1].Args); + var attribution = Assert.Single(result.Clauses[1].Args); + Assert.True(attribution.IsCwdAttribution); + Assert.Equal("", attribution.Raw); + Assert.Equal(ArgKind.DynamicSkip, attribution.Kind); + Assert.Null(attribution.Resolved); } [Fact] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs index 2cf307d..d715962 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashStructuralProjectionTests.cs @@ -455,11 +455,265 @@ public void Substitution_state_is_sequential_inside_and_isolated_from_outer_comm }, result.Clauses.Select(clause => clause.Operator)); Assert.Contains(result.Clauses[1].Args, - argument => argument.IsCwdAttribution && argument.Resolved == "/tmp"); + argument => argument.IsCwdAttribution && + argument.Kind == ArgKind.DynamicSkip && + argument.Resolved is null); Assert.DoesNotContain(result.Clauses[2].Args, argument => argument.IsCwdAttribution); Assert.Equal("/work/relative.txt", Assert.Single(result.Clauses[3].Args).Resolved); } + [Fact] + public void Cwd_flow_uses_command_outcomes_instead_of_parse_order() + { + var sequence = Parse("cd /maybe; cat relative.txt"); + var andIf = Parse("cd /maybe && cat relative.txt"); + var orIf = Parse("cd /maybe || cat relative.txt"); + + Assert.Equal(ShellValueDomainKind.Unknown, sequence.Commands[1].WorkingDirectory.Kind); + Assert.Null(sequence.Clauses[1].Args[0].Resolved); + Assert.Null(sequence.Clauses[1].Elements[1].Resolved); + Assert.Contains(sequence.Clauses[1].Args, argument => + argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + + Assert.Equal("/maybe", Assert.Single(andIf.Commands[1].WorkingDirectory.Values)); + Assert.Equal("/maybe/relative.txt", andIf.Clauses[1].Args[0].Resolved); + + Assert.Equal("/work", Assert.Single(orIf.Commands[1].WorkingDirectory.Values)); + Assert.Equal("/work/relative.txt", orIf.Clauses[1].Args[0].Resolved); + Assert.DoesNotContain(orIf.Clauses[1].Args, argument => argument.IsCwdAttribution); + + var absolute = Parse("cd /outer && cat /work relative.txt"); + Assert.Equal("/work", absolute.Clauses[1].Args[0].Resolved); + Assert.Equal("/outer/relative.txt", absolute.Clauses[1].Args[1].Resolved); + } + + [Fact] + public void Cwd_rebasing_uses_resolver_provenance() + { + var parentTraversal = Parse("cd /maybe/deep; cat ../secret.txt"); + var homeExpansion = Parse("cd /home/test || cat $HOME/secret.txt"); + var escapedRelative = Parse(@"cd /maybe; cat \file.txt"); + + Assert.Null(parentTraversal.Clauses[1].Args[0].Resolved); + Assert.Null(parentTraversal.Clauses[1].Elements[1].Resolved); + Assert.Equal("/home/test/secret.txt", homeExpansion.Clauses[1].Args[0].Resolved); + Assert.Equal("/home/test/secret.txt", homeExpansion.Clauses[1].Elements[1].Resolved); + Assert.Null(escapedRelative.Clauses[1].Args[0].Resolved); + Assert.Null(escapedRelative.Clauses[1].Elements[1].Resolved); + } + + [Theory] + [InlineData("cd /maybe/deep || curl -d @../request.json https://example.invalid", "/request.json")] + [InlineData("cd /maybe/deep || curl --data=@../request.json https://example.invalid", "/request.json")] + [InlineData("cd /outer/deep && bash -c 'curl -d @../request.json https://example.invalid'", "/outer/request.json")] + public void Cwd_rebasing_preserves_per_verb_logical_path_operands( + string source, + string expected) + { + var result = Parse(source); + var curl = Assert.Single(result.Commands, command => CommandVerb(command) == "curl"); + + Assert.Equal(expected, Assert.Single(curl.Clause.Args, + argument => argument.IsPath).Resolved); + Assert.Equal(expected, Assert.Single(curl.Clause.Elements, + element => element.IsPath).Resolved); + } + + [Fact] + public void Exact_failure_partition_promotes_paths_blocked_only_by_parse_order_cwd() + { + var argumentOr = Parse("cd \"$TARGET\" || cat rel.txt"); + var argumentAnd = Parse("cd \"$TARGET\" && cat rel.txt"); + var redirectOr = Parse("cd \"$TARGET\" || printf x > out.txt"); + var redirectAnd = Parse("cd \"$TARGET\" && printf x > out.txt"); + var transformedOr = Parse( + "cd \"$TARGET\" || curl --data=@payload.json https://example.invalid"); + + var exactArgument = Assert.Single(argumentOr.Clauses[1].Args); + Assert.Equal(ArgKind.Literal, exactArgument.Kind); + Assert.True(exactArgument.IsPath); + Assert.Equal("/work/rel.txt", exactArgument.Resolved); + Assert.Equal("/work/rel.txt", argumentOr.Clauses[1].Elements[1].Resolved); + + Assert.Null(argumentAnd.Clauses[1].Args[0].Resolved); + Assert.Equal(ArgKind.DynamicSkip, argumentAnd.Clauses[1].Args[0].Kind); + + var exactRedirect = Assert.Single(redirectOr.Clauses[1].Redirects); + Assert.False(exactRedirect.IsDynamicSkip); + Assert.Equal("/work/out.txt", exactRedirect.Target); + var exactRedirectElement = Assert.Single(redirectOr.Clauses[1].Elements, + element => element.Role == ClauseElementRole.Redirect); + Assert.Equal(ArgKind.Literal, exactRedirectElement.Kind); + Assert.True(exactRedirectElement.IsPath); + Assert.Equal("/work/out.txt", exactRedirectElement.Resolved); + + Assert.True(Assert.Single(redirectAnd.Clauses[1].Redirects).IsDynamicSkip); + + Assert.Equal("/work/payload.json", Assert.Single(transformedOr.Clauses[1].Args, + argument => argument.IsPath).Resolved); + Assert.Equal("/work/payload.json", Assert.Single(transformedOr.Clauses[1].Elements, + element => element.IsPath).Resolved); + } + + [Theory] + [InlineData("command command cd /outer && cat relative.txt")] + [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) + { + 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); + } + + [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) + { + 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); + } + + [Fact] + public void Cd_operand_forms_only_publish_exact_cwd_when_lexically_safe() + { + var cdPath = Parse("cd /a && cd sub && pwd"); + var explicitRelative = Parse("cd /a && cd ./sub && pwd"); + var physical = Parse("cd -P /a && pwd"); + var dashOperand = Parse("cd -- -foo && pwd"); + + Assert.Null(cdPath.Clauses[1].Args[0].Resolved); + Assert.Equal(ShellValueDomainKind.Unknown, cdPath.Commands[2].WorkingDirectory.Kind); + Assert.Equal("/a/sub", Assert.Single(explicitRelative.Commands[2].WorkingDirectory.Values)); + Assert.Equal(ShellValueDomainKind.Unknown, physical.Commands[1].WorkingDirectory.Kind); + Assert.Equal(ShellValueDomainKind.Unknown, dashOperand.Commands[1].WorkingDirectory.Kind); + } + + [Theory] + [InlineData("cd ~ && pwd", "/home/test")] + [InlineData("cd $HOME && pwd", "/home/test")] + [InlineData("cd . && pwd", "/work")] + [InlineData("cd .. && pwd", "/")] + public void Cd_operands_that_bypass_search_retain_exact_cwd( + string source, + string expected) + { + var result = Parse(source); + + Assert.Equal(expected, Assert.Single(result.Commands[1].WorkingDirectory.Values)); + Assert.Equal(expected, Assert.Single(result.Clauses[1].Args).Resolved); + } + + [Fact] + public void Cd_extended_attribute_option_fails_closed() + { + var result = Parse("cd -@ /a && pwd"); + + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[1].WorkingDirectory.Kind); + Assert.Contains(result.Clauses[1].Args, argument => + argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + } + + [Theory] + [InlineData("> out.txt; cat relative.txt")] + [InlineData("> out.txt && cat relative.txt")] + [InlineData("> out.txt || cat relative.txt")] + public void Redirect_only_commands_preserve_cwd_state(string source) + { + var result = Parse(source); + + var cat = Assert.Single(result.Commands, command => CommandVerb(command) == "cat"); + Assert.Equal("/work", Assert.Single(cat.WorkingDirectory.Values)); + Assert.Equal("/work/relative.txt", result.Clauses[1].Args[0].Resolved); + Assert.DoesNotContain(result.Clauses[1].Args, argument => + argument.IsCwdAttribution); + } + + [Fact] + public void Redirect_paths_follow_the_same_outcome_sensitive_cwd() + { + var sequence = Parse("cd /maybe; printf x > relative.txt"); + var andIf = Parse("cd /maybe && printf x > relative.txt"); + + var unknownRedirect = Assert.Single(sequence.Clauses[1].Redirects); + Assert.True(unknownRedirect.IsDynamicSkip); + Assert.Equal("relative.txt", unknownRedirect.Target); + Assert.Null(Assert.Single(sequence.Clauses[1].Elements, + element => element.Role == ClauseElementRole.Redirect).Resolved); + + Assert.Equal("/maybe/relative.txt", + Assert.Single(andIf.Clauses[1].Redirects).Target); + + var quoted = Parse("cd /maybe; printf x > \"relative file.txt\""); + var quotedRedirect = Assert.Single(quoted.Clauses[1].Redirects); + Assert.True(quotedRedirect.IsDynamicSkip); + Assert.Equal("\"relative file.txt\"", quotedRedirect.Target); + } + + [Fact] + public void Pipeline_options_join_last_stage_state_conservatively() + { + var result = Parse("printf x | cd /tmp; pwd"); + + Assert.All(result.Commands.Take(2), command => + Assert.Equal("/work", Assert.Single(command.WorkingDirectory.Values))); + Assert.Equal(ShellValueDomainKind.Unknown, result.Commands[2].WorkingDirectory.Kind); + Assert.Contains(result.Clauses[2].Args, argument => + argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + } + + [Fact] + public void Decoded_wrapper_inherits_invocation_cwd_and_isolates_exit_state() + { + var result = Parse("cd /outer && bash -c 'cd /inner && pwd' && pwd"); + + Assert.Equal(new[] { "/work", "/outer", "/inner", "/outer" }, + result.Commands.Select(command => + Assert.Single(command.WorkingDirectory.Values))); + Assert.DoesNotContain(result.Clauses[1].Args, argument => + argument.IsCwdAttribution && argument.Resolved == "/outer"); + Assert.Contains(result.Clauses[2].Args, argument => + argument.IsCwdAttribution && argument.Resolved == "/inner"); + Assert.Contains(result.Clauses[3].Args, argument => + argument.IsCwdAttribution && argument.Resolved == "/outer"); + } + + [Theory] + [InlineData("cd /maybe; echo \"$(cat rel.txt)\"")] + [InlineData("pushd /maybe && echo \"$(cat rel.txt)\"")] + [InlineData("pushd /maybe && bash -c 'cat rel.txt'")] + public void Isolated_child_retains_dynamic_cwd_signal(string source) + { + var result = Parse(source); + var cat = Assert.Single(result.Commands, command => CommandVerb(command) == "cat"); + + Assert.Equal(ShellValueDomainKind.Unknown, cat.WorkingDirectory.Kind); + Assert.Null(Assert.Single(cat.Clause.Args, + argument => !argument.IsCwdAttribution).Resolved); + Assert.Contains(cat.Clause.Args, argument => + argument.IsCwdAttribution && argument.Kind == ArgKind.DynamicSkip); + } + + [Fact] + public void Substitution_on_cd_failure_uses_failure_partition_cwd() + { + var result = Parse("cd /maybe || echo \"$(cat rel.txt)\""); + var cat = Assert.Single(result.Commands, command => CommandVerb(command) == "cat"); + + Assert.Equal("/work", Assert.Single(cat.WorkingDirectory.Values)); + Assert.Equal("/work/rel.txt", Assert.Single(cat.Clause.Args).Resolved); + Assert.DoesNotContain(cat.Clause.Args, argument => argument.IsCwdAttribution); + } + [Fact] public void Substitution_in_later_list_item_does_not_invent_a_compound_operator() { @@ -708,7 +962,9 @@ public void Heredoc_substitution_cwd_is_isolated_from_its_consumer_and_continuat Assert.Equal(new[] { "cd", "pwd", "cat", "cat" }, result.Commands.Select(CommandVerb)); Assert.Contains(result.Clauses[1].Args, - argument => argument.IsCwdAttribution && argument.Resolved == "/tmp"); + argument => argument.IsCwdAttribution && + argument.Kind == ArgKind.DynamicSkip && + argument.Resolved is null); Assert.DoesNotContain(result.Clauses[2].Args, argument => argument.IsCwdAttribution); Assert.Equal("/work/relative.txt", Assert.Single(result.Clauses[3].Args).Resolved);