Skip to content

Commit 8ba6d00

Browse files
Merge pull request #82 from Aaronontheweb/feature/shared-path-normalization-audit
Refactor shared path normalization
2 parents 0a2ffec + ab23826 commit 8ba6d00

6 files changed

Lines changed: 94 additions & 77 deletions

File tree

IMPLEMENTATION_PLAN.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,13 @@ priorities.
207207
unchanged. It introduces no new compatibility correction; the
208208
shell-oracle-proved corrections remain the ones documented in the
209209
preceding provenance item.
210+
- [x] Audit duplicated Bash and PowerShell path-normalization helpers. Share
211+
only the identical string-level join and separator-normalization rules;
212+
keep root detection, drive-relative handling, full segment
213+
normalization, provider/PSDrive behavior, and resolver failure policy in
214+
their shell-specific implementations. Direct boundary tests pin the
215+
extracted helpers, while the complete resolver and corpus suites prove
216+
the refactor leaves both compatibility projections unchanged.
210217
- [ ] Add the structural and command-occurrence projections for the existing
211218
grammar before enabling any control-flow construct.
212219
- [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two

openspec/changes/v0-3-structured-shell-analysis/tasks.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
- [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.
1818
- [x] 2.3 Implement issue #69's shell-neutral native argument-fragment classifier with explicit Bash and PowerShell adapters that preserve the new provenance.
1919
- [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.
20-
- [ ] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
21-
- [ ] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
20+
- [x] 2.5 Audit duplicated Bash and PowerShell path-normalization helpers and extract only rules with identical shell semantics.
21+
- [x] 2.6 Run Release build, full tests, header verification, and the adversarial security corpus for the completed preparation.
2222

2323
## 3. Structural and Projection Skeleton
2424

src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs

Lines changed: 4 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -165,7 +165,7 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
165165
// Drop the leading `~` and join the rest (which starts with
166166
// '/' or '\') to home.
167167
var rest = working.Substring(2); // skip "~/" or "~\\"
168-
working = JoinPath(home, rest);
168+
working = ShellPathNormalization.Join(home, rest);
169169
}
170170

171171
hadTilde = true;
@@ -446,7 +446,7 @@ private static string GetHomeDirectory(BashParserOptions options)
446446

447447
// Lazy fallback. SPEC §2 / §8: defaults to UserProfile. May be the
448448
// empty string in pathological environments — callers tolerate that
449-
// because JoinPath / Path.GetFullPath fall back accordingly.
449+
// because downstream path joining and normalization handle it.
450450
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
451451
}
452452

@@ -600,32 +600,6 @@ private static bool IsIdentifierContinuation(char c) =>
600600
private static bool IsAsciiLetter(char c) =>
601601
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
602602

603-
/// <summary>
604-
/// Combine a base directory with a relative or rooted sub-path using
605-
/// bash semantics — forward slashes everywhere, regardless of host OS.
606-
/// Strips a single leading separator from the sub-path so the combine
607-
/// doesn't treat the sub-path as rooted.
608-
/// </summary>
609-
private static string JoinPath(string baseDir, string sub)
610-
{
611-
if (string.IsNullOrEmpty(sub))
612-
{
613-
return baseDir;
614-
}
615-
616-
// Sub-paths may be rooted (e.g. `~/rest` produces `/rest` after
617-
// tilde expansion) — strip exactly one leading separator before
618-
// combining so we don't lose baseDir.
619-
var s = sub;
620-
if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
621-
{
622-
s = s.Substring(1);
623-
}
624-
625-
// Always forward-slash, always bash semantics.
626-
return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
627-
}
628-
629603
/// <summary>
630604
/// Resolve <paramref name="token"/> to an absolute path against the
631605
/// supplied options. Returns null on resolution failure (SPEC §8 step 6).
@@ -647,7 +621,7 @@ private static string JoinPath(string baseDir, string sub)
647621
string combined;
648622
if (IsRootedPath(token))
649623
{
650-
combined = NormalizeToForwardSlashes(token);
624+
combined = ShellPathNormalization.NormalizeSeparators(token);
651625
}
652626
else if (workingDirectoryUnknown)
653627
{
@@ -667,7 +641,7 @@ private static string JoinPath(string baseDir, string sub)
667641
// rather than guess.
668642
return null;
669643
}
670-
combined = JoinPath(wd, token);
644+
combined = ShellPathNormalization.Join(wd, token);
671645
}
672646

673647
return NormalizePath(combined);
@@ -686,22 +660,6 @@ private static string JoinPath(string baseDir, string sub)
686660
}
687661
}
688662

689-
/// <summary>
690-
/// Normalize backslashes to forward slashes; preserve bash semantics
691-
/// for `\\server\share` UNC paths by collapsing the leading `\\` to a
692-
/// single `//`. (UNC paths are rare in bash but the heuristic preserves
693-
/// them in a recognizable form for consumers.)
694-
/// </summary>
695-
private static string NormalizeToForwardSlashes(string token)
696-
{
697-
if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
698-
{
699-
// UNC: \\server\share -> //server/share
700-
return "//" + token.Substring(2).Replace('\\', '/');
701-
}
702-
return token.Replace('\\', '/');
703-
}
704-
705663
/// <summary>
706664
/// Bash-style path normalization: collapse `.`/`..` segments, deduplicate
707665
/// adjacent slashes, preserve a leading `/` (or `//` for UNC), use

src/ShellSyntaxTree/Internal/Resolving/PwshResolver.cs

