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