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