diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1fcc015..e9ada83 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -207,6 +207,13 @@ priorities. unchanged. It introduces no new compatibility correction; the shell-oracle-proved corrections remain the ones documented in the preceding provenance item. +- [x] Audit duplicated Bash and PowerShell path-normalization helpers. Share + only the identical string-level join and separator-normalization rules; + keep root detection, drive-relative handling, full segment + normalization, provider/PSDrive behavior, and resolver failure policy in + their shell-specific implementations. Direct boundary tests pin the + extracted helpers, while the complete resolver and corpus suites prove + the refactor leaves both compatibility projections unchanged. - [ ] Add the structural and command-occurrence projections for the existing grammar before enabling any control-flow construct. - [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 8c5bf69..5652f2a 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -17,8 +17,8 @@ - [x] 2.2 Add paired Bash and PowerShell shell-oracle regressions for standalone escapes, adjacent escaped values, all-static mixed quoting, within-token escapes, genuine literal-plus-expandable values, adjacent and wildcard redirect targets, runtime special/positional/numeric/Unicode variables, incomplete and escaped-literal braced interpolation, Bash provider-looking literals, and PowerShell native-versus-cmdlet, Path-versus-LiteralPath, and redirect-context divergence; require exact compatibility path results when every fragment, binding fact, and required resolver fact is exact, otherwise fail closed. - [x] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance. - [x] 2.4 Prove raw spelling, decoded logical values, source spans, and unaffected classifications remain unchanged; document each oracle-proved false exact, `Glob`, `Tilde`, provider, path, or avoidable `DynamicSkip` compatibility correction. -- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics. -- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation. +- [x] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics. +- [x] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation. ## 3. Structural and Projection Skeleton diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs index ab0e8f1..ad111b8 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs @@ -165,7 +165,7 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( // Drop the leading `~` and join the rest (which starts with // '/' or '\') to home. var rest = working.Substring(2); // skip "~/" or "~\\" - working = JoinPath(home, rest); + working = ShellPathNormalization.Join(home, rest); } hadTilde = true; @@ -446,7 +446,7 @@ private static string GetHomeDirectory(BashParserOptions options) // Lazy fallback. SPEC §2 / §8: defaults to UserProfile. May be the // empty string in pathological environments — callers tolerate that - // because JoinPath / Path.GetFullPath fall back accordingly. + // because downstream path joining and normalization handle it. return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); } @@ -600,32 +600,6 @@ private static bool IsIdentifierContinuation(char c) => private static bool IsAsciiLetter(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); - /// - /// Combine a base directory with a relative or rooted sub-path using - /// bash semantics — forward slashes everywhere, regardless of host OS. - /// Strips a single leading separator from the sub-path so the combine - /// doesn't treat the sub-path as rooted. - /// - private static string JoinPath(string baseDir, string sub) - { - if (string.IsNullOrEmpty(sub)) - { - return baseDir; - } - - // Sub-paths may be rooted (e.g. `~/rest` produces `/rest` after - // tilde expansion) — strip exactly one leading separator before - // combining so we don't lose baseDir. - var s = sub; - if (s.Length > 0 && (s[0] == '/' || s[0] == '\\')) - { - s = s.Substring(1); - } - - // Always forward-slash, always bash semantics. - return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/'); - } - /// /// Resolve to an absolute path against the /// supplied options. Returns null on resolution failure (SPEC §8 step 6). @@ -647,7 +621,7 @@ private static string JoinPath(string baseDir, string sub) string combined; if (IsRootedPath(token)) { - combined = NormalizeToForwardSlashes(token); + combined = ShellPathNormalization.NormalizeSeparators(token); } else if (workingDirectoryUnknown) { @@ -667,7 +641,7 @@ private static string JoinPath(string baseDir, string sub) // rather than guess. return null; } - combined = JoinPath(wd, token); + combined = ShellPathNormalization.Join(wd, token); } return NormalizePath(combined); @@ -686,22 +660,6 @@ private static string JoinPath(string baseDir, string sub) } } - /// - /// Normalize backslashes to forward slashes; preserve bash semantics - /// for `\\server\share` UNC paths by collapsing the leading `\\` to a - /// single `//`. (UNC paths are rare in bash but the heuristic preserves - /// them in a recognizable form for consumers.) - /// - private static string NormalizeToForwardSlashes(string token) - { - if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\') - { - // UNC: \\server\share -> //server/share - return "//" + token.Substring(2).Replace('\\', '/'); - } - return token.Replace('\\', '/'); - } - /// /// Bash-style path normalization: collapse `.`/`..` segments, deduplicate /// adjacent slashes, preserve a leading `/` (or `//` for UNC), use diff --git a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs index 093bd7a..5ae830b 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs @@ -78,7 +78,9 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( } var home = GetHomeDirectory(options); - working = working.Length == 1 ? home : JoinPath(home, working.Substring(2)); + working = working.Length == 1 + ? home + : ShellPathNormalization.Join(home, working.Substring(2)); hadHomeish = true; } @@ -548,22 +550,6 @@ private static bool IsIdentifierContinuation(char c) => private static bool IsAsciiLetter(char c) => (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z'); - private static string JoinPath(string baseDir, string sub) - { - if (string.IsNullOrEmpty(sub)) - { - return baseDir; - } - - var s = sub; - if (s.Length > 0 && (s[0] == '/' || s[0] == '\\')) - { - s = s.Substring(1); - } - - return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/'); - } - /// /// Resolve to a normalized absolute path. /// Returns null on failure (SPEC.POWERSHELL.md §8). Drive-qualified @@ -583,7 +569,7 @@ private static string JoinPath(string baseDir, string sub) string combined; if (IsRootedPath(token)) { - combined = NormalizeToForwardSlashes(token); + combined = ShellPathNormalization.NormalizeSeparators(token); } else if (workingDirectoryUnknown) { @@ -597,7 +583,7 @@ private static string JoinPath(string baseDir, string sub) return null; } - combined = JoinPath(wd, token); + combined = ShellPathNormalization.Join(wd, token); } return NormalizePath(combined); @@ -616,16 +602,6 @@ private static string JoinPath(string baseDir, string sub) } } - private static string NormalizeToForwardSlashes(string token) - { - if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\') - { - return "//" + token.Substring(2).Replace('\\', '/'); - } - - return token.Replace('\\', '/'); - } - private static string NormalizePath(string path) { if (string.IsNullOrEmpty(path)) diff --git a/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs b/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs new file mode 100644 index 0000000..55d9540 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Resolving/ShellPathNormalization.cs @@ -0,0 +1,40 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +namespace ShellSyntaxTree.Internal.Resolving; + +/// +/// String-only path operations whose behavior is identical for the Bash and +/// PowerShell resolvers. Root detection, drive handling, and segment +/// normalization remain shell-specific. +/// +internal static class ShellPathNormalization +{ + internal static string Join(string baseDirectory, string subpath) + { + if (string.IsNullOrEmpty(subpath)) + { + return baseDirectory; + } + + var relative = subpath; + if (relative.Length > 0 && (relative[0] == '/' || relative[0] == '\\')) + { + relative = relative.Substring(1); + } + + return baseDirectory.TrimEnd('/', '\\') + "/" + relative.Replace('\\', '/'); + } + + internal static string NormalizeSeparators(string path) + { + if (path.Length >= 2 && path[0] == '\\' && path[1] == '\\') + { + return "//" + path.Substring(2).Replace('\\', '/'); + } + + return path.Replace('\\', '/'); + } +} diff --git a/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs b/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs new file mode 100644 index 0000000..095ba1f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Resolving/ShellPathNormalizationTests.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using ShellSyntaxTree.Internal.Resolving; +using Xunit; + +namespace ShellSyntaxTree.Tests.Resolving; + +public class ShellPathNormalizationTests +{ + [Theory] + [InlineData("/base", "", "/base")] + [InlineData("/base/", "/child\\file", "/base/child/file")] + [InlineData("C:\\base\\", "child\\file", "C:\\base/child/file")] + [InlineData("/base", "//child", "/base//child")] + public void Join_preserves_the_existing_shared_string_semantics( + string baseDirectory, + string subpath, + string expected) + { + Assert.Equal(expected, ShellPathNormalization.Join(baseDirectory, subpath)); + } + + [Theory] + [InlineData("\\\\server\\share\\file", "//server/share/file")] + [InlineData("\\root\\file", "/root/file")] + [InlineData("/root\\file", "/root/file")] + public void NormalizeSeparators_preserves_UNC_and_normalizes_backslashes( + string path, + string expected) + { + Assert.Equal(expected, ShellPathNormalization.NormalizeSeparators(path)); + } +}