Lines changed: 5 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve(
7878
}
7979

8080
var home = GetHomeDirectory(options);
81-
working = working.Length == 1 ? home : JoinPath(home, working.Substring(2));
81+
working = working.Length == 1
82+
? home
83+
: ShellPathNormalization.Join(home, working.Substring(2));
8284
hadHomeish = true;
8385
}
8486

@@ -548,22 +550,6 @@ private static bool IsIdentifierContinuation(char c) =>
548550
private static bool IsAsciiLetter(char c) =>
549551
(c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z');
550552

551-
private static string JoinPath(string baseDir, string sub)
552-
{
553-
if (string.IsNullOrEmpty(sub))
554-
{
555-
return baseDir;
556-
}
557-
558-
var s = sub;
559-
if (s.Length > 0 && (s[0] == '/' || s[0] == '\\'))
560-
{
561-
s = s.Substring(1);
562-
}
563-
564-
return baseDir.TrimEnd('/', '\\') + "/" + s.Replace('\\', '/');
565-
}
566-
567553
/// <summary>
568554
/// Resolve <paramref name="token"/> to a normalized absolute path.
569555
/// Returns null on failure (SPEC.POWERSHELL.md §8). Drive-qualified
@@ -583,7 +569,7 @@ private static string JoinPath(string baseDir, string sub)
583569
string combined;
584570
if (IsRootedPath(token))
585571
{
586-
combined = NormalizeToForwardSlashes(token);
572+
combined = ShellPathNormalization.NormalizeSeparators(token);
587573
}
588574
else if (workingDirectoryUnknown)
589575
{
@@ -597,7 +583,7 @@ private static string JoinPath(string baseDir, string sub)
597583
return null;
598584
}
599585

600-
combined = JoinPath(wd, token);
586+
combined = ShellPathNormalization.Join(wd, token);
601587
}
602588

603589
return NormalizePath(combined);
@@ -616,16 +602,6 @@ private static string JoinPath(string baseDir, string sub)
616602
}
617603
}
618604

619-
private static string NormalizeToForwardSlashes(string token)
620-
{
621-
if (token.Length >= 2 && token[0] == '\\' && token[1] == '\\')
622-
{
623-
return "//" + token.Substring(2).Replace('\\', '/');
624-
}
625-
626-
return token.Replace('\\', '/');
627-
}
628-
629605
private static string NormalizePath(string path)
630606
{
631607
if (string.IsNullOrEmpty(path))
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="ShellPathNormalization.cs" company="Aaron Stannard">
3+
// Copyright (C) 2026 - 2026 Aaron Stannard <https://github.com/Aaronontheweb>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
namespace ShellSyntaxTree.Internal.Resolving;
7+
8+
/// <summary>
9+
/// String-only path operations whose behavior is identical for the Bash and
10+
/// PowerShell resolvers. Root detection, drive handling, and segment
11+
/// normalization remain shell-specific.
12+
/// </summary>
13+
internal static class ShellPathNormalization
14+
{
15+
internal static string Join(string baseDirectory, string subpath)
16+
{
17+
if (string.IsNullOrEmpty(subpath))
18+
{
19+
return baseDirectory;
20+
}
21+
22+
var relative = subpath;
23+
if (relative.Length > 0 && (relative[0] == '/' || relative[0] == '\\'))
24+
{
25+
relative = relative.Substring(1);
26+
}
27+
28+
return baseDirectory.TrimEnd('/', '\\') + "/" + relative.Replace('\\', '/');
29+
}
30+
31+
internal static string NormalizeSeparators(string path)
32+
{
33+
if (path.Length >= 2 && path[0] == '\\' && path[1] == '\\')
34+
{
35+
return "//" + path.Substring(2).Replace('\\', '/');
36+
}
37+
38+
return path.Replace('\\', '/');
39+
}
40+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// -----------------------------------------------------------------------
2+
// <copyright file="ShellPathNormalizationTests.cs" company="Aaron Stannard">
3+
// Copyright (C) 2026 - 2026 Aaron Stannard <https://github.com/Aaronontheweb>
4+
// </copyright>
5+
// -----------------------------------------------------------------------
6+
using ShellSyntaxTree.Internal.Resolving;
7+
using Xunit;
8+
9+
namespace ShellSyntaxTree.Tests.Resolving;
10+
11+
public class ShellPathNormalizationTests
12+
{
13+
[Theory]
14+
[InlineData("/base", "", "/base")]
15+
[InlineData("/base/", "/child\\file", "/base/child/file")]
16+
[InlineData("C:\\base\\", "child\\file", "C:\\base/child/file")]
17+
[InlineData("/base", "//child", "/base//child")]
18+
public void Join_preserves_the_existing_shared_string_semantics(
19+
string baseDirectory,
20+
string subpath,
21+
string expected)
22+
{
23+
Assert.Equal(expected, ShellPathNormalization.Join(baseDirectory, subpath));
24+
}
25+
26+
[Theory]
27+
[InlineData("\\\\server\\share\\file", "//server/share/file")]
28+
[InlineData("\\root\\file", "/root/file")]
29+
[InlineData("/root\\file", "/root/file")]
30+
public void NormalizeSeparators_preserves_UNC_and_normalizes_backslashes(
31+
string path,
32+
string expected)
33+
{
34+
Assert.Equal(expected, ShellPathNormalization.NormalizeSeparators(path));
35+
}
36+
}

0 commit comments

Comments
 (0)