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
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,17 @@ private sealed class DirectoryPathValidator : ValidationAdapter
/// <param name="value">The string value to validate</param>
/// <returns>A validation result indicating success or failure</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
protected override ValidationResult ValidateValue(string value)
{
Expand All @@ -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()
Expand Down
18 changes: 18 additions & 0 deletions Semantics.Paths/Validation/Attributes/Path/IsFilePathAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,16 @@ private sealed class FilePathValidator : ValidationAdapter
/// <param name="value">The string value to validate</param>
/// <returns>A validation result indicating success or failure</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The existence check applies only to fully qualified paths, for the same reason it does in
/// <see cref="IsDirectoryPathAttribute"/>: 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.
/// </para>
/// </remarks>
protected override ValidationResult ValidateValue(string value)
{
Expand All @@ -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()
Expand Down
107 changes: 107 additions & 0 deletions Semantics.Test/PathValidationAttributeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,113 @@ public void IsFilePathAttribute_EmptyPath_ShouldPass()
Assert.IsTrue(emptyPath.IsValid());
}

/// <summary>
/// A relative directory name must stay valid even when a file of the same name sits in the
/// process's current working directory.
/// </summary>
/// <remarks>
/// 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 <c>ktsu.BlastMerge.Test</c>
/// leaves a file called <c>ktsu.BlastMerge.Test</c> beside the test assembly. When
/// <c>ktsu.AppDataStorage</c> converted the app domain name to a <see cref="RelativeDirectoryPath"/>,
/// <c>File.Exists</c> resolved it against that directory, found the apphost, and rejected the name.
/// On Windows the apphost carries a <c>.exe</c> suffix, so the same string validated fine — the
/// verdict depended on unrelated files rather than on the path itself.
/// </remarks>
[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<TestDirectoryPath>(collidingName);

// Assert - validity is decided by the string, not by what sits in the working directory
Assert.IsTrue(directoryPath.IsValid());
}
finally
{
File.Delete(collidingFile);
}
}

/// <summary>
/// The mirror of <see cref="IsDirectoryPathAttribute_RelativeNameCollidingWithFileInWorkingDirectory_ShouldPass"/>:
/// a relative file name must stay valid when a directory of the same name sits in the working directory.
/// </summary>
[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<TestFilePath>(collidingName);

// Assert - validity is decided by the string, not by what sits in the working directory
Assert.IsTrue(filePath.IsValid());
}
finally
{
Directory.Delete(collidingDirectory);
}
}

/// <summary>
/// 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.
/// </summary>
[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<ArgumentException>(() =>
TestDirectoryPath.Create<TestDirectoryPath>(existingFile));
}
finally
{
File.Delete(existingFile);
}
}

/// <summary>
/// The mirror of <see cref="IsDirectoryPathAttribute_AbsolutePathOfExistingFile_ShouldFail"/>: an
/// absolute file path that points at an existing directory is still rejected.
/// </summary>
[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<ArgumentException>(() =>
TestFilePath.Create<TestFilePath>(existingDirectory));
}
finally
{
Directory.Delete(existingDirectory);
}
}

[TestMethod]
public void DoesExistAttribute_NonExistentPath_ShouldFail()
{
Expand Down
11 changes: 9 additions & 2 deletions docs/validation-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -126,6 +126,13 @@ These live in `Semantics.Paths` and require `using ktsu.Semantics.Paths;`.
public sealed record ConfigFilePath : SemanticString<ConfigFilePath> { }
```

`[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
Expand Down
Loading