Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: implement GetFullPath for simulated Path #573

Merged
merged 3 commits into from
Apr 20, 2024
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
15 changes: 15 additions & 0 deletions Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,14 @@ internal static ArgumentException AppendAccessOnlyInWriteOnlyMode(
#endif
};

internal static ArgumentException BasePathNotFullyQualified(string paramName)
=> new("Basepath argument is not fully qualified.", paramName)
{
#if FEATURE_EXCEPTION_HRESULT
HResult = -2147024809
#endif
};

internal static IOException CannotCreateFileAsAlreadyExists(Execute execute, string path)
=> new(
$"Cannot create '{path}' because a file or directory with the same name already exists.",
Expand Down Expand Up @@ -127,6 +135,13 @@ internal static NotSupportedException NotSupportedSafeFileHandle()
internal static NotSupportedException NotSupportedTimerWrapping()
=> new("You cannot wrap an existing Timer in the MockTimeSystem instance!");

internal static ArgumentException NullCharacterInPath(string paramName)
#if NET8_0_OR_GREATER
=> new("Null character in path.", paramName);
#else
=> new("Illegal characters in path.", paramName);
#endif

internal static PlatformNotSupportedException OperationNotSupportedOnThisPlatform()
=> new("Operation is not supported on this platform.")
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System;
using System.Text;

namespace Testably.Abstractions.Testing.Helpers;

Expand All @@ -18,6 +19,54 @@ private class LinuxPath(MockFileSystem fileSystem) : SimulatedPath(fileSystem)
/// <inheritdoc cref="IPath.VolumeSeparatorChar" />
public override char VolumeSeparatorChar => '/';

private readonly MockFileSystem _fileSystem = fileSystem;

/// <inheritdoc cref="IPath.GetFullPath(string)" />
public override string GetFullPath(string path)
{
path.EnsureValidArgument(_fileSystem, nameof(path));

if (!IsPathRooted(path))
{
path = Combine(_fileSystem.Storage.CurrentDirectory, path);
}

// We would ideally use realpath to do this, but it resolves symlinks and requires that the file actually exist.
string collapsedString = RemoveRelativeSegments(path, GetRootLength(path));

string result = collapsedString.Length == 0
? $"{DirectorySeparatorChar}"
: collapsedString;

if (result.Contains('\0', StringComparison.Ordinal))
{
throw ExceptionFactory.NullCharacterInPath(nameof(path));
}

return result;
}

#if FEATURE_PATH_RELATIVE
/// <inheritdoc cref="IPath.GetFullPath(string, string)" />
public override string GetFullPath(string path, string basePath)
{
path.EnsureValidArgument(_fileSystem, nameof(path));
basePath.EnsureValidArgument(_fileSystem, nameof(basePath));

if (!IsPathFullyQualified(basePath))
{
throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath));
}

if (IsPathFullyQualified(path))
{
return GetFullPath(path);
}

return GetFullPath(Combine(basePath, path));
}
#endif

/// <inheritdoc cref="IPath.GetInvalidFileNameChars()" />
public override char[] GetInvalidFileNameChars() => ['\0', '/'];

Expand Down Expand Up @@ -65,6 +114,12 @@ protected override bool IsDirectorySeparator(char c)
protected override bool IsEffectivelyEmpty(string path)
=> string.IsNullOrEmpty(path);

/// <summary>
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77
/// </summary>
protected override bool IsPartiallyQualified(string path)
=> !IsPathRooted(path);

/// <summary>
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39
/// </summary>
Expand Down
144 changes: 117 additions & 27 deletions Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Text;
Expand Down Expand Up @@ -259,35 +260,11 @@
}

/// <inheritdoc cref="IPath.GetFullPath(string)" />
public string GetFullPath(string path)
{
path.EnsureValidArgument(fileSystem, nameof(path));

string? pathRoot = System.IO.Path.GetPathRoot(path);
string? directoryRoot =
System.IO.Path.GetPathRoot(fileSystem.Storage.CurrentDirectory);
if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot))
{
if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0]))
{
return System.IO.Path.GetFullPath(path);
}

if (pathRoot.Length < directoryRoot.Length)
{
path = path.Substring(pathRoot.Length);
}
}

return System.IO.Path.GetFullPath(System.IO.Path.Combine(
fileSystem.Storage.CurrentDirectory,
path));
}
public abstract string GetFullPath(string path);

