Skip to content

Commit c9d4256

Browse files
authored
feat: implement GetFullPath for simulated Path (#573)
* Add GetFullPath * Skip failing simulated test on .Net Framework * Initialize simulated tests
1 parent 760b601 commit c9d4256

File tree

7 files changed

+349
-35
lines changed

7 files changed

+349
-35
lines changed

Source/Testably.Abstractions.Testing/Helpers/ExceptionFactory.cs

+15
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@ internal static ArgumentException AppendAccessOnlyInWriteOnlyMode(
2929
#endif
3030
};
3131

32+
internal static ArgumentException BasePathNotFullyQualified(string paramName)
33+
=> new("Basepath argument is not fully qualified.", paramName)
34+
{
35+
#if FEATURE_EXCEPTION_HRESULT
36+
HResult = -2147024809
37+
#endif
38+
};
39+
3240
internal static IOException CannotCreateFileAsAlreadyExists(Execute execute, string path)
3341
=> new(
3442
$"Cannot create '{path}' because a file or directory with the same name already exists.",
@@ -127,6 +135,13 @@ internal static NotSupportedException NotSupportedSafeFileHandle()
127135
internal static NotSupportedException NotSupportedTimerWrapping()
128136
=> new("You cannot wrap an existing Timer in the MockTimeSystem instance!");
129137

138+
internal static ArgumentException NullCharacterInPath(string paramName)
139+
#if NET8_0_OR_GREATER
140+
=> new("Null character in path.", paramName);
141+
#else
142+
=> new("Illegal characters in path.", paramName);
143+
#endif
144+
130145
internal static PlatformNotSupportedException OperationNotSupportedOnThisPlatform()
131146
=> new("Operation is not supported on this platform.")
132147
{

Source/Testably.Abstractions.Testing/Helpers/Execute.LinuxPath.cs

+56-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
using System.Text;
1+
using System;
2+
using System.Text;
23

34
namespace Testably.Abstractions.Testing.Helpers;
45

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

22+
private readonly MockFileSystem _fileSystem = fileSystem;
23+
24+
/// <inheritdoc cref="IPath.GetFullPath(string)" />
25+
public override string GetFullPath(string path)
26+
{
27+
path.EnsureValidArgument(_fileSystem, nameof(path));
28+
29+
if (!IsPathRooted(path))
30+
{
31+
path = Combine(_fileSystem.Storage.CurrentDirectory, path);
32+
}
33+
34+
// We would ideally use realpath to do this, but it resolves symlinks and requires that the file actually exist.
35+
string collapsedString = RemoveRelativeSegments(path, GetRootLength(path));
36+
37+
string result = collapsedString.Length == 0
38+
? $"{DirectorySeparatorChar}"
39+
: collapsedString;
40+
41+
if (result.Contains('\0', StringComparison.Ordinal))
42+
{
43+
throw ExceptionFactory.NullCharacterInPath(nameof(path));
44+
}
45+
46+
return result;
47+
}
48+
49+
#if FEATURE_PATH_RELATIVE
50+
/// <inheritdoc cref="IPath.GetFullPath(string, string)" />
51+
public override string GetFullPath(string path, string basePath)
52+
{
53+
path.EnsureValidArgument(_fileSystem, nameof(path));
54+
basePath.EnsureValidArgument(_fileSystem, nameof(basePath));
55+
56+
if (!IsPathFullyQualified(basePath))
57+
{
58+
throw ExceptionFactory.BasePathNotFullyQualified(nameof(basePath));
59+
}
60+
61+
if (IsPathFullyQualified(path))
62+
{
63+
return GetFullPath(path);
64+
}
65+
66+
return GetFullPath(Combine(basePath, path));
67+
}
68+
#endif
69+
2170
/// <inheritdoc cref="IPath.GetInvalidFileNameChars()" />
2271
public override char[] GetInvalidFileNameChars() => ['\0', '/'];
2372

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

117+
/// <summary>
118+
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L77
119+
/// </summary>
120+
protected override bool IsPartiallyQualified(string path)
121+
=> !IsPathRooted(path);
122+
68123
/// <summary>
69124
/// https://github.com/dotnet/runtime/blob/v8.0.4/src/libraries/Common/src/System/IO/PathInternal.Unix.cs#L39
70125
/// </summary>

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

+117-27
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System;
2+
using System.Diagnostics;
23
using System.Diagnostics.CodeAnalysis;
34
using System.Linq;
45
using System.Text;
@@ -259,35 +260,11 @@ public ReadOnlySpan<char> GetFileNameWithoutExtension(ReadOnlySpan<char> path)
259260
}
260261

261262
/// <inheritdoc cref="IPath.GetFullPath(string)" />
262-
public string GetFullPath(string path)
263-
{
264-
path.EnsureValidArgument(fileSystem, nameof(path));
265-
266-
string? pathRoot = System.IO.Path.GetPathRoot(path);
267-
string? directoryRoot =
268-
System.IO.Path.GetPathRoot(fileSystem.Storage.CurrentDirectory);
269-
if (!string.IsNullOrEmpty(pathRoot) && !string.IsNullOrEmpty(directoryRoot))
270-
{
271-
if (char.ToUpperInvariant(pathRoot[0]) != char.ToUpperInvariant(directoryRoot[0]))
272-
{
273-
return System.IO.Path.GetFullPath(path);
274-
}
275-
276-
if (pathRoot.Length < directoryRoot.Length)
277-
{
278-
path = path.Substring(pathRoot.Length);
279-
}
280-
}
281-
282-
return System.IO.Path.GetFullPath(System.IO.Path.Combine(
283-
fileSystem.Storage.CurrentDirectory,
284-
path));
285-
}
263+
public abstract string GetFullPath(string path);
286264

287265
#if FEATURE_PATH_RELATIVE
288266
/// <inheritdoc cref="IPath.GetFullPath(string, string)" />
289-
public string GetFullPath(string path, string basePath)
290-
=> System.IO.Path.GetFullPath(path, basePath);
267+
public abstract string GetFullPath(string path, string basePath);
291268
#endif
292269

293270
/// <inheritdoc cref="IPath.GetInvalidFileNameChars()" />
@@ -353,7 +330,14 @@ public bool IsPathFullyQualified(ReadOnlySpan<char> path)
353330
#if FEATURE_PATH_RELATIVE
354331
/// <inheritdoc cref="IPath.IsPathFullyQualified(string)" />
355332
public bool IsPathFullyQualified(string path)
356-
=> System.IO.Path.IsPathFullyQualified(path);
333+
{
334+
if (path == null)
335+
{
336+
throw new ArgumentNullException(nameof(path));
337+
}
338+
339+
return !IsPartiallyQualified(path);
340+
}
357341
#endif
358342

359343
#if FEATURE_SPAN
@@ -533,6 +517,8 @@ string NormalizePath(string path, bool ignoreStartingSeparator)
533517
protected abstract bool IsDirectorySeparator(char c);
534518
protected abstract bool IsEffectivelyEmpty(string path);
535519

520+
protected abstract bool IsPartiallyQualified(string path);
521+
536522
#if FEATURE_PATH_JOIN || FEATURE_PATH_ADVANCED
537523
private string JoinInternal(string?[] paths)
538524
{
@@ -582,6 +568,110 @@ protected string RandomString(int length)
582568
.Select(s => s[fileSystem.RandomSystem.Random.Shared.Next(s.Length)]).ToArray());
583569
}
584570

571+
/// <summary>
572+
/// Remove relative segments from the given path (without combining with a root).
573+
/// </summary>
574+
protected string RemoveRelativeSegments(string path, int rootLength)
575+
{
576+
Debug.Assert(rootLength > 0);
577+
bool flippedSeparator = false;
578+
579+
StringBuilder sb = new();
580+
581+
int skip = rootLength;
582+
// We treat "\.." , "\." and "\\" as a relative segment. We want to collapse the first separator past the root presuming
583+
// the root actually ends in a separator. Otherwise the first segment for RemoveRelativeSegments
584+
// 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.
585+
if (IsDirectorySeparator(path[skip - 1]))
586+
{
587+
skip--;
588+
}
589+
590+
// Remove "//", "/./", and "/../" from the path by copying each character to the output,
591+
// except the ones we're removing, such that the builder contains the normalized path
592+
// at the end.
593+
if (skip > 0)
594+
{
595+
sb.Append(path.Substring(0, skip));
596+
}
597+
598+
for (int i = skip; i < path.Length; i++)
599+
{
600+
char c = path[i];
601+
602+
if (IsDirectorySeparator(c) && i + 1 < path.Length)
603+
{
604+
// Skip this character if it's a directory separator and if the next character is, too,
605+
// e.g. "parent//child" => "parent/child"
606+
if (IsDirectorySeparator(path[i + 1]))
607+
{
608+
continue;
609+
}
610+
611+
// Skip this character and the next if it's referring to the current directory,
612+
// e.g. "parent/./child" => "parent/child"
613+
if ((i + 2 == path.Length || IsDirectorySeparator(path[i + 2])) &&
614+
path[i + 1] == '.')
615+
{
616+
i++;
617+
continue;
618+
}
619+
620+
// Skip this character and the next two if it's referring to the parent directory,
621+
// e.g. "parent/child/../grandchild" => "parent/grandchild"
622+
if (i + 2 < path.Length &&
623+
(i + 3 == path.Length || IsDirectorySeparator(path[i + 3])) &&
624+
path[i + 1] == '.' && path[i + 2] == '.')
625+
{
626+
// Unwind back to the last slash (and if there isn't one, clear out everything).
627+
int s;
628+
for (s = sb.Length - 1; s >= skip; s--)
629+
{
630+
if (IsDirectorySeparator(sb[s]))
631+
{
632+
sb.Length =
633+
i + 3 >= path.Length && s == skip
634+
? s + 1
635+
: s; // to avoid removing the complete "\tmp\" segment in cases like \\?\C:\tmp\..\, C:\tmp\..
636+
break;
637+
}
638+
}
639+
640+
if (s < skip)
641+
{
642+
sb.Length = skip;
643+
}
644+
645+
i += 2;
646+
continue;
647+
}
648+
}
649+
650+
// Normalize the directory separator if needed
651+
if (c != DirectorySeparatorChar && c == AltDirectorySeparatorChar)
652+
{
653+
c = DirectorySeparatorChar;
654+
flippedSeparator = true;
655+
}
656+
657+
sb.Append(c);
658+
}
659+
660+
// If we haven't changed the source path, return the original
661+
if (!flippedSeparator && sb.Length == path.Length)
662+
{
663+
return path;
664+
}
665+
666+
// We may have eaten the trailing separator from the root when we started and not replaced it
667+
if (skip != rootLength && sb.Length < rootLength)
668+
{
669+
sb.Append(path[rootLength - 1]);
670+
}
671+
672+
return sb.ToString();
673+
}
674+
585675
private bool TryGetExtensionIndex(string path, [NotNullWhen(true)] out int? dotIndex)
586676
{
587677
for (int i = path.Length - 1; i >= 0; i--)

0 commit comments

Comments
 (0)