Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions Icod.Path.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
<AssemblyName>Icod.Path</AssemblyName>
<RootNamespace>Icod.Path</RootNamespace>
<Configurations>Debug;Release;Staging</Configurations>
<Version>1.0.1</Version>
<Version>1.1.0</Version>
</PropertyGroup>
<PropertyGroup Condition=" '$(PlatformTarget)' == '' ">
<PlatformTarget>AnyCPU</PlatformTarget>
Expand Down Expand Up @@ -47,8 +47,8 @@
<WarningsNotAsErrors>CS1591</WarningsNotAsErrors>
</PropertyGroup>
<PropertyGroup>
<PackageVersion>1.0.1</PackageVersion>
<PackageReleaseNotes>Canonical-path foundation release</PackageReleaseNotes>
<PackageVersion>1.1.0</PackageVersion>
<PackageReleaseNotes>Add platform-explicit lexical pathname decomposition for command-neutral consumers.</PackageReleaseNotes>
<Authors>Timothy J. Bruce</Authors>
<PackageReadmeFile>README.md</PackageReadmeFile>
<PackageLicenseExpression>LGPL-3.0-or-later</PackageLicenseExpression>
Expand All @@ -58,7 +58,7 @@
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Deterministic>true</Deterministic>
<PackageTags>path;filesystem;canonicalization;path-resolution;realpath;normalization;symlink;symbolic-link;reparse-point;cross-platform;posix;hard-link;junction-point;canonical-path</PackageTags>
<PackageTags>path;filesystem;canonicalization;path-resolution;realpath;normalization;symlink;symbolic-link;reparse-point;cross-platform;posix;hard-link;junction-point;canonical-path;pathname;path-parsing</PackageTags>
<RepositoryUrl>https://github.com/uniblab/Icod.Path</RepositoryUrl>
<RepositoryType>git</RepositoryType>
<PackageIcon>icon.png</PackageIcon>
Expand Down
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
# Icod.Path

`Icod.Path` is a standalone .NET library for deterministic pathname normalization, physical canonicalization, and no-follow pathname-indirection inspection across POSIX and Windows path models.
`Icod.Path` is a standalone .NET library for deterministic pathname decomposition, normalization, physical canonicalization, and no-follow pathname-indirection inspection across POSIX and Windows path models.

The library is command-neutral. It can be consumed by utility suites, applications, services, build tools, or other libraries that need canonical-path behavior without depending on a command-line implementation.

## What the library provides

- `PathPlatformSemantics` models POSIX and Windows separators, roots, volume identity, and pathname comparison rules independently of the host operating system.
- `PathSyntaxParser` decomposes pathname text into root and ordered components without normalizing components, interpreting wildcard characters, or observing the filesystem.
- `PathLexicalNormalizer` converts input into absolute lexical form without observing the filesystem.
- `CanonicalPathResolver` performs ordered physical resolution, missing-component handling, pathname-indirection traversal, relative-path calculation, and component-aware containment checks.
- `ICanonicalPathFileSystemProvider` separates canonical-path policy from filesystem observation and permits deterministic or synthetic providers in tests and specialized hosts.
- `IPathIndirectionInspector` and `SystemPathIndirectionInspector` characterize a terminal pathname object without silently dereferencing it.
- `CanonicalPathResult`, `RelativePathResult`, `PathContainmentResult`, and related models return structured success or failure information instead of writing diagnostics or inventing a successful path after an error.

## Lexical pathname decomposition

`PathSyntaxParser.Parse` separates pathname structure from later interpretation. It identifies POSIX and Windows roots, volume identity, drive-relative and current-volume-rooted Windows forms, and the ordered nonempty component sequence.

The parser does not collapse `.` or `..`, does not interpret `*` or `?`, and does not enumerate the filesystem. Component text is preserved for higher-level consumers such as command frameworks that may apply their own pathname-pattern semantics. Windows root syntax is validated, while component contents are intentionally left uninterpreted by this decomposition layer.

```csharp
var syntax = PathSyntaxParser.Parse(
@"C:\src\**\foo?.cs",
PathPlatformSemantics.Windows
);

Console.WriteLine( syntax.RootPath );
foreach ( var component in syntax.Components ) {
Console.WriteLine( component );
}
```

## Canonicalization model

Lexical normalization and physical resolution are deliberately separate operations. Lexical normalization applies the selected pathname grammar without touching the filesystem. Physical resolution processes pathname components in filesystem order so supported pathname indirection is expanded before a following `..`, matching actual traversal semantics rather than merely simplifying text.
Expand Down Expand Up @@ -56,7 +75,7 @@ System-backed physical observation uses the current host filesystem. On Windows,

## Build and test

