From 63275830ec71518daca79a5dc620278b0e1210de Mon Sep 17 00:00:00 2001 From: Matthew Edmondson Date: Mon, 14 Sep 2026 06:26:29 +0000 Subject: [PATCH] Decide relative path validity from the string, not the working directory [patch] `IsDirectoryPathAttribute` called `File.Exists(value)` on every value, and `IsFilePathAttribute` called `Directory.Exists(value)`. For a path that is not fully qualified, those resolve against the process's current working directory, so whether a path was valid depended on what files happened to sit beside whatever was running. That reached consumers. On Linux .NET publishes an extensionless apphost named after the project, so a test project called `ktsu.BlastMerge.Test` leaves a file of exactly that name next to the test assembly. `AppData.AppDomain` converts the app domain name to a `RelativeDirectoryPath`, `File.Exists` found the apphost, and the conversion threw. On Windows the apphost carries a `.exe` suffix, so the same string validated. Nothing about the name differed between the platforms. Both attributes now run the existence check only when the path is fully qualified, which is the case where it names one location and the question is answerable. Anything else is validated by shape alone. Note this also excludes Windows drive-relative paths such as `C:foo`, which resolve against a per-drive working directory and are ambient in the same way. Four tests cover it: the two collision cases reproducing the reported failure, and two pinning that an absolute path pointing at the wrong kind of entry is still rejected. Fixes #196 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UArBGBWxw5dSrddFUcYccd --- .../Path/IsDirectoryPathAttribute.cs | 19 ++++ .../Attributes/Path/IsFilePathAttribute.cs | 18 +++ .../PathValidationAttributeTests.cs | 107 ++++++++++++++++++ docs/validation-reference.md | 11 +- 4 files changed, 153 insertions(+), 2 deletions(-) diff --git a/Semantics.Paths/Validation/Attributes/Path/IsDirectoryPathAttribute.cs b/Semantics.Paths/Validation/Attributes/Path/IsDirectoryPathAttribute.cs index 608ae17a..1fd42fe5 100644 --- a/Semantics.Paths/Validation/Attributes/Path/IsDirectoryPathAttribute.cs +++ b/Semantics.Paths/Validation/Attributes/Path/IsDirectoryPathAttribute.cs @@ -29,8 +29,17 @@ private sealed class DirectoryPathValidator : ValidationAdapter /// The string value to validate /// A validation result indicating success or failure /// + /// /// This validation passes if the path doesn't exist as a file, allowing for non-existent directories /// and existing directories. It only fails if the path exists and is specifically a file. + /// + /// + /// The existence check applies only to fully qualified paths. A path that is not fully qualified + /// names no particular location on disk until a caller supplies a base directory, so probing the + /// filesystem for it would resolve it against the process's current working directory — making the + /// same string valid in one process and invalid in another depending on what files happen to sit + /// beside them. Such paths are validated by shape alone. + /// /// protected override ValidationResult ValidateValue(string value) { @@ -39,6 +48,16 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Success(); } +#if NETSTANDARD2_0 + bool isFullyQualified = PathPolyfill.IsPathFullyQualified(value); +#else + bool isFullyQualified = Path.IsPathFullyQualified(value); +#endif + if (!isFullyQualified) + { + return ValidationResult.Success(); + } + bool isNotFile = !File.Exists(value); return isNotFile ? ValidationResult.Success() diff --git a/Semantics.Paths/Validation/Attributes/Path/IsFilePathAttribute.cs b/Semantics.Paths/Validation/Attributes/Path/IsFilePathAttribute.cs index ac6c6b35..94b4ce8c 100644 --- a/Semantics.Paths/Validation/Attributes/Path/IsFilePathAttribute.cs +++ b/Semantics.Paths/Validation/Attributes/Path/IsFilePathAttribute.cs @@ -29,8 +29,16 @@ private sealed class FilePathValidator : ValidationAdapter /// The string value to validate /// A validation result indicating success or failure /// + /// /// This validation passes if the path doesn't exist as a directory, allowing for non-existent files /// and existing files. It only fails if the path exists and is specifically a directory. + /// + /// + /// The existence check applies only to fully qualified paths, for the same reason it does in + /// : a path that is not fully qualified names no particular + /// location on disk until a caller supplies a base directory, so probing for it would resolve it + /// against the process's current working directory. Such paths are validated by shape alone. + /// /// protected override ValidationResult ValidateValue(string value) { @@ -39,6 +47,16 @@ protected override ValidationResult ValidateValue(string value) return ValidationResult.Success(); } +#if NETSTANDARD2_0 + bool isFullyQualified = PathPolyfill.IsPathFullyQualified(value); +#else + bool isFullyQualified = Path.IsPathFullyQualified(value); +#endif + if (!isFullyQualified) + { + return ValidationResult.Success(); + } + bool isValidFilePath = !Directory.Exists(value); return isValidFilePath ? ValidationResult.Success() diff --git a/Semantics.Test/PathValidationAttributeTests.cs b/Semantics.Test/PathValidationAttributeTests.cs index e2e3826e..ac1d9cf4 100644 --- a/Semantics.Test/PathValidationAttributeTests.cs +++ b/Semantics.Test/PathValidationAttributeTests.cs @@ -217,6 +217,113 @@ public void IsFilePathAttribute_EmptyPath_ShouldPass() Assert.IsTrue(emptyPath.IsValid()); } + /// + /// A relative directory name must stay valid even when a file of the same name sits in the + /// process's current working directory. + /// + /// + /// This is the failure mode reported in issue #196. On Linux .NET publishes an extensionless + /// apphost named exactly after the project, so a test project called ktsu.BlastMerge.Test + /// leaves a file called ktsu.BlastMerge.Test beside the test assembly. When + /// ktsu.AppDataStorage converted the app domain name to a , + /// File.Exists resolved it against that directory, found the apphost, and rejected the name. + /// On Windows the apphost carries a .exe suffix, so the same string validated fine — the + /// verdict depended on unrelated files rather than on the path itself. + /// + [TestMethod] + public void IsDirectoryPathAttribute_RelativeNameCollidingWithFileInWorkingDirectory_ShouldPass() + { + // Arrange - a file in the working directory named exactly like the directory we want to name + string collidingName = $"ktsu-semantics-196-dir-{Guid.NewGuid():N}"; + string collidingFile = Path.Combine(Directory.GetCurrentDirectory(), collidingName); + File.WriteAllText(collidingFile, ""); + + try + { + // Act + TestDirectoryPath directoryPath = TestDirectoryPath.Create(collidingName); + + // Assert - validity is decided by the string, not by what sits in the working directory + Assert.IsTrue(directoryPath.IsValid()); + } + finally + { + File.Delete(collidingFile); + } + } + + /// + /// The mirror of : + /// a relative file name must stay valid when a directory of the same name sits in the working directory. + /// + [TestMethod] + public void IsFilePathAttribute_RelativeNameCollidingWithDirectoryInWorkingDirectory_ShouldPass() + { + // Arrange - a directory in the working directory named exactly like the file we want to name + string collidingName = $"ktsu-semantics-196-file-{Guid.NewGuid():N}"; + string collidingDirectory = Path.Combine(Directory.GetCurrentDirectory(), collidingName); + Directory.CreateDirectory(collidingDirectory); + + try + { + // Act + TestFilePath filePath = TestFilePath.Create(collidingName); + + // Assert - validity is decided by the string, not by what sits in the working directory + Assert.IsTrue(filePath.IsValid()); + } + finally + { + Directory.Delete(collidingDirectory); + } + } + + /// + /// The existence check is kept where it is answerable: an absolute path names one location, so a + /// directory path that points at an existing file is still rejected. + /// + [TestMethod] + public void IsDirectoryPathAttribute_AbsolutePathOfExistingFile_ShouldFail() + { + // Arrange + string existingFile = Path.Combine(Path.GetTempPath(), $"ktsu-semantics-196-{Guid.NewGuid():N}.tmp"); + File.WriteAllText(existingFile, ""); + + try + { + // Act & Assert + Assert.ThrowsExactly(() => + TestDirectoryPath.Create(existingFile)); + } + finally + { + File.Delete(existingFile); + } + } + + /// + /// The mirror of : an + /// absolute file path that points at an existing directory is still rejected. + /// + [TestMethod] + public void IsFilePathAttribute_AbsolutePathOfExistingDirectory_ShouldFail() + { + // Arrange + string existingDirectory = Path.Combine(Path.GetTempPath(), $"ktsu-semantics-196-{Guid.NewGuid():N}"); + Directory.CreateDirectory(existingDirectory); + + try + { + // Act & Assert + Assert.ThrowsExactly(() => + TestFilePath.Create(existingDirectory)); + } + finally + { + Directory.Delete(existingDirectory); + } + } + [TestMethod] public void DoesExistAttribute_NonExistentPath_ShouldFail() { diff --git a/docs/validation-reference.md b/docs/validation-reference.md index ebfa1d49..d2aa0cd8 100644 --- a/docs/validation-reference.md +++ b/docs/validation-reference.md @@ -114,8 +114,8 @@ These live in `Semantics.Paths` and require `using ktsu.Semantics.Paths;`. | `[IsValidPath]` | Stricter: also rejects reserved names. | | `[IsAbsolutePath]` | Fully qualified path. | | `[IsRelativePath]` | Not absolute. | -| `[IsFilePath]` | Refers to a file (not a directory). | -| `[IsDirectoryPath]` | Refers to a directory. | +| `[IsFilePath]` | Refers to a file: a fully qualified path must not name an existing directory. | +| `[IsDirectoryPath]` | Refers to a directory: a fully qualified path must not name an existing file. | | `[IsFileName]` | Filename without separators. | | `[IsValidFileName]` | Stricter filename validation. | | `[IsExtension]` | File extension including the leading dot. | @@ -126,6 +126,13 @@ These live in `Semantics.Paths` and require `using ktsu.Semantics.Paths;`. public sealed record ConfigFilePath : SemanticString { } ``` +`[IsFilePath]` and `[IsDirectoryPath]` consult the file system only for fully qualified paths. A path +that is not fully qualified names no particular location until a caller supplies a base directory, so +probing for it would resolve it against the process's current working directory — making the same +string valid in one process and invalid in another depending on what files happen to sit beside them. +Those paths are validated by shape alone. To ask the existence question about a relative path, resolve +it first with `AsAbsolute(baseDirectory)` and validate the result. + For most use cases, prefer the dedicated path types (`AbsoluteFilePath`, `RelativeDirectoryPath`, etc.) from `Semantics.Paths` — they bundle these attributes and provide rich path operations. ## Strategies