#if FEATURE_PATH_RELATIVE
/// <inheritdoc cref="IPath.GetFullPath(string, string)" />
public string GetFullPath(string path, string basePath)
=> System.IO.Path.GetFullPath(path, basePath);
public abstract string GetFullPath(string path, string basePath);
#endif

/// <inheritdoc cref="IPath.GetInvalidFileNameChars()" />
Expand Down Expand Up @@ -353,7 +330,14 @@
#if FEATURE_PATH_RELATIVE
/// <inheritdoc cref="IPath.IsPathFullyQualified(string)" />
public bool IsPathFullyQualified(string path)
=> System.IO.Path.IsPathFullyQualified(path);
{
if (path == null)
{
throw new ArgumentNullException(nameof(path));
}

return !IsPartiallyQualified(path);
}
#endif

#if FEATURE_SPAN
Expand Down Expand Up @@ -533,6 +517,8 @@
protected abstract bool IsDirectorySeparator(char c);
protected abstract bool IsEffectivelyEmpty(string path);

protected abstract bool IsPartiallyQualified(string path);

#if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED
private string JoinInternal(string?[] paths)
{
Expand Down Expand Up @@ -582,6 +568,110 @@
.Select(s => s[fileSystem.RandomSystem.Random.Shared.Next(s.Length)]).ToArray());
}

/// <summary>
/// Remove relative segments from the given path (without combining with a root).
/// </summary>
protected string RemoveRelativeSegments(string path, int rootLength)
{
Debug.Assert(rootLength > 0);
bool flippedSeparator = false;

StringBuilder sb = new();

int skip = rootLength;
// We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming
// the root actually ends in a separator. Otherwise the first segment for RemoveRelativeSegments
// in cases like "\\?\C:\.\" and "\\?\C:\..\", the first segment after the root will be ".\" and "..\" which is not considered as a relative segment and hence not be removed.
if (IsDirectorySeparator(path[skip - 1]))
{
skip--;
}

// Remove "//", "/./", and "/../" from the path by copying each character to the output,
// except the ones we're removing, such that the builder contains the normalized path
// at the end.
if (skip > 0)
{
sb.Append(path.Substring(0, skip));
}

for (int i = skip; i < path.Length; i++)
{
char c = path[i];

if (IsDirectorySeparator(c) && i + 1 < path.Length)
{
// Skip this character if it's a directory separator and if the next character is, too,
// e.g. "parent//child" => "parent/child"
if (IsDirectorySeparator(path[i + 1]))
{
continue;
}

// Skip this character and the next if it's referring to the current directory,
// e.g. "parent/./child" => "parent/child"
if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) &&
path[i + 1] == '.')
{
i++;

Check warning on line 616 in Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs#L616

Do not update the loop counter 'i' within the loop body.
continue;
}

// Skip this character and the next two if it's referring to the parent directory,
// e.g. "parent/child/../grandchild" => "parent/grandchild"
if (i + 2 < path.Length &&
(i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) &&
path[i + 1] == '.' && path[i + 2] == '.')
{
// Unwind back to the last slash (and if there isn't one, clear out everything).
int s;
for (s = sb.Length - 1; s >= skip; s--)
{
if (IsDirectorySeparator(sb[s]))
{
sb.Length =
i + 3 >= path.Length && s == skip
? s + 1
: s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\..
break;
}
}

if (s < skip)
{
sb.Length = skip;
}

i += 2;

Check warning on line 645 in Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs

View check run for this annotation

Codacy Production / Codacy Static Code Analysis

Source/Testably.Abstractions.Testing/Helpers/Execute.SimulatedPath.cs#L645

Do not update the loop counter 'i' within the loop body.
continue;
}
}

// Normalize the directory separator if needed
if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar)
{
c = DirectorySeparatorChar;
flippedSeparator = true;
}

sb.Append(c);
}

// If we haven't changed the source path, return the original
if (!flippedSeparator && sb.Length == path.Length)
{
return path;
}

// We may have eaten the trailing separator from the root when we started and not replaced it
if (skip != rootLength && sb.Length < rootLength)
{
sb.Append(path[rootLength - 1]);
}

return sb.ToString();
}

private bool TryGetExtensionIndex(string path, [NotNullWhen(true)] out int? dotIndex)
{
for (int i = path.Length - 1; i >= 0; i--)
Expand Down
Loading
Loading