`Icod.Path` targets .NET 10.0 and uses C# 13.
`Icod.Path` targets .NET 7.0, 8.0, 9.0, and 10.0 and uses C# 13.

```text
dotnet build Icod.Path.sln
Expand Down
4 changes: 2 additions & 2 deletions src/PathLexicalNormalizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -479,7 +479,7 @@ private static bool IsValidWindowsComponent( string component ) {
return true;
}

private static WindowsRoot ParseWindowsRoot( string path ) {
internal static WindowsRoot ParseWindowsRoot( string path ) {
if (
4 <= path.Length
&& IsSeparator( path[0] )
Expand Down Expand Up @@ -688,7 +688,7 @@ private static LexicalNormalizationOutcome InvalidBase( string path ) =>
)
;

private readonly record struct WindowsRoot(
internal readonly record struct WindowsRoot(
string RootPath,
string VolumeName,
int ContentStart,
Expand Down
259 changes: 259 additions & 0 deletions src/PathSyntax.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
namespace Icod.Path;

/// <summary>
/// Decomposes pathname text according to an explicit platform grammar without
/// normalizing components or observing the filesystem.
/// </summary>
public static class PathSyntaxParser {
/// <summary>
/// Parses pathname root structure and ordered components.
/// </summary>
/// <param name="path">The pathname text to decompose.</param>
/// <param name="semantics">The pathname grammar to apply.</param>
/// <returns>The decomposed pathname syntax.</returns>
/// <exception cref="ArgumentException">
/// <paramref name="path"/> is empty, contains a NUL character, or has a
/// malformed Windows root.
/// </exception>
/// <exception cref="ArgumentNullException">
/// <paramref name="path"/> or <paramref name="semantics"/> is
/// <see langword="null"/>.
/// </exception>
public static PathSyntaxParts Parse(
string path,
PathPlatformSemantics semantics
) {
ArgumentNullException.ThrowIfNull(
path
);
ArgumentNullException.ThrowIfNull(
semantics
);
if ( 0 == path.Length ) {
throw new ArgumentException(
"pathname is empty",
nameof( path )
);
}
if ( 0 <= path.IndexOf( '\0' ) ) {
throw new ArgumentException(
"pathname contains a NUL character",
nameof( path )
);
}

return PathPlatformKind.Windows == semantics.Kind
? ParseWindows(
path,
semantics
)
: ParsePosix(
path,
semantics
)
;
}

private static PathSyntaxParts ParsePosix(
string path,
PathPlatformSemantics semantics
) {
var isAbsolute = semantics.IsDirectorySeparator(
path[ 0 ]
);
var components = SplitComponents(
path.AsSpan(
isAbsolute
? 1
: 0
),
semantics
);
return new PathSyntaxParts(
path,
PathPlatformKind.Posix,
isAbsolute
? "/"
: string.Empty,
isAbsolute
? "/"
: string.Empty,
components,
isAbsolute,
isDriveRelative: false,
isCurrentVolumeRooted: false
);
}

private static PathSyntaxParts ParseWindows(
string path,
PathPlatformSemantics semantics
) {
var canonical = null == semantics.AlternateDirectorySeparator
? path
: path.Replace(
semantics.AlternateDirectorySeparator.Value,
semantics.DirectorySeparator
)
;
var root = PathLexicalNormalizer.ParseWindowsRoot(
canonical
);
if ( root.IsInvalid ) {
throw new ArgumentException(
"pathname contains a malformed Windows root",
nameof( path )
);
}

var components = SplitComponents(
canonical.AsSpan(
root.ContentStart
),
semantics
);
var rootPath = root.IsCurrentVolumeRooted
? semantics.DirectorySeparator.ToString()
: (
root.IsDriveRelative
? root.VolumeName
: root.RootPath
)
;
return new PathSyntaxParts(
path,
PathPlatformKind.Windows,
rootPath,
root.VolumeName,
components,
root.IsAbsolute,
root.IsDriveRelative,
root.IsCurrentVolumeRooted
);
}

private static IReadOnlyList<string> SplitComponents(
ReadOnlySpan<char> content,
PathPlatformSemantics semantics
) {
var components = new List<string>();
var start = 0;
for (
var index = 0;
index <= content.Length;
index++
) {
if (
index < content.Length
&& !semantics.IsDirectorySeparator(
content[ index ]
)
) {
continue;
}

var component = content[
start..index
].ToString();
start = index + 1;
if ( 0 == component.Length ) {
continue;
}
components.Add(
component
);
}
return Array.AsReadOnly(
components.ToArray()
);
}
}

/// <summary>
/// Represents the structural decomposition of pathname text without component
/// normalization or filesystem observation.
/// </summary>
public sealed class PathSyntaxParts {
internal PathSyntaxParts(
string originalPath,
PathPlatformKind platformKind,
string rootPath,
string volumeName,
IReadOnlyList<string> components,
bool isAbsolute,
bool isDriveRelative,
bool isCurrentVolumeRooted
) {
this.OriginalPath = originalPath;
this.PlatformKind = platformKind;
this.RootPath = rootPath;
this.VolumeName = volumeName;
this.Components = components;
this.IsAbsolute = isAbsolute;
this.IsDriveRelative = isDriveRelative;
this.IsCurrentVolumeRooted = isCurrentVolumeRooted;
}

/// <summary>Gets the pathname text supplied by the caller.</summary>
public string OriginalPath {
get;
}

/// <summary>Gets the pathname grammar used for decomposition.</summary>
public PathPlatformKind PlatformKind {
get;
}

/// <summary>
/// Gets the canonical root spelling, an empty string for an unrooted path,
/// the drive designator for a drive-relative Windows path, or one separator
/// for a current-volume-rooted Windows path.
/// </summary>
public string RootPath {
get;
}

/// <summary>
/// Gets the comparison identity of the root volume when one is identified,
/// or an empty string otherwise.
/// </summary>
public string VolumeName {
get;
}

/// <summary>
/// Gets the ordered nonempty pathname components exactly as parsed after
/// separator normalization.
/// </summary>
public IReadOnlyList<string> Components {
get;
}

/// <summary>Gets whether the pathname names an absolute root.</summary>
public bool IsAbsolute {
get;
}

/// <summary>Gets whether a Windows pathname uses drive-relative syntax.</summary>
public bool IsDriveRelative {
get;
}

/// <summary>
/// Gets whether a Windows pathname is rooted on the current volume without
/// identifying that volume.
/// </summary>
public bool IsCurrentVolumeRooted {
get;
}

/// <summary>Gets whether the pathname contains any form of explicit root syntax.</summary>
public bool HasRoot {
get {
return this.IsAbsolute
|| this.IsDriveRelative
|| this.IsCurrentVolumeRooted
;
}
}
}
11 changes: 10 additions & 1 deletion src/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
# Canonical-path and pathname-indirection model

The standalone `Icod.Path` library separates pathname grammar from physical filesystem observation. This document describes the source-level contract behind its public canonicalization and pathname-indirection APIs.
The standalone `Icod.Path` library separates pathname grammar from physical filesystem observation. This document describes the source-level contract behind its public pathname-syntax, canonicalization, and pathname-indirection APIs.

- `PathPlatformSemantics` describes POSIX and Windows separators, root syntax, volume identity, and comparison rules independently of the host operating system.
- `PathSyntaxParser` decomposes pathname text into root metadata and ordered components without normalization, wildcard interpretation, or filesystem observation.
- `PathLexicalNormalizer` creates absolute lexical paths without observing the filesystem and rejects invalid or unresolved drive-relative forms.
- `IPathIndirectionInspector` characterizes one terminal physical object without dereferencing it or opening file content.
- `SystemPathIndirectionInspector` reads POSIX link targets and, on Windows, combines no-follow handle information, `FSCTL_GET_REPARSE_POINT`, and volume-mount APIs.
Expand All @@ -11,6 +12,14 @@ The standalone `Icod.Path` library separates pathname grammar from physical file
- `CanonicalPathResolver` performs ordered physical resolution, loop and expansion-limit checks, missing-component policy, terminal-object inspection, relative-path computation, and containment evaluation.
- `CanonicalPathResult`, `RelativePathResult`, and `PathContainmentResult` carry structured failures; no failure path is returned as a successful canonical result.

## Pathname syntax decomposition

`PathSyntaxParser` is the command-neutral structural layer. It validates only pathname-level invariants needed to identify the root and component boundaries: nonempty input, no NUL character, and well-formed Windows root syntax. It returns `PathSyntaxParts` containing the original input, canonical root spelling, volume identity, ordered nonempty components, and flags for absolute, drive-relative, and current-volume-rooted forms.

Component text is not normalized or interpreted. In particular, `.` and `..` remain components, and characters such as `*` and `?` remain ordinary component text at this layer. The parser does not decide whether those characters are valid filesystem names or wildcard operators. Higher-level consumers may impose those policies after decomposition.

This distinction keeps pathname grammar in `Icod.Path` while leaving pathname-pattern matching, directory enumeration, recursive `**` semantics, unmatched-pattern handling, and traversal policy to higher-level libraries.

## Missing components

`RequireExisting` requires every component. `AllowFinalComponent` permits only the final unresolved component. `AllowMissingSuffix` permits the first missing component and the remaining lexical suffix. The result records the number of unresolved suffix components.
Expand Down
Loading
Loading