diff --git a/src/LibHac/Fs/Common/PathFormatter.cs b/src/LibHac/Fs/Common/PathFormatter.cs new file mode 100644 index 00000000..8f4975d7 --- /dev/null +++ b/src/LibHac/Fs/Common/PathFormatter.cs @@ -0,0 +1,630 @@ +using System; +using System.Buffers; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using LibHac.Common; +using LibHac.Diag; +using LibHac.FsSystem; +using LibHac.Util; +using static LibHac.Fs.StringTraits; + +namespace LibHac.Fs.Common +{ + public static class PathFormatter + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static Result CheckHostName(ReadOnlySpan name) + { + if (name.Length == 2 && name[0] == Dot && name[1] == Dot) + return ResultFs.InvalidPathFormat.Log(); + + for (int i = 0; i < name.Length; i++) + { + if (name[i] == ':' || name[i] == '$') + return ResultFs.InvalidPathFormat.Log(); + } + + return Result.Success; + } + + private static Result CheckSharedName(ReadOnlySpan name) + { + if (name.Length == 1 && name[0] == Dot) + return ResultFs.InvalidPathFormat.Log(); + + if (name.Length == 2 && name[0] == Dot && name[1] == Dot) + return ResultFs.InvalidPathFormat.Log(); + + for (int i = 0; i < name.Length; i++) + { + if (name[i] == ':') + return ResultFs.InvalidPathFormat.Log(); + } + + return Result.Success; + } + + public static Result ParseMountName(out ReadOnlySpan newPath, out int mountNameLength, + Span outMountNameBuffer, ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + UnsafeHelpers.SkipParamInit(out mountNameLength); + newPath = default; + + int maxMountLength = outMountNameBuffer.Length == 0 + ? PathTools.MountNameLengthMax + 1 + : Math.Min(outMountNameBuffer.Length, PathTools.MountNameLengthMax + 1); + + int mountLength; + for (mountLength = 0; mountLength < maxMountLength && path.At(mountLength) != 0; mountLength++) + { + byte c = path[mountLength]; + + if (c == DriveSeparator) + { + mountLength++; + break; + } + + if (c == DirectorySeparator || c == AltDirectorySeparator) + { + newPath = path; + mountNameLength = 0; + + return Result.Success; + } + } + + if (mountLength <= 2 || path[mountLength - 1] != DriveSeparator) + { + newPath = path; + mountNameLength = 0; + + return Result.Success; + } + + for (int i = 0; i < mountLength; i++) + { + if (path.At(i) is (byte)'*' or (byte)'?' or (byte)'<' or (byte)'>' or (byte)'|') + return ResultFs.InvalidCharacter.Log(); + } + + if (!outMountNameBuffer.IsEmpty) + { + if (mountLength >= outMountNameBuffer.Length) + return ResultFs.TooLongPath.Log(); + + path.Slice(0, mountLength).CopyTo(outMountNameBuffer); + outMountNameBuffer[mountLength] = NullTerminator; + } + + newPath = path.Slice(mountLength); + mountNameLength = mountLength; + return Result.Success; + } + + public static Result SkipMountName(out ReadOnlySpan newPath, out int mountNameLength, + ReadOnlySpan path) + { + return ParseMountName(out newPath, out mountNameLength, Span.Empty, path); + } + + private static Result ParseWindowsPathImpl(out ReadOnlySpan newPath, out int windowsPathLength, + Span normalizeBuffer, ReadOnlySpan path, bool hasMountName) + { + Assert.SdkRequiresNotNull(path); + + UnsafeHelpers.SkipParamInit(out windowsPathLength); + newPath = default; + + if (normalizeBuffer.Length != 0) + normalizeBuffer[0] = NullTerminator; + + ReadOnlySpan currentPath = path; + + if (hasMountName && path.At(0) == DirectorySeparator) + { + if (path.At(1) == AltDirectorySeparator && path.At(2) == AltDirectorySeparator) + { + if (normalizeBuffer.Length == 0) + return ResultFs.NotNormalized.Log(); + + currentPath = path.Slice(1); + } + else if (path.Length != 0 && WindowsPath12.IsWindowsDrive(path.Slice(1))) + { + if (normalizeBuffer.Length == 0) + return ResultFs.NotNormalized.Log(); + + currentPath = path.Slice(1); + } + } + + if (WindowsPath12.IsWindowsDrive(currentPath)) + { + int winPathLength; + for (winPathLength = 2; currentPath.At(winPathLength) != NullTerminator; winPathLength++) + { + if (currentPath[winPathLength] == DirectorySeparator || + currentPath[winPathLength] == AltDirectorySeparator) + { + break; + } + } + + if (normalizeBuffer.IsEmpty) + { + for (int i = 0; i < winPathLength; i++) + { + if (currentPath[i] == '\\') + return ResultFs.NotNormalized.Log(); + } + } + + if (!normalizeBuffer.IsEmpty) + { + if (winPathLength >= normalizeBuffer.Length) + return ResultFs.TooLongPath.Log(); + + currentPath.Slice(0, winPathLength).CopyTo(normalizeBuffer); + normalizeBuffer[winPathLength] = NullTerminator; + PathUtility12.Replace(normalizeBuffer.Slice(0, winPathLength), AltDirectorySeparator, + DirectorySeparator); + } + + newPath = currentPath.Slice(winPathLength); + windowsPathLength = winPathLength; + return Result.Success; + } + + if (WindowsPath12.IsDosDevicePath(currentPath)) + { + int dosPathLength = WindowsPath12.GetDosDevicePathPrefixLength(); + + if (WindowsPath12.IsWindowsDrive(currentPath.Slice(dosPathLength))) + { + dosPathLength += 2; + } + else + { + dosPathLength--; + } + + if (!normalizeBuffer.IsEmpty) + { + if (dosPathLength >= normalizeBuffer.Length) + return ResultFs.TooLongPath.Log(); + + currentPath.Slice(0, dosPathLength).CopyTo(normalizeBuffer); + normalizeBuffer[dosPathLength] = NullTerminator; + PathUtility12.Replace(normalizeBuffer.Slice(0, dosPathLength), DirectorySeparator, + AltDirectorySeparator); + } + + newPath = currentPath.Slice(dosPathLength); + windowsPathLength = dosPathLength; + return Result.Success; + } + + if (WindowsPath12.IsUncPath(currentPath, false, true)) + { + Result rc; + + ReadOnlySpan finalPath = currentPath; + + if (currentPath.At(2) == DirectorySeparator || currentPath.At(2) == AltDirectorySeparator) + return ResultFs.InvalidPathFormat.Log(); + + int currentComponentOffset = 0; + int pos; + for (pos = 2; currentPath.At(pos) != NullTerminator; pos++) + { + if (currentPath.At(pos) == DirectorySeparator || currentPath.At(pos) == AltDirectorySeparator) + { + if (currentComponentOffset != 0) + { + rc = CheckSharedName( + currentPath.Slice(currentComponentOffset, pos - currentComponentOffset)); + if (rc.IsFailure()) return rc; + + finalPath = currentPath.Slice(pos); + break; + } + + if (currentPath.At(pos + 1) == DirectorySeparator || currentPath.At(pos + 1) == AltDirectorySeparator) + return ResultFs.InvalidPathFormat.Log(); + + rc = CheckHostName(currentPath.Slice(2, pos - 2)); + if (rc.IsFailure()) return rc; + + currentComponentOffset = pos + 1; + } + } + + if (currentComponentOffset == pos) + return ResultFs.InvalidPathFormat.Log(); + + if (currentComponentOffset != 0 && finalPath == currentPath) + { + rc = CheckSharedName(currentPath.Slice(currentComponentOffset, pos - currentComponentOffset)); + if (rc.IsFailure()) return rc; + + finalPath = currentPath.Slice(pos); + } + + ref byte currentPathStart = ref MemoryMarshal.GetReference(currentPath); + ref byte finalPathStart = ref MemoryMarshal.GetReference(finalPath); + int uncPrefixLength = (int)Unsafe.ByteOffset(ref currentPathStart, ref finalPathStart); + + if (normalizeBuffer.IsEmpty) + { + for (int i = 0; i < uncPrefixLength; i++) + { + if (currentPath[i] == DirectorySeparator) + return ResultFs.NotNormalized.Log(); + } + } + + if (!normalizeBuffer.IsEmpty) + { + if (uncPrefixLength >= normalizeBuffer.Length) + return ResultFs.TooLongPath.Log(); + + currentPath.Slice(0, uncPrefixLength).CopyTo(normalizeBuffer); + normalizeBuffer[uncPrefixLength] = NullTerminator; + PathUtility12.Replace(normalizeBuffer.Slice(0, uncPrefixLength), DirectorySeparator, AltDirectorySeparator); + } + + newPath = finalPath; + windowsPathLength = uncPrefixLength; + return Result.Success; + } + + newPath = path; + windowsPathLength = 0; + return Result.Success; + } + + public static Result ParseWindowsPath(out ReadOnlySpan newPath, out int windowsPathLength, + Span normalizeBuffer, ReadOnlySpan path, bool hasMountName) + { + return ParseWindowsPathImpl(out newPath, out windowsPathLength, normalizeBuffer, path, hasMountName); + } + + public static Result SkipWindowsPath(out ReadOnlySpan newPath, out int windowsPathLength, + out bool isNormalized, ReadOnlySpan path, bool hasMountName) + { + isNormalized = true; + + Result rc = ParseWindowsPathImpl(out newPath, out windowsPathLength, Span.Empty, path, hasMountName); + if (!rc.IsSuccess()) + { + if (ResultFs.NotNormalized.Includes(rc)) + { + isNormalized = false; + } + else + { + return rc; + } + } + + return Result.Success; + } + + private static Result ParseRelativeDotPathImpl(out ReadOnlySpan newPath, out int length, + Span relativePathBuffer, ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + UnsafeHelpers.SkipParamInit(out length); + newPath = default; + + if (relativePathBuffer.Length != 0) + relativePathBuffer[0] = NullTerminator; + + if (path.At(0) == Dot && (path.At(1) == NullTerminator || path.At(1) == DirectorySeparator || + path.At(1) == AltDirectorySeparator)) + { + if (relativePathBuffer.Length >= 2) + { + relativePathBuffer[0] = Dot; + relativePathBuffer[1] = NullTerminator; + } + + newPath = path.Slice(1); + length = 1; + return Result.Success; + } + + if (path.At(0) == Dot && path.At(1) == Dot) + return ResultFs.DirectoryUnobtainable.Log(); + + newPath = path; + length = 0; + return Result.Success; + } + + public static Result ParseRelativeDotPath(out ReadOnlySpan newPath, out int length, + Span relativePathBuffer, ReadOnlySpan path) + { + return ParseRelativeDotPathImpl(out newPath, out length, relativePathBuffer, path); + } + + public static Result SkipRelativeDotPath(out ReadOnlySpan newPath, out int length, + ReadOnlySpan path) + { + return ParseRelativeDotPathImpl(out newPath, out length, Span.Empty, path); + } + + public static Result IsNormalized(out bool isNormalized, out int normalizedLength, ReadOnlySpan path, + PathFlags flags) + { + UnsafeHelpers.SkipParamInit(out isNormalized, out normalizedLength); + + Result rc = PathUtility12.CheckUtf8(path); + if (rc.IsFailure()) return rc; + + ReadOnlySpan buffer = path; + int totalLength = 0; + + if (path.At(0) == NullTerminator) + { + if (!flags.IsEmptyPathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + isNormalized = true; + normalizedLength = 0; + return Result.Success; + } + + if (path.At(0) != DirectorySeparator && + !flags.IsWindowsPathAllowed() && + !flags.IsRelativePathAllowed() && + !flags.IsMountNameAllowed()) + { + return ResultFs.InvalidPathFormat.Log(); + } + + if (WindowsPath12.IsWindowsPath(path, false) && !flags.IsWindowsPathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + bool hasMountName = false; + + rc = SkipMountName(out buffer, out int mountNameLength, buffer); + if (rc.IsFailure()) return rc; + + if (mountNameLength != 0) + { + if (!flags.IsMountNameAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + totalLength += mountNameLength; + hasMountName = true; + } + + if (buffer.At(0) != DirectorySeparator && !PathUtility12.IsPathStartWithCurrentDirectory(buffer) && + !WindowsPath12.IsWindowsPath(buffer, false)) + { + if (!flags.IsRelativePathAllowed() || !PathUtility12.CheckInvalidCharacter(buffer.At(0)).IsSuccess()) + return ResultFs.InvalidPathFormat.Log(); + + isNormalized = false; + return Result.Success; + } + + bool isRelativePath = false; + + rc = SkipRelativeDotPath(out buffer, out int relativePathLength, buffer); + if (rc.IsFailure()) return rc; + + if (relativePathLength != 0) + { + if (!flags.IsRelativePathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + totalLength += relativePathLength; + + if (buffer.At(0) == NullTerminator) + { + isNormalized = true; + normalizedLength = totalLength; + return Result.Success; + } + + isRelativePath = true; + } + + rc = SkipWindowsPath(out buffer, out int windowsPathLength, out bool isNormalizedWin, buffer, hasMountName); + if (rc.IsFailure()) return rc; + + if (!isNormalizedWin) + { + if (!flags.IsWindowsPathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + isNormalized = false; + return Result.Success; + } + + if (windowsPathLength != 0) + { + if (!flags.IsWindowsPathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + totalLength += windowsPathLength; + + if (isRelativePath) + return ResultFs.InvalidPathFormat.Log(); + + if (buffer.At(0) == NullTerminator) + { + isNormalized = false; + return Result.Success; + } + + for (int i = 0; i < buffer.Length; i++) + { + if (buffer[i] == AltDirectorySeparator) + { + isNormalized = false; + return Result.Success; + } + } + } + + if (PathNormalizer12.IsParentDirectoryPathReplacementNeeded(buffer)) + return ResultFs.DirectoryUnobtainable.Log(); + + rc = PathUtility12.CheckInvalidBackslash(out bool isBackslashContained, buffer, + flags.IsWindowsPathAllowed() || flags.IsBackslashAllowed()); + if (rc.IsFailure()) return rc; + + if (isBackslashContained && !flags.IsBackslashAllowed()) + { + isNormalized = false; + return Result.Success; + } + + rc = PathNormalizer12.IsNormalized(out isNormalized, out int length, buffer); + if (rc.IsFailure()) return rc; + + totalLength += length; + normalizedLength = totalLength; + return Result.Success; + } + + public static Result Normalize(Span outputBuffer, ReadOnlySpan path, PathFlags flags) + { + Result rc; + + ReadOnlySpan src = path; + int currentPos = 0; + bool isWindowsPath = false; + + if (path.At(0) == NullTerminator) + { + if (!flags.IsEmptyPathAllowed()) + return ResultFs.InvalidPathFormat.Log(); + + if (outputBuffer.Length != 0) + outputBuffer[0] = NullTerminator; + + return Result.Success; + } + + bool hasMountName = false; + + if (flags.IsMountNameAllowed()) + { + rc = ParseMountName(out src, out int mountNameLength, outputBuffer.Slice(currentPos), src); + if (rc.IsFailure()) return rc; + + currentPos += mountNameLength; + hasMountName = mountNameLength != 0; + } + + bool isDriveRelative = false; + + if (src.At(0) != DirectorySeparator && !PathUtility12.IsPathStartWithCurrentDirectory(src) && + !WindowsPath12.IsWindowsPath(src, false)) + { + if (!flags.IsRelativePathAllowed() || !PathUtility12.CheckInvalidCharacter(src.At(0)).IsSuccess()) + return ResultFs.InvalidPathFormat.Log(); + + outputBuffer[currentPos++] = Dot; + isDriveRelative = true; + } + + if (flags.IsRelativePathAllowed()) + { + if (currentPos >= outputBuffer.Length) + return ResultFs.TooLongPath.Log(); + + rc = ParseRelativeDotPath(out src, out int relativePathLength, outputBuffer.Slice(currentPos), src); + if (rc.IsFailure()) return rc; + + currentPos += relativePathLength; + + if (src.At(0) == NullTerminator) + { + if (currentPos >= outputBuffer.Length) + return ResultFs.TooLongPath.Log(); + + outputBuffer[currentPos] = NullTerminator; + return Result.Success; + } + } + + if (flags.IsWindowsPathAllowed()) + { + ReadOnlySpan originalPath = src; + + if (currentPos >= outputBuffer.Length) + return ResultFs.TooLongPath.Log(); + + rc = ParseWindowsPath(out src, out int windowsPathLength, outputBuffer.Slice(currentPos), src, + hasMountName); + if (rc.IsFailure()) return rc; + + currentPos += windowsPathLength; + + if (src.At(0) == NullTerminator) + { + // Note: Bug is in the original code. Should be "currentPos + 2" + if (currentPos + 1 >= outputBuffer.Length) + return ResultFs.TooLongPath.Log(); + + outputBuffer[currentPos] = DirectorySeparator; + outputBuffer[currentPos + 1] = NullTerminator; + return Result.Success; + } + + int skippedLength = (int)Unsafe.ByteOffset(ref MemoryMarshal.GetReference(originalPath), + ref MemoryMarshal.GetReference(src)); + + if (skippedLength > 0) + isWindowsPath = true; + } + + rc = PathUtility12.CheckInvalidBackslash(out bool isBackslashContained, src, + flags.IsWindowsPathAllowed() || flags.IsBackslashAllowed()); + if (rc.IsFailure()) return rc; + + byte[] srcBufferSlashReplaced = null; + try + { + if (isBackslashContained && flags.IsWindowsPathAllowed()) + { + srcBufferSlashReplaced = ArrayPool.Shared.Rent(path.Length); + + StringUtils.Copy(srcBufferSlashReplaced, path); + PathUtility12.Replace(srcBufferSlashReplaced, AltDirectorySeparator, DirectorySeparator); + + int srcOffset = (int)Unsafe.ByteOffset(ref MemoryMarshal.GetReference(path), + ref MemoryMarshal.GetReference(src)); + + src = srcBufferSlashReplaced.AsSpan(srcOffset); + } + + rc = PathNormalizer12.Normalize(outputBuffer.Slice(currentPos), out _, src, isWindowsPath, isDriveRelative); + if (rc.IsFailure()) return rc; + + return Result.Success; + } + finally + { + if (srcBufferSlashReplaced is not null) + { + ArrayPool.Shared.Return(srcBufferSlashReplaced); + } + } + } + + public static Result CheckPathFormat(ReadOnlySpan path, PathFlags flags) + { + return Result.Success; + } + } +} diff --git a/src/LibHac/Fs/Common/PathNormalizer12.cs b/src/LibHac/Fs/Common/PathNormalizer12.cs new file mode 100644 index 00000000..b822484d --- /dev/null +++ b/src/LibHac/Fs/Common/PathNormalizer12.cs @@ -0,0 +1,367 @@ +using System; +using LibHac.Common; +using LibHac.Diag; +using LibHac.FsSystem; +using static LibHac.Fs.Common.PathUtility12; +using static LibHac.Fs.StringTraits; + +namespace LibHac.Fs.Common +{ + public static class PathNormalizer12 + { + private enum PathState + { + Initial, + Normal, + FirstSeparator, + Separator, + CurrentDir, + ParentDir + } + + public static Result Normalize(Span outputBuffer, out int length, ReadOnlySpan path, bool isWindowsPath, + bool isDriveRelativePath) + { + UnsafeHelpers.SkipParamInit(out length); + + ReadOnlySpan currentPath = path; + int totalLength = 0; + int i = 0; + + if (!IsSeparator(path.At(0))) + { + if (!isDriveRelativePath) + return ResultFs.InvalidPathFormat.Log(); + + outputBuffer[totalLength++] = DirectorySeparator; + } + + var convertedPath = new RentedArray(); + try + { + // Check if parent directory path replacement is needed. + if (IsParentDirectoryPathReplacementNeeded(currentPath)) + { + // Allocate a buffer to hold the replacement path. + convertedPath = new RentedArray(PathTools.MaxPathLength + 1); + + // Replace the path. + ReplaceParentDirectoryPath(convertedPath.Span, currentPath); + + // Set current path to be the replacement path. + currentPath = new U8Span(convertedPath.Span); + } + + bool skipNextSeparator = false; + + while (!IsNul(currentPath.At(i))) + { + if (IsSeparator(currentPath[i])) + { + do + { + i++; + } while (IsSeparator(currentPath.At(i))); + + if (IsNul(currentPath.At(i))) + break; + + if (!skipNextSeparator) + { + if (totalLength + 1 == outputBuffer.Length) + { + outputBuffer[totalLength] = NullTerminator; + length = totalLength; + + return ResultFs.TooLongPath.Log(); + } + + outputBuffer[totalLength++] = DirectorySeparator; + } + + skipNextSeparator = false; + } + + int dirLen = 0; + while (!IsSeparator(currentPath.At(i + dirLen)) && !IsNul(currentPath.At(i + dirLen))) + { + dirLen++; + } + + if (IsCurrentDirectory(currentPath.Slice(i))) + { + skipNextSeparator = true; + } + else if (IsParentDirectory(currentPath.Slice(i))) + { + Assert.SdkAssert(outputBuffer[totalLength - 1] == DirectorySeparator); + + if (!isWindowsPath) + Assert.SdkAssert(outputBuffer[0] == DirectorySeparator); + + if (totalLength == 1) + { + if (!isWindowsPath) + return ResultFs.DirectoryUnobtainable.Log(); + + totalLength--; + } + else + { + totalLength -= 2; + + do + { + if (outputBuffer[totalLength] == DirectorySeparator) + break; + + totalLength--; + } while (totalLength != 0); + } + + if (!isWindowsPath) + Assert.SdkAssert(outputBuffer[totalLength] == DirectorySeparator); + + Assert.SdkAssert(totalLength < outputBuffer.Length); + } + else + { + if (totalLength + dirLen + 1 > outputBuffer.Length) + { + int copyLen = outputBuffer.Length - 1 - totalLength; + + for (int j = 0; j < copyLen; j++) + { + outputBuffer[totalLength++] = currentPath[i + j]; + } + + outputBuffer[totalLength] = NullTerminator; + length = totalLength; + return ResultFs.TooLongPath.Log(); + } + + for (int j = 0; j < dirLen; j++) + { + outputBuffer[totalLength++] = currentPath[i + j]; + } + } + + i += dirLen; + } + + if (skipNextSeparator) + totalLength--; + + if (totalLength == 0 && outputBuffer.Length != 0) + { + totalLength = 1; + outputBuffer[0] = DirectorySeparator; + } + + // Note: This bug is in the original code. They probably meant to put "totalLength + 1" + if (totalLength - 1 > outputBuffer.Length) + return ResultFs.TooLongPath.Log(); + + outputBuffer[totalLength] = NullTerminator; + + Result rc = IsNormalized(out bool isNormalized, out _, outputBuffer); + if (rc.IsFailure()) return rc; + + Assert.SdkAssert(isNormalized); + + length = totalLength; + return Result.Success; + } + finally + { + convertedPath.Dispose(); + } + } + + /// + /// Checks if a given path is normalized. Path must be a basic path, starting with a directory separator + /// and not containing any sort of prefix such as a mount name. + /// + /// When this function returns , + /// contains if the path is normalized or if it is not. + /// Contents are undefined if the function does not return . + /// + /// When this function returns and + /// is , contains the length of the normalized path. + /// Contents are undefined if the function does not return + /// or is . + /// + /// The path to check. + /// : The operation was successful.
+ /// : The path contains an invalid character.
+ /// : The path is not in a valid format.
+ public static Result IsNormalized(out bool isNormalized, out int length, ReadOnlySpan path) + { + UnsafeHelpers.SkipParamInit(out isNormalized, out length); + + var state = PathState.Initial; + int pathLength = 0; + + for (int i = 0; i < path.Length; i++) + { + byte c = path[i]; + if (c == NullTerminator) break; + + pathLength++; + + if (state != PathState.Initial) + { + Result rc = CheckInvalidCharacter(c); + if (rc.IsFailure()) return rc; + } + + switch (state) + { + case PathState.Initial: + if (c != DirectorySeparator) + return ResultFs.InvalidPathFormat.Log(); + + state = PathState.FirstSeparator; + + break; + case PathState.Normal: + + if (c == DirectorySeparator) + state = PathState.Separator; + + break; + case PathState.FirstSeparator: + case PathState.Separator: + if (c == DirectorySeparator) + { + isNormalized = false; + return Result.Success; + } + + state = c == Dot ? PathState.CurrentDir : PathState.Normal; + break; + case PathState.CurrentDir: + if (c == DirectorySeparator) + { + isNormalized = false; + return Result.Success; + } + + state = c == Dot ? PathState.ParentDir : PathState.Normal; + break; + case PathState.ParentDir: + if (c == DirectorySeparator) + { + isNormalized = false; + return Result.Success; + } + + state = PathState.Normal; + break; + // ReSharper disable once UnreachableSwitchCaseDueToIntegerAnalysis + default: + Abort.UnexpectedDefault(); + break; + } + } + + switch (state) + { + case PathState.Initial: + return ResultFs.InvalidPathFormat.Log(); + case PathState.Normal: + case PathState.FirstSeparator: + isNormalized = true; + break; + case PathState.Separator: + case PathState.CurrentDir: + case PathState.ParentDir: + isNormalized = false; + break; + // ReSharper disable once UnreachableSwitchCaseDueToIntegerAnalysis + default: + Abort.UnexpectedDefault(); + break; + } + + length = pathLength; + return Result.Success; + } + + + /// + /// Checks if a path begins with / or \ and contains any of these patterns: + /// "/..\", "\..\", "\../", "\..0" where '0' is the null terminator. + /// + public static bool IsParentDirectoryPathReplacementNeeded(ReadOnlySpan path) + { + if (path.Length == 0 || (path[0] != DirectorySeparator && path[0] != AltDirectorySeparator)) + return false; + + for (int i = 0; i < path.Length - 2 && path[i] != NullTerminator; i++) + { + byte c3 = path.At(i + 3); + + if (path[i] == AltDirectorySeparator && + path[i + 1] == Dot && + path[i + 2] == Dot && + (c3 == DirectorySeparator || c3 == AltDirectorySeparator || c3 == NullTerminator)) + { + return true; + } + + if ((path[i] == DirectorySeparator || path[i] == AltDirectorySeparator) && + path[i + 1] == Dot && + path[i + 2] == Dot && + c3 == AltDirectorySeparator) + { + return true; + } + } + + return false; + } + + private static void ReplaceParentDirectoryPath(Span dest, ReadOnlySpan source) + { + dest[0] = DirectorySeparator; + + int i = 1; + while (source.Length > i && source[i] != NullTerminator) + { + if (source.Length > i + 2 && + (source[i - 1] == DirectorySeparator || source[i - 1] == AltDirectorySeparator) && + source[i + 0] == Dot && + source[i + 1] == Dot && + (source[i + 2] == DirectorySeparator || source[i + 2] == AltDirectorySeparator)) + { + dest[i - 1] = DirectorySeparator; + dest[i + 0] = Dot; + dest[i + 1] = Dot; + dest[i + 2] = DirectorySeparator; + i += 3; + } + else + { + if (source.Length > i + 1 && + source[i - 1] == AltDirectorySeparator && + source[i + 0] == Dot && + source[i + 1] == Dot && + (source.Length == i + 2 || source[i + 2] == NullTerminator)) + { + dest[i - 1] = DirectorySeparator; + dest[i + 0] = Dot; + dest[i + 1] = Dot; + i += 2; + break; + } + + dest[i] = source[i]; + i++; + } + } + + dest[i] = NullTerminator; + } + } +} diff --git a/src/LibHac/Fs/Common/PathUtility12.cs b/src/LibHac/Fs/Common/PathUtility12.cs new file mode 100644 index 00000000..9edc8828 --- /dev/null +++ b/src/LibHac/Fs/Common/PathUtility12.cs @@ -0,0 +1,245 @@ +using System; +using System.Runtime.CompilerServices; +using LibHac.Common; +using LibHac.Diag; +using LibHac.FsSrv.Sf; +using LibHac.Util; +using static LibHac.Fs.StringTraits; + +namespace LibHac.Fs.Common +{ + public static class PathUtility12 + { + public static void Replace(Span buffer, byte currentChar, byte newChar) + { + Assert.SdkRequiresNotNull(buffer); + + for (int i = 0; i < buffer.Length; i++) + { + if (buffer[i] == currentChar) + { + buffer[i] = newChar; + } + } + } + + public static bool IsCurrentDirectory(ReadOnlySpan path) + { + if (path.Length < 1) + return false; + + return path[0] == Dot && + (path.Length < 2 || path[1] == NullTerminator || path[1] == DirectorySeparator); + } + + public static bool IsParentDirectory(ReadOnlySpan path) + { + if (path.Length < 2) + return false; + + return path[0] == Dot && + path[1] == Dot && + (path.Length < 3 || path[2] == NullTerminator || path[2] == DirectorySeparator); + } + + public static bool IsSeparator(byte c) + { + return c == DirectorySeparator; + } + + public static bool IsNul(byte c) + { + return c == NullTerminator; + } + + public static Result ConvertToFspPath(out FspPath fspPath, ReadOnlySpan path) + { + UnsafeHelpers.SkipParamInit(out fspPath); + + int length = StringUtils.Copy(SpanHelpers.AsByteSpan(ref fspPath), path, PathTool.EntryNameLengthMax + 1); + + if (length >= PathTool.EntryNameLengthMax + 1) + return ResultFs.TooLongPath.Log(); + + Result rc = PathFormatter.SkipMountName(out ReadOnlySpan pathWithoutMountName, out _, + new U8Span(path)); + if (rc.IsFailure()) return rc; + + if (!WindowsPath12.IsWindowsPath(pathWithoutMountName, true)) + { + Replace(SpanHelpers.AsByteSpan(ref fspPath).Slice(0, 0x300), AltDirectorySeparator, DirectorySeparator); + } + else if (fspPath.Str[0] == DirectorySeparator && fspPath.Str[1] == DirectorySeparator) + { + SpanHelpers.AsByteSpan(ref fspPath)[0] = AltDirectorySeparator; + SpanHelpers.AsByteSpan(ref fspPath)[1] = AltDirectorySeparator; + } + + return Result.Success; + } + + public static bool IsDirectoryPath(ReadOnlySpan path) + { + if (path.Length < 1 || path[0] == NullTerminator) + return false; + + int length = StringUtils.GetLength(path); + return path[length - 1] == DirectorySeparator || path[length - 1] == AltDirectorySeparator; + } + + public static bool IsDirectoryPath(in FspPath path) + { + return IsDirectoryPath(SpanHelpers.AsReadOnlyByteSpan(in path)); + } + + public static Result CheckUtf8(ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + uint utf8Buffer = 0; + Span utf8BufferSpan = SpanHelpers.AsByteSpan(ref utf8Buffer); + + ReadOnlySpan currentChar = path; + + while (currentChar.Length > 0 && currentChar[0] != NullTerminator) + { + utf8BufferSpan.Clear(); + + CharacterEncodingResult result = + CharacterEncoding.PickOutCharacterFromUtf8String(utf8BufferSpan, ref currentChar); + + if (result != CharacterEncodingResult.Success) + return ResultFs.InvalidPathFormat.Log(); + + result = CharacterEncoding.ConvertCharacterUtf8ToUtf32(out _, utf8BufferSpan); + + if (result != CharacterEncodingResult.Success) + return ResultFs.InvalidPathFormat.Log(); + } + + return Result.Success; + } + + public static Result CheckInvalidCharacter(byte c) + { + /* + The optimized code is equivalent to this: + + ReadOnlySpan invalidChars = new[] + {(byte) ':', (byte) '*', (byte) '?', (byte) '<', (byte) '>', (byte) '|'}; + + for (int i = 0; i < invalidChars.Length; i++) + { + if (c == invalidChars[i]) + return ResultFs.InvalidCharacter.Log(); + } + + return Result.Success; + */ + + const ulong mask = (1ul << (byte)':') | + (1ul << (byte)'*') | + (1ul << (byte)'?') | + (1ul << (byte)'<') | + (1ul << (byte)'>'); + + if (c <= 0x3Fu && ((1ul << c) & mask) != 0 || c == (byte)'|') + return ResultFs.InvalidCharacter.Log(); + + return Result.Success; + } + + public static Result CheckInvalidBackslash(out bool containsBackslash, ReadOnlySpan path, bool allowBackslash) + { + containsBackslash = false; + + for (int i = 0; i < path.Length && path[i] != NullTerminator; i++) + { + if (path[i] == '\\') + { + containsBackslash = true; + + if (!allowBackslash) + return ResultFs.InvalidCharacter.Log(); + } + } + + return Result.Success; + } + + public static Result CheckEntryNameBytes(ReadOnlySpan path, int maxEntryLength) + { + Assert.SdkRequiresNotNull(path); + + int currentEntryLength = 0; + + for (int i = 0; i < path.Length && path[i] != NullTerminator; i++) + { + currentEntryLength++; + + if (path[i] == DirectorySeparator || path[i] == AltDirectorySeparator) + currentEntryLength = 0; + + // Note: The original does use >= instead of > + if (currentEntryLength >= maxEntryLength) + return ResultFs.TooLongPath.Log(); + } + + return Result.Success; + } + + public static bool IsSubPath(ReadOnlySpan lhs, ReadOnlySpan rhs) + { + Assert.SdkRequiresNotNull(lhs); + Assert.SdkRequiresNotNull(rhs); + + if (WindowsPath12.IsUncPath(lhs) && !WindowsPath12.IsUncPath(rhs)) + return false; + + if (!WindowsPath12.IsUncPath(lhs) && WindowsPath12.IsUncPath(rhs)) + return false; + + if (lhs.At(0) == DirectorySeparator && lhs.At(1) == NullTerminator && + rhs.At(0) == DirectorySeparator && rhs.At(1) != NullTerminator) + return true; + + if (rhs.At(0) == DirectorySeparator && rhs.At(1) == NullTerminator && + lhs.At(0) == DirectorySeparator && lhs.At(1) != NullTerminator) + return true; + + for (int i = 0; ; i++) + { + if (lhs.At(i) == NullTerminator) + { + return rhs.At(i) == DirectorySeparator; + } + else if (rhs.At(i) == NullTerminator) + { + return lhs.At(i) == DirectorySeparator; + } + else if (lhs.At(i) != rhs.At(i)) + { + return false; + } + } + } + + public static bool IsPathAbsolute(ReadOnlySpan path) + { + if (WindowsPath12.IsWindowsPath(path, false)) + return true; + + return path.At(0) == DirectorySeparator; + } + + public static bool IsPathRelative(ReadOnlySpan path) + { + return path.At(0) != NullTerminator && !IsPathAbsolute(path); + } + + public static bool IsPathStartWithCurrentDirectory(ReadOnlySpan path) + { + return IsCurrentDirectory(path) || IsParentDirectory(path); + } + } +} diff --git a/src/LibHac/Fs/Common/WindowsPath12.cs b/src/LibHac/Fs/Common/WindowsPath12.cs new file mode 100644 index 00000000..07de8fc4 --- /dev/null +++ b/src/LibHac/Fs/Common/WindowsPath12.cs @@ -0,0 +1,249 @@ +using System; +using LibHac.Common; +using LibHac.Diag; +using LibHac.Util; +using static LibHac.Util.CharacterEncoding; + +namespace LibHac.Fs.Common +{ + public static class WindowsPath12 + { + public static int GetCodePointByteLength(byte firstCodeUnit) + { + if ((firstCodeUnit & 0x80) == 0x00) return 1; + if ((firstCodeUnit & 0xE0) == 0xC0) return 2; + if ((firstCodeUnit & 0xF0) == 0xE0) return 3; + if ((firstCodeUnit & 0xF8) == 0xF0) return 4; + return 0; + } + + private static bool IsUncPathImpl(ReadOnlySpan path, bool checkForwardSlash, bool checkBackSlash) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 2) + return false; + + if (checkForwardSlash && path[0] == '/' && path[1] == '/') + return true; + + return checkBackSlash && path[0] == '\\' && path[1] == '\\'; + } + + private static bool IsUncPathImpl(ReadOnlySpan path, bool checkForwardSlash, bool checkBackSlash) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 2) + return false; + + if (checkForwardSlash && path[0] == '/' && path[1] == '/') + return true; + + return checkBackSlash && path[0] == '\\' && path[1] == '\\'; + } + + private static int GetUncPathPrefixLengthImpl(ReadOnlySpan path, bool checkForwardSlash) + { + Assert.SdkRequiresNotNull(path); + + int length; + int separatorCount = 0; + + for (length = 0; length < path.Length && path[length] != 0; length++) + { + if (checkForwardSlash && path[length] == '/') + ++separatorCount; + + if (path[length] == '\\') + ++separatorCount; + + if (separatorCount == 4) + return length; + } + + return length; + } + + private static int GetUncPathPrefixLengthImpl(ReadOnlySpan path, bool checkForwardSlash) + { + Assert.SdkRequiresNotNull(path); + + int length; + int separatorCount = 0; + + for (length = 0; length < path.Length && path[length] != 0; length++) + { + if (checkForwardSlash && path[length] == '/') + ++separatorCount; + + if (path[length] == '\\') + ++separatorCount; + + if (separatorCount == 4) + return length; + } + + return length; + } + + private static bool IsDosDevicePathImpl(ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 4) + return false; + + return path[0] == '\\' && + path[1] == '\\' && + (path[2] == '.' || path[2] == '?') && + (path[3] == '/' || path[3] == '\\'); + } + + private static bool IsDosDevicePathImpl(ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 4) + return false; + + return path[0] == '\\' && + path[1] == '\\' && + (path[2] == '.' || path[2] == '?') && + (path[3] == '/' || path[3] == '\\'); + } + + public static bool IsWindowsDrive(ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 2) + return false; + + // Mask lowercase letters to uppercase and check if it's in range + return ((0b1101_1111 & path[0]) - 'A' <= 'Z' - 'A') && path[1] == ':'; + // return ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') && path[1] == ':'; + } + + public static bool IsUncPath(ReadOnlySpan path) + { + return IsUncPathImpl(path, true, true); + } + + public static bool IsUncPath(ReadOnlySpan path, bool checkForwardSlash, bool checkBackSlash) + { + return IsUncPathImpl(path, checkForwardSlash, checkBackSlash); + } + + public static int GetUncPathPrefixLength(ReadOnlySpan path) + { + return GetUncPathPrefixLengthImpl(path, true); + } + + public static bool IsDosDevicePath(ReadOnlySpan path) + { + return IsDosDevicePathImpl(path); + } + + public static int GetDosDevicePathPrefixLength() + { + return 4; + } + + public static bool IsWindowsPath(ReadOnlySpan path, bool checkForwardSlash) + { + return IsWindowsDrive(path) || IsDosDevicePath(path) || IsUncPath(path, checkForwardSlash, true); + } + + public static int GetWindowsSkipLength(ReadOnlySpan path) + { + if (IsDosDevicePath(path)) + return GetDosDevicePathPrefixLength(); + + if (IsWindowsDrive(path)) + return 2; + + if (IsUncPath(path)) + return GetUncPathPrefixLength(path); + + return 0; + } + + public static bool IsDosDelimiterW(char c) + { + return c == '/' || c == '\\'; + } + + public static bool IsWindowsDriveW(ReadOnlySpan path) + { + Assert.SdkRequiresNotNull(path); + + if ((uint)path.Length < 2) + return false; + + // Mask lowercase letters to uppercase and check if it's in range + return ((0b1101_1111 & path[0]) - 'A' <= 'Z' - 'A') && path[1] == ':'; + // return ('a' <= c && c <= 'z' || 'A' <= c && c <= 'Z') && path[1] == ':'; + } + + public static bool IsUncPathW(ReadOnlySpan path) + { + return IsUncPathImpl(path, true, true); + } + + public static int GetUncPathPrefixLengthW(ReadOnlySpan path) + { + return GetUncPathPrefixLengthImpl(path, true); + } + + public static bool IsDosDevicePathW(ReadOnlySpan path) + { + return IsDosDevicePathImpl(path); + } + + public static bool IsWindowsPathW(ReadOnlySpan path) + { + return IsWindowsDriveW(path) || IsUncPathW(path) || IsDosDevicePathW(path); + } + + public static Result CheckCharacterCountForWindows(ReadOnlySpan path, int maxNameLength, int maxPathLength) + { + Assert.SdkRequiresNotNull(path); + + ReadOnlySpan currentChar = path; + int currentNameLength = 0; + int currentPathLength = 0; + + while (currentChar.Length > 1 && currentChar[0] != 0) + { + int utf16CodeUnitCount = GetCodePointByteLength(currentChar[0]) < 4 ? 1 : 2; + + int utf8Buffer = 0; + CharacterEncodingResult result = + PickOutCharacterFromUtf8String(SpanHelpers.AsByteSpan(ref utf8Buffer), ref currentChar); + + if (result != CharacterEncodingResult.Success) + return ResultFs.InvalidPathFormat.Log(); + + result = ConvertCharacterUtf8ToUtf32(out uint pathChar, SpanHelpers.AsReadOnlyByteSpan(in utf8Buffer)); + + if (result != CharacterEncodingResult.Success) + return ResultFs.InvalidPathFormat.Log(); + + currentNameLength += utf16CodeUnitCount; + currentPathLength += utf16CodeUnitCount; + + if (pathChar == '/' || pathChar == '\\') + currentNameLength = 0; + + if (maxNameLength > 0 && currentNameLength > maxNameLength) + return ResultFs.TooLongPath.Log(); + + if (maxPathLength > 0 && currentPathLength > maxPathLength) + return ResultFs.TooLongPath.Log(); + } + + return Result.Success; + } + } +} diff --git a/tests/LibHac.Tests/Fs/PathFormatterTests.cs b/tests/LibHac.Tests/Fs/PathFormatterTests.cs new file mode 100644 index 00000000..16842a8b --- /dev/null +++ b/tests/LibHac.Tests/Fs/PathFormatterTests.cs @@ -0,0 +1,396 @@ +// ReSharper disable InconsistentNaming + +using System; +using LibHac.Common; +using LibHac.Fs; +using LibHac.Fs.Common; +using LibHac.Util; +using Xunit; + +namespace LibHac.Tests.Fs +{ + public class PathFormatterTests + { + public static TheoryData TestData_Normalize_EmptyPath => new() + { + { @"", "", @"", ResultFs.InvalidPathFormat.Value }, + { @"", "E", @"", Result.Success }, + { @"/aa/bb/../cc", "E", @"/aa/cc", Result.Success } + }; + + [Theory, MemberData(nameof(TestData_Normalize_EmptyPath))] + public static void Normalize_EmptyPath(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_MountName => new() + { + { @"mount:/aa/bb", "", @"", ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa/bb", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa/bb", "M", @"mount:/aa/bb", Result.Success }, + { @"mount:/aa/./bb", "M", @"mount:/aa/bb", Result.Success }, + { @"mount:\aa\bb", "M", @"mount:", ResultFs.InvalidPathFormat.Value }, + { @"m:/aa/bb", "M", @"", ResultFs.InvalidPathFormat.Value }, + { @"mo>unt:/aa/bb", "M", @"", ResultFs.InvalidCharacter.Value }, + { @"moun?t:/aa/bb", "M", @"", ResultFs.InvalidCharacter.Value }, + { @"mo&unt:/aa/bb", "M", @"mo&unt:/aa/bb", Result.Success }, + { @"/aa/./bb", "M", @"/aa/bb", Result.Success }, + { @"mount/aa/./bb", "M", @"", ResultFs.InvalidPathFormat.Value } + }; + + [Theory, MemberData(nameof(TestData_Normalize_MountName))] + public static void Normalize_MountName(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_WindowsPath => new() + { + { @"c:/aa/bb", "", @"", ResultFs.InvalidPathFormat.Value }, + { @"c:\aa\bb", "", @"", ResultFs.InvalidCharacter.Value }, + { @"\\host\share", "", @"", ResultFs.InvalidCharacter.Value }, + { @"\\.\c:\", "", @"", ResultFs.InvalidCharacter.Value }, + { @"\\.\c:/aa/bb/.", "", @"", ResultFs.InvalidCharacter.Value }, + { @"\\?\c:\", "", @"", ResultFs.InvalidCharacter.Value }, + { @"mount:\\host\share\aa\bb", "M", @"mount:", ResultFs.InvalidCharacter.Value }, + { @"mount:\\host/share\aa\bb", "M", @"mount:", ResultFs.InvalidCharacter.Value }, + { @"mount:/\\aa\..\bb", "MW", @"mount:", ResultFs.InvalidPathFormat.Value }, + { @"mount:/c:\aa\..\bb", "MW", @"mount:c:/bb", Result.Success }, + { @"mount:/aa/bb", "MW", @"mount:/aa/bb", Result.Success }, + { @"/mount:/aa/bb", "MW", @"/mount:/aa/bb", ResultFs.InvalidCharacter.Value }, + { @"/mount:/aa/bb", "W", @"/mount:/aa/bb", ResultFs.InvalidCharacter.Value }, + { @"a:aa/../bb", "MW", @"a:aa/bb", Result.Success }, + { @"a:aa\..\bb", "MW", @"a:aa/bb", Result.Success }, + { @"/a:aa\..\bb", "W", @"/bb", Result.Success }, + { @"\\?\c:\.\aa", "W", @"\\?\c:/aa", Result.Success }, + { @"\\.\c:\.\aa", "W", @"\\.\c:/aa", Result.Success }, + { @"\\.\mount:\.\aa", "W", @"\\./mount:/aa", ResultFs.InvalidCharacter.Value }, + { @"\\./.\aa", "W", @"\\./aa", Result.Success }, + { @"\\/aa", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\\aa", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\", "W", @"/", Result.Success }, + { @"\\host\share", "W", @"\\host\share/", Result.Success }, + { @"\\host\share\path", "W", @"\\host\share/path", Result.Success }, + { @"\\host\share\path\aa\bb\..\cc\.", "W", @"\\host\share/path/aa/cc", Result.Success }, + { @"\\host\", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\ho$st\share\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\host:\share\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\..\share\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\host\s:hare\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\host\.\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\host\..\path", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @"\\host\sha:re", "W", @"", ResultFs.InvalidPathFormat.Value }, + { @".\\host\share", "RW", @"..\\host\share/", Result.Success } + }; + + [Theory, MemberData(nameof(TestData_Normalize_WindowsPath))] + public static void Normalize_WindowsPath(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_RelativePath => new() + { + { @"./aa/bb", "", @"", ResultFs.InvalidPathFormat.Value }, + { @"./aa/bb/../cc", "R", @"./aa/cc", Result.Success }, + { @".\aa/bb/../cc", "R", @"..", ResultFs.InvalidCharacter.Value }, + { @".", "R", @".", Result.Success }, + { @"../aa/bb", "R", @"", ResultFs.DirectoryUnobtainable.Value }, + { @"/aa/./bb", "R", @"/aa/bb", Result.Success }, + { @"mount:./aa/bb", "MR", @"mount:./aa/bb", Result.Success }, + { @"mount:./aa/./bb", "MR", @"mount:./aa/bb", Result.Success }, + { @"mount:./aa/bb", "M", @"mount:", ResultFs.InvalidPathFormat.Value } + }; + + [Theory, MemberData(nameof(TestData_Normalize_RelativePath))] + public static void Normalize_RelativePath(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_Backslash => new() + { + { @"\aa\bb\..\cc", "", @"", ResultFs.InvalidPathFormat.Value }, + { @"\aa\bb\..\cc", "B", @"", ResultFs.InvalidPathFormat.Value }, + { @"/aa\bb\..\cc", "", @"", ResultFs.InvalidCharacter.Value }, + { @"/aa\bb\..\cc", "B", @"/cc", Result.Success }, + { @"/aa\bb\cc", "", @"", ResultFs.InvalidCharacter.Value }, + { @"/aa\bb\cc", "B", @"/aa\bb\cc", Result.Success }, + { @"\\host\share\path\aa\bb\cc", "W", @"\\host\share/path/aa/bb/cc", Result.Success }, + { @"\\host\share\path\aa\bb\cc", "WB", @"\\host\share/path/aa/bb/cc", Result.Success }, + { @"/aa/bb\../cc/..\dd\..\ee/..", "", @"", ResultFs.InvalidCharacter.Value }, + { @"/aa/bb\../cc/..\dd\..\ee/..", "B", @"/aa", Result.Success } + }; + + [Theory, MemberData(nameof(TestData_Normalize_Backslash))] + public static void Normalize_Backslash(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_All => new() + { + { @"mount:./aa/bb", "WRM", @"mount:./aa/bb", Result.Success }, + { @"mount:./aa/bb\cc/dd", "WRM", @"mount:./aa/bb/cc/dd", Result.Success }, + { @"mount:./aa/bb\cc/dd", "WRMB", @"mount:./aa/bb/cc/dd", Result.Success }, + { @"mount:./.c:/aa/bb", "RM", @"mount:./.c:/aa/bb", ResultFs.InvalidCharacter.Value }, + { @"mount:.c:/aa/bb", "WRM", @"mount:./.c:/aa/bb", ResultFs.InvalidCharacter.Value }, + { @"mount:./cc:/aa/bb", "WRM", @"mount:./cc:/aa/bb", ResultFs.InvalidCharacter.Value }, + { @"mount:./\\host\share/aa/bb", "MW", @"mount:", ResultFs.InvalidPathFormat.Value }, + { @"mount:./\\host\share/aa/bb", "WRM", @"mount:.\\host\share/aa/bb", Result.Success }, + { @"mount:.\\host\share/aa/bb", "WRM", @"mount:..\\host\share/aa/bb", Result.Success }, + { @"mount:..\\host\share/aa/bb", "WRM", @"mount:.", ResultFs.DirectoryUnobtainable.Value }, + { @".\\host\share/aa/bb", "WRM", @"..\\host\share/aa/bb", Result.Success }, + { @"..\\host\share/aa/bb", "WRM", @".", ResultFs.DirectoryUnobtainable.Value }, + { @"mount:\\host\share/aa/bb", "MW", @"mount:\\host\share/aa/bb", Result.Success }, + { @"mount:\aa\bb", "BM", @"mount:", ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa\bb", "BM", @"mount:/aa\bb", Result.Success }, + { @".//aa/bb", "RW", @"./aa/bb", Result.Success }, + { @"./aa/bb", "R", @"./aa/bb", Result.Success }, + { @"./c:/aa/bb", "RW", @"./c:/aa/bb", ResultFs.InvalidCharacter.Value } + }; + + [Theory, MemberData(nameof(TestData_Normalize_All))] + public static void Normalize_All(string path, string pathFlags, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, 0x301, expectedNormalized, expectedResult); + } + + public static TheoryData TestData_Normalize_SmallBuffer => new() + { + { @"/aa/bb", "M", 1, @"", ResultFs.TooLongPath.Value }, + { @"mount:/aa/bb", "MR", 6, @"", ResultFs.TooLongPath.Value }, + { @"mount:/aa/bb", "MR", 7, @"mount:", ResultFs.TooLongPath.Value }, + { @"aa/bb", "MR", 3, @"./", ResultFs.TooLongPath.Value }, + { @"\\host\share", "W", 13, @"\\host\share", ResultFs.TooLongPath.Value } + }; + + [Theory, MemberData(nameof(TestData_Normalize_SmallBuffer))] + public static void Normalize_SmallBuffer(string path, string pathFlags, int bufferSize, string expectedNormalized, Result expectedResult) + { + NormalizeImpl(path, pathFlags, bufferSize, expectedNormalized, expectedResult); + } + + private static void NormalizeImpl(string path, string pathFlags, int bufferSize, string expectedNormalized, Result expectedResult) + { + byte[] buffer = new byte[bufferSize]; + + Result result = PathFormatter.Normalize(buffer, path.ToU8Span(), GetPathFlags(pathFlags)); + + Assert.Equal(expectedResult, result); + Assert.Equal(expectedNormalized, StringUtils.Utf8ZToString(buffer)); + } + + public static TheoryData TestData_IsNormalized_EmptyPath => new() + { + { @"", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"", "E", true, 0, Result.Success }, + { @"/aa/bb/../cc", "E", false, 0, Result.Success } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_EmptyPath))] + public static void IsNormalized_EmptyPath(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + public static TheoryData TestData_IsNormalized_MountName => new() + { + { @"mount:/aa/bb", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa/bb", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa/bb", "M", true, 12, Result.Success }, + { @"mount:/aa/./bb", "M", false, 6, Result.Success }, + { @"mount:\aa\bb", "M", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"m:/aa/bb", "M", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mo>unt:/aa/bb", "M", false, 0, ResultFs.InvalidCharacter.Value }, + { @"moun?t:/aa/bb", "M", false, 0, ResultFs.InvalidCharacter.Value }, + { @"mo&unt:/aa/bb", "M", true, 13, Result.Success }, + { @"/aa/./bb", "M", false, 0, Result.Success }, + { @"mount/aa/./bb", "M", false, 0, ResultFs.InvalidPathFormat.Value } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_MountName))] + public static void IsNormalized_MountName(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + public static TheoryData TestData_IsNormalized_WindowsPath => new() + { + { @"c:/aa/bb", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"c:/aa/bb", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"c:\aa\bb", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host\share", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\.\c:\", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\.\c:/aa/bb/.", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\?\c:\", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:\\host\share\aa\bb", "M", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:\\host/share\aa\bb", "M", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:/\\aa\..\bb", "MW", false, 0, Result.Success }, + { @"mount:/c:\aa\..\bb", "MW", false, 0, Result.Success }, + { @"mount:/aa/bb", "MW", true, 12, Result.Success }, + { @"/mount:/aa/bb", "MW", false, 0, ResultFs.InvalidCharacter.Value }, + { @"/mount:/aa/bb", "W", false, 0, ResultFs.InvalidCharacter.Value }, + { @"a:aa/../bb", "MW", false, 8, Result.Success }, + { @"a:aa\..\bb", "MW", false, 0, Result.Success }, + { @"/a:aa\..\bb", "W", false, 0, ResultFs.DirectoryUnobtainable.Value }, + { @"\\?\c:\.\aa", "W", false, 0, Result.Success }, + { @"\\.\c:\.\aa", "W", false, 0, Result.Success }, + { @"\\.\mount:\.\aa", "W", false, 0, Result.Success }, + { @"\\./.\aa", "W", false, 0, Result.Success }, + { @"\\/aa", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\\aa", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\", "W", false, 0, Result.Success }, + { @"\\host\share", "W", false, 0, Result.Success }, + { @"\\host\share\path", "W", false, 0, Result.Success }, + { @"\\host\share\path\aa\bb\..\cc\.", "W", false, 0, Result.Success }, + { @"\\host\", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\ho$st\share\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host:\share\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\..\share\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host\s:hare\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host\.\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host\..\path", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\\host\sha:re", "W", false, 0, ResultFs.InvalidPathFormat.Value }, + { @".\\host\share", "RW", false, 0, Result.Success } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_WindowsPath))] + public static void IsNormalized_WindowsPath(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + public static TheoryData TestData_IsNormalized_RelativePath => new() + { + { @"./aa/bb", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"./aa/bb/../cc", "R", false, 1, Result.Success }, + { @".\aa/bb/../cc", "R", false, 0, Result.Success }, + { @".", "R", true, 1, Result.Success }, + { @"../aa/bb", "R", false, 0, ResultFs.DirectoryUnobtainable.Value }, + { @"/aa/./bb", "R", false, 0, Result.Success }, + { @"mount:./aa/bb", "MR", true, 13, Result.Success }, + { @"mount:./aa/./bb", "MR", false, 7, Result.Success }, + { @"mount:./aa/bb", "M", false, 0, ResultFs.InvalidPathFormat.Value } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_RelativePath))] + public static void IsNormalized_RelativePath(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + public static TheoryData TestData_IsNormalized_Backslash => new() + { + { @"\aa\bb\..\cc", "", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"\aa\bb\..\cc", "B", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"/aa\bb\..\cc", "", false, 0, ResultFs.DirectoryUnobtainable.Value }, + { @"/aa\bb\..\cc", "B", false, 0, ResultFs.DirectoryUnobtainable.Value }, + { @"/aa\bb\cc", "", false, 0, ResultFs.InvalidCharacter.Value }, + { @"/aa\bb\cc", "B", true, 9, Result.Success }, + { @"\\host\share\path\aa\bb\cc", "W", false, 0, Result.Success }, + { @"\\host\share\path\aa\bb\cc", "WB", false, 0, Result.Success }, + { @"/aa/bb\../cc/..\dd\..\ee/..", "", false, 0, ResultFs.DirectoryUnobtainable.Value }, + { @"/aa/bb\../cc/..\dd\..\ee/..", "B", false, 0, ResultFs.DirectoryUnobtainable.Value } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_Backslash))] + public static void IsNormalized_Backslash(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + public static TheoryData TestData_IsNormalized_All => new() + { + { @"mount:./aa/bb", "WRM", true, 13, Result.Success }, + { @"mount:./aa/bb\cc/dd", "WRM", false, 0, Result.Success }, + { @"mount:./aa/bb\cc/dd", "WRMB", true, 19, Result.Success }, + { @"mount:./.c:/aa/bb", "RM", false, 0, ResultFs.InvalidCharacter.Value }, + { @"mount:.c:/aa/bb", "WRM", false, 0, Result.Success }, + { @"mount:./cc:/aa/bb", "WRM", false, 0, ResultFs.InvalidCharacter.Value }, + { @"mount:./\\host\share/aa/bb", "MW", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:./\\host\share/aa/bb", "WRM", false, 0, Result.Success }, + { @"mount:.\\host\share/aa/bb", "WRM", false, 0, Result.Success }, + { @"mount:..\\host\share/aa/bb", "WRM", false, 0, Result.Success }, + { @".\\host\share/aa/bb", "WRM", false, 0, Result.Success }, + { @"..\\host\share/aa/bb", "WRM", false, 0, Result.Success }, + { @"mount:\\host\share/aa/bb", "MW", true, 24, Result.Success }, + { @"mount:\aa\bb", "BM", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:/aa\bb", "BM", true, 12, Result.Success }, + { @".//aa/bb", "RW", false, 1, Result.Success }, + { @"./aa/bb", "R", true, 7, Result.Success }, + { @"./c:/aa/bb", "RW", false, 0, ResultFs.InvalidCharacter.Value } + }; + + [Theory, MemberData(nameof(TestData_IsNormalized_All))] + public static void IsNormalized_All(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + IsNormalizedImpl(path, pathFlags, expectedIsNormalized, expectedLength, expectedResult); + } + + private static void IsNormalizedImpl(string path, string pathFlags, bool expectedIsNormalized, long expectedLength, + Result expectedResult) + { + Result result = PathFormatter.IsNormalized(out bool isNormalized, out int length, path.ToU8Span(), + GetPathFlags(pathFlags)); + + Assert.Equal(expectedResult, result); + + if (result.IsSuccess()) + { + Assert.Equal(expectedIsNormalized, isNormalized); + + if (isNormalized) + { + Assert.Equal(expectedLength, length); + } + } + } + + [Fact] + public static void IsNormalized_InvalidUtf8() + { + ReadOnlySpan invalidUtf8 = new byte[] { 0x44, 0xE3, 0xAA, 0x55, 0x50 }; + + Result result = PathFormatter.IsNormalized(out _, out _, invalidUtf8, new PathFlags()); + + Assert.Result(ResultFs.InvalidPathFormat, result); + } + + private static PathFlags GetPathFlags(string pathFlags) + { + var flags = new PathFlags(); + + foreach (char c in pathFlags) + { + switch (c) + { + case 'B': + flags.AllowBackslash(); + break; + case 'E': + flags.AllowEmptyPath(); + break; + case 'M': + flags.AllowMountName(); + break; + case 'R': + flags.AllowRelativePath(); + break; + case 'W': + flags.AllowWindowsPath(); + break; + } + } + + return flags; + } + } +} diff --git a/tests/LibHac.Tests/Fs/PathNormalizationTestGenerator.cpp b/tests/LibHac.Tests/Fs/PathNormalizationTestGenerator.cpp new file mode 100644 index 00000000..5c5e41cc --- /dev/null +++ b/tests/LibHac.Tests/Fs/PathNormalizationTestGenerator.cpp @@ -0,0 +1,406 @@ +// Uses GLoat to run code in nnsdk https://github.com/h1k421/GLoat +#include + +#include +#include +#include + +static char Buf[0x80000]; +static int BufPos = 0; + +static char ResultNameBuf[0x100]; + +namespace nn::fs::detail { + bool IsEnabledAccessLog(); +} + +// SDK 12 +namespace nn::fs { + bool IsSubPath(const char* path1, const char* path2); + + class PathFlags { + private: + int32_t value; + public: + PathFlags() { value = 0; } + + void AllowWindowsPath() { value |= (1 << 0); } + void AllowRelativePath() { value |= (1 << 1); } + void AllowEmptyPath() { value |= (1 << 2); } + void AllowMountName() { value |= (1 << 3); } + void AllowBackslash() { value |= (1 << 4); } + + const bool IsWindowsPathAllowed() { return (value & (1 << 0)) != 0; } + const bool IsRelativePathAllowed() { return (value & (1 << 1)) != 0; } + const bool IsEmptyPathAllowed() { return (value & (1 << 2)) != 0; } + const bool IsMountNameAllowed() { return (value & (1 << 3)) != 0; } + const bool IsBackslashAllowed() { return (value & (1 << 4)) != 0; } + }; + + class PathFormatter { + public: + static nn::Result Normalize(char* buffer, uint64_t normalizeBufferLength, const char* path, uint64_t pathLength, const nn::fs::PathFlags&); + static nn::Result IsNormalized(bool* outIsNormalized, uint64_t* outNormalizedPathLength, const char* path, const nn::fs::PathFlags&); + static nn::Result SkipWindowsPath(const char** outPath, uint64_t* outLength, bool* outIsNormalized, const char* path, bool hasMountName); + static nn::Result SkipMountName(const char** outPath, uint64_t* outLength, const char* path); + }; + + class PathNormalizer { + public: + static nn::Result Normalize(char* outBuffer, uint64_t* outLength, const char* path, uint64_t outBufferLength, bool isWindowsPath, bool isDriveRelative); + static nn::Result IsNormalized(bool* outIsNormalized, uint64_t* outNormalizedPathLength, const char* path); + }; +} + +template +constexpr auto make_array(T&& head, Ts&&... tail)->std::array +{ + return { head, tail ... }; +} + +template +void CreateTest(const char* name, void (*func)(Ts...), const std::array, N>& testData) { + Buf[0] = '\n'; + BufPos = 1; + + BufPos += sprintf(&Buf[BufPos], "%s\n", name); + + for (auto item : testData) { + std::apply(func, item); + } + + svcOutputDebugString(Buf, BufPos); +} + +const char* GetResultName(nn::Result result) { + switch (result.GetValue()) { + case 0: return "Result.Success"; + case 0x2EE402: return "ResultFs.InvalidPath.Value"; + case 0x2EE602: return "ResultFs.TooLongPath.Value"; + case 0x2EE802: return "ResultFs.InvalidCharacter.Value"; + case 0x2EEA02: return "ResultFs.InvalidPathFormat.Value"; + case 0x2EEC02: return "ResultFs.DirectoryUnobtainable.Value"; + default: + sprintf(ResultNameBuf, "0x%x", result.GetValue()); + return ResultNameBuf; + } +} + +constexpr const char* const BoolStr(bool value) +{ + return value ? "true" : "false"; +} + +nn::fs::PathFlags GetPathFlags(char const* pathFlags) { + nn::fs::PathFlags flags = nn::fs::PathFlags(); + + for (char const* c = pathFlags; *c; c++) { + switch (*c) { + case 'B': + flags.AllowBackslash(); + break; + case 'E': + flags.AllowEmptyPath(); + break; + case 'M': + flags.AllowMountName(); + break; + case 'R': + flags.AllowRelativePath(); + break; + case 'W': + flags.AllowWindowsPath(); + break; + } + } + + return flags; +} + +static constexpr const auto TestData_PathFormatterNormalize_EmptyPath = make_array( + // Check AllowEmptyPath option + std::make_tuple("", ""), + std::make_tuple("", "E"), + std::make_tuple("/aa/bb/../cc", "E") +); + +static constexpr const auto TestData_PathFormatterNormalize_MountName = make_array( + // Mount names should only be allowed with the AllowMountNames option + std::make_tuple("mount:/aa/bb", ""), // Mount name isn't allowed without the AllowMountNames option + std::make_tuple("mount:/aa/bb", "W"), + std::make_tuple("mount:/aa/bb", "M"), // Basic mount names + std::make_tuple("mount:/aa/./bb", "M"), + std::make_tuple("mount:\\aa\\bb", "M"), + std::make_tuple("m:/aa/bb", "M"), // Windows mount name without AllowWindowsPath option + std::make_tuple("mo>unt:/aa/bb", "M"), // Mount names with invalid characters + std::make_tuple("moun?t:/aa/bb", "M"), + std::make_tuple("mo&unt:/aa/bb", "M"), // Mount name with valid special character + std::make_tuple("/aa/./bb", "M"), // AllowMountName set when path has no mount name + std::make_tuple("mount/aa/./bb", "M") // Relative path or mount name is missing separator +); + +static constexpr const auto TestData_PathFormatterNormalize_WindowsPath = make_array( + // Windows paths should only be allowed with the AllowWindowsPath option + std::make_tuple(R"(c:/aa/bb)", ""), + std::make_tuple(R"(c:\aa\bb)", ""), + std::make_tuple(R"(\\host\share)", ""), + std::make_tuple(R"(\\.\c:\)", ""), + std::make_tuple(R"(\\.\c:/aa/bb/.)", ""), + std::make_tuple(R"(\\?\c:\)", ""), + std::make_tuple(R"(mount:\\host\share\aa\bb)", "M"), // Catch instances where the Windows path comes after other parts in the path + std::make_tuple(R"(mount:\\host/share\aa\bb)", "M"), // And do it again with the UNC path not normalized + + std::make_tuple(R"(mount:/\\aa\..\bb)", "MW"), + std::make_tuple(R"(mount:/c:\aa\..\bb)", "MW"), + std::make_tuple(R"(mount:/aa/bb)", "MW"), + std::make_tuple(R"(/mount:/aa/bb)", "MW"), + std::make_tuple(R"(/mount:/aa/bb)", "W"), + std::make_tuple(R"(a:aa/../bb)", "MW"), + std::make_tuple(R"(a:aa\..\bb)", "MW"), + std::make_tuple(R"(/a:aa\..\bb)", "W"), + std::make_tuple(R"(\\?\c:\.\aa)", "W"), // Path with win32 file namespace prefix + std::make_tuple(R"(\\.\c:\.\aa)", "W"), // Path with win32 device namespace prefix + std::make_tuple(R"(\\.\mount:\.\aa)", "W"), + std::make_tuple(R"(\\./.\aa)", "W"), + std::make_tuple(R"(\\/aa)", "W"), + std::make_tuple(R"(\\\aa)", "W"), + std::make_tuple(R"(\\)", "W"), + std::make_tuple(R"(\\host\share)", "W"), // Basic UNC paths + std::make_tuple(R"(\\host\share\path)", "W"), + std::make_tuple(R"(\\host\share\path\aa\bb\..\cc\.)", "W"), // UNC path using only backslashes that is not normalized + std::make_tuple(R"(\\host\)", "W"), // Share name cannot be empty + std::make_tuple(R"(\\ho$st\share\path)", "W"), // Invalid character '$' in host name + std::make_tuple(R"(\\host:\share\path)", "W"), // Invalid character ':' in host name + std::make_tuple(R"(\\..\share\path)", "W"), // Host name can't be ".." + std::make_tuple(R"(\\host\s:hare\path)", "W"), // Invalid character ':' in host name + std::make_tuple(R"(\\host\.\path)", "W"), // Share name can't be "." + std::make_tuple(R"(\\host\..\path)", "W"), // Share name can't be ".." + std::make_tuple(R"(\\host\sha:re)", "W"), // Invalid share name when nothing follows it + std::make_tuple(R"(.\\host\share)", "RW") // Can't have a relative Windows path +); + +static constexpr const auto TestData_PathFormatterNormalize_RelativePath = make_array( + std::make_tuple("./aa/bb", ""), // Relative path isn't allowed without the AllowRelativePaths option + std::make_tuple("./aa/bb/../cc", "R"), // Basic relative paths using different separators + std::make_tuple(".\\aa/bb/../cc", "R"), + std::make_tuple(".", "R"), // Standalone current directory + std::make_tuple("../aa/bb", "R"), // Path starting with parent directory is not allowed + std::make_tuple("/aa/./bb", "R"), // Absolute paths should work normally + std::make_tuple("mount:./aa/bb", "MR"), // Mount name with relative path + std::make_tuple("mount:./aa/./bb", "MR"), + std::make_tuple("mount:./aa/bb", "M") +); + +static constexpr const auto TestData_PathFormatterNormalize_Backslash = make_array( + std::make_tuple(R"(\aa\bb\..\cc)", ""), // Paths can't start with a backslash no matter the path flags set + std::make_tuple(R"(\aa\bb\..\cc)", "B"), + std::make_tuple(R"(/aa\bb\..\cc)", ""), // Paths can contain backslashes if they start with a frontslash and have AllowBackslash set + std::make_tuple(R"(/aa\bb\..\cc)", "B"), // When backslashes are allowed they do not count as a directory separator + std::make_tuple(R"(/aa\bb\cc)", ""), // Normalized path without a prefix except it uses backslashes + std::make_tuple(R"(/aa\bb\cc)", "B"), + std::make_tuple(R"(\\host\share\path\aa\bb\cc)", "W"), // Otherwise normalized Windows path except with backslashes + std::make_tuple(R"(\\host\share\path\aa\bb\cc)", "WB"), + std::make_tuple(R"(/aa/bb\../cc/..\dd\..\ee/..)", ""), // Path with "parent directory path replacement needed" + std::make_tuple(R"(/aa/bb\../cc/..\dd\..\ee/..)", "B") +); + +static constexpr const auto TestData_PathFormatterNormalize_All = make_array( + std::make_tuple(R"(mount:./aa/bb)", "WRM"), // Normalized path with both mount name and relative path + std::make_tuple(R"(mount:./aa/bb\cc/dd)", "WRM"), // Path with backslashes + std::make_tuple(R"(mount:./aa/bb\cc/dd)", "WRMB"), // This path is considered normalized but the backslashes still normalize to forward slashes + std::make_tuple(R"(mount:./.c:/aa/bb)", "RM"), // These next 2 form a chain where if you normalize one it'll turn into the next + std::make_tuple(R"(mount:.c:/aa/bb)", "WRM"), + std::make_tuple(R"(mount:./cc:/aa/bb)", "WRM"), + std::make_tuple(R"(mount:./\\host\share/aa/bb)", "MW"), + std::make_tuple(R"(mount:./\\host\share/aa/bb)", "WRM"), // These next 3 form a chain where if you normalize one it'll turn into the next + std::make_tuple(R"(mount:.\\host\share/aa/bb)", "WRM"), + std::make_tuple(R"(mount:..\\host\share/aa/bb)", "WRM"), + std::make_tuple(R"(.\\host\share/aa/bb)", "WRM"), // These next 2 form a chain where if you normalize one it'll turn into the next + std::make_tuple(R"(..\\host\share/aa/bb)", "WRM"), + std::make_tuple(R"(mount:\\host\share/aa/bb)", "MW"), // Use a mount name and windows path together + std::make_tuple(R"(mount:\aa\bb)", "BM"), // Backslashes are never allowed directly after a mount name even with AllowBackslashes + std::make_tuple(R"(mount:/aa\bb)", "BM"), + std::make_tuple(R"(.//aa/bb)", "RW"), // Relative path followed by a Windows path won't work + std::make_tuple(R"(./aa/bb)", "R"), + std::make_tuple(R"(./c:/aa/bb)", "RW") +); + +void CreateTest_PathFormatterNormalize(char const* path, char const* pathFlags) { + char normalized[0x200] = { 0 }; + nn::fs::PathFlags flags = GetPathFlags(pathFlags); + + nn::Result result = nn::fs::PathFormatter::Normalize(normalized, 0x200, path, 0x200, flags); + + BufPos += sprintf(&Buf[BufPos], "{@\"%s\", \"%s\", @\"%s\", %s},\n", + path, pathFlags, normalized, GetResultName(result)); +} + +void CreateTest_PathFormatterIsNormalized(char const* path, char const* pathFlags) { + bool isNormalized = 0; + uint64_t normalizedLength = 0; + nn::fs::PathFlags flags = GetPathFlags(pathFlags); + + nn::Result result = nn::fs::PathFormatter::IsNormalized(&isNormalized, &normalizedLength, path, flags); + + BufPos += sprintf(&Buf[BufPos], "{@\"%s\", \"%s\", %s, %ld, %s},\n", + path, pathFlags, BoolStr(isNormalized), normalizedLength, GetResultName(result)); +} + +static constexpr const auto TestData_PathFormatterNormalize_SmallBuffer = make_array( + //std::make_tuple(R"(aa/bb)", "MR", 2), // Crashes nnsdk and throws an out-of-range exception in LibHac. I guess that counts as a pass? + std::make_tuple(R"(/aa/bb)", "M", 1), + std::make_tuple(R"(mount:/aa/bb)", "MR", 6), + std::make_tuple(R"(mount:/aa/bb)", "MR", 7), + std::make_tuple(R"(aa/bb)", "MR", 3), + std::make_tuple(R"(\\host\share)", "W", 13) +); + +void CreateTest_PathFormatterNormalize_SmallBuffer(char const* path, char const* pathFlags, int bufferSize) { + char normalized[0x200] = { 0 }; + nn::fs::PathFlags flags = GetPathFlags(pathFlags); + + svcOutputDebugString(path, strnlen(path, 0x200)); + + nn::Result result = nn::fs::PathFormatter::Normalize(normalized, bufferSize, path, 0x200, flags); + + BufPos += sprintf(&Buf[BufPos], "{@\"%s\", \"%s\", %d, @\"%s\", %s},\n", + path, pathFlags, bufferSize, normalized, GetResultName(result)); +} + +static constexpr const auto TestData_PathNormalizerNormalize = make_array( + std::make_tuple("/aa/bb/c/", false, true), + std::make_tuple("aa/bb/c/", false, false), + std::make_tuple("aa/bb/c/", false, true), + std::make_tuple("mount:a/b", false, true), + std::make_tuple("/aa/bb/../..", true, false), + std::make_tuple("/aa/bb/../../..", true, false), + std::make_tuple("/aa/bb/../../..", false, false), + std::make_tuple("aa/bb/../../..", true, true), + std::make_tuple("aa/bb/../../..", false, true), + std::make_tuple("", false, false), + std::make_tuple("/", false, false), + std::make_tuple("/.", false, false), + std::make_tuple("/./", false, false), + std::make_tuple("/..", false, false), + std::make_tuple("//.", false, false), + std::make_tuple("/ ..", false, false), + std::make_tuple("/.. /", false, false), + std::make_tuple("/. /.", false, false), + std::make_tuple("/aa/bb/cc/dd/./.././../..", false, false), + std::make_tuple("/aa/bb/cc/dd/./.././../../..", false, false), + std::make_tuple("/./aa/./bb/./cc/./dd/.", false, false), + std::make_tuple("/aa\\bb/cc", false, false), + std::make_tuple("/aa\\bb/cc", false, false), + std::make_tuple("/a|/bb/cc", false, false), + std::make_tuple("/>a/bb/cc", false, false), + std::make_tuple("/aa/. TestData_Normalize => new() + { + { @"/aa/bb/c/", false, true, @"/aa/bb/c", 8, Result.Success }, + { @"aa/bb/c/", false, false, @"", 0, ResultFs.InvalidPathFormat.Value }, + { @"aa/bb/c/", false, true, @"/aa/bb/c", 8, Result.Success }, + { @"mount:a/b", false, true, @"/mount:a/b", 0, ResultFs.InvalidCharacter.Value }, + { @"/aa/bb/../..", true, false, @"/", 1, Result.Success }, + { @"/aa/bb/../../..", true, false, @"/", 1, Result.Success }, + { @"/aa/bb/../../..", false, false, @"/aa/bb/", 0, ResultFs.DirectoryUnobtainable.Value }, + { @"aa/bb/../../..", true, true, @"/", 1, Result.Success }, + { @"aa/bb/../../..", false, true, @"/aa/bb/", 0, ResultFs.DirectoryUnobtainable.Value }, + { @"", false, false, @"", 0, ResultFs.InvalidPathFormat.Value }, + { @"/", false, false, @"/", 1, Result.Success }, + { @"/.", false, false, @"/", 1, Result.Success }, + { @"/./", false, false, @"/", 1, Result.Success }, + { @"/..", false, false, @"/", 0, ResultFs.DirectoryUnobtainable.Value }, + { @"//.", false, false, @"/", 1, Result.Success }, + { @"/ ..", false, false, @"/ ..", 4, Result.Success }, + { @"/.. /", false, false, @"/.. ", 4, Result.Success }, + { @"/. /.", false, false, @"/. ", 3, Result.Success }, + { @"/aa/bb/cc/dd/./.././../..", false, false, @"/aa", 3, Result.Success }, + { @"/aa/bb/cc/dd/./.././../../..", false, false, @"/", 1, Result.Success }, + { @"/./aa/./bb/./cc/./dd/.", false, false, @"/aa/bb/cc/dd", 12, Result.Success }, + { @"/aa\bb/cc", false, false, @"/aa\bb/cc", 9, Result.Success }, + { @"/aa\bb/cc", false, false, @"/aa\bb/cc", 9, Result.Success }, + { @"/a|/bb/cc", false, false, @"/a|/bb/cc", 0, ResultFs.InvalidCharacter.Value }, + { @"/>a/bb/cc", false, false, @"/>a/bb/cc", 0, ResultFs.InvalidCharacter.Value }, + { @"/aa/. TestData_Normalize_SmallBuffer => new() + { + { @"/aa/bb/cc/", 7, @"/aa/bb", 6, ResultFs.TooLongPath.Value }, + { @"/aa/bb/cc/", 8, @"/aa/bb/", 7, ResultFs.TooLongPath.Value }, + { @"/aa/bb/cc/", 9, @"/aa/bb/c", 8, ResultFs.TooLongPath.Value }, + { @"/aa/bb/cc/", 10, @"/aa/bb/cc", 9, Result.Success }, + { @"/aa/bb/cc", 9, @"/aa/bb/c", 8, ResultFs.TooLongPath.Value }, + { @"/aa/bb/cc", 10, @"/aa/bb/cc", 9, Result.Success }, + { @"/./aa/./bb/./cc", 9, @"/aa/bb/c", 8, ResultFs.TooLongPath.Value }, + { @"/./aa/./bb/./cc", 10, @"/aa/bb/cc", 9, Result.Success }, + { @"/aa/bb/cc/../../..", 9, @"/aa/bb/c", 8, ResultFs.TooLongPath.Value }, + { @"/aa/bb/cc/../../..", 10, @"/aa/bb/cc", 9, ResultFs.TooLongPath.Value }, + { @"/aa/bb/.", 7, @"/aa/bb", 6, ResultFs.TooLongPath.Value }, + { @"/aa/bb/./", 7, @"/aa/bb", 6, ResultFs.TooLongPath.Value }, + { @"/aa/bb/..", 8, @"/aa", 3, Result.Success }, + { @"/aa/bb", 1, @"", 0, ResultFs.TooLongPath.Value }, + { @"/aa/bb", 2, @"/", 1, ResultFs.TooLongPath.Value }, + { @"/aa/bb", 3, @"/a", 2, ResultFs.TooLongPath.Value }, + { @"aa/bb", 1, @"", 0, ResultFs.InvalidPathFormat.Value } + }; + + [Theory, MemberData(nameof(TestData_Normalize_SmallBuffer))] + public static void Normalize_SmallBuffer(string path, int bufferLength, string expectedNormalized, long expectedLength, Result expectedResult) + { + byte[] buffer = new byte[bufferLength]; + + Result result = PathNormalizer12.Normalize(buffer, out int normalizedLength, path.ToU8Span(), false, false); + + Assert.Equal(expectedResult, result); + Assert.Equal(expectedNormalized, StringUtils.Utf8ZToString(buffer)); + Assert.Equal(expectedLength, normalizedLength); + } + + public static TheoryData TestData_IsNormalized => new() + { + { @"/aa/bb/c/", false, 9, Result.Success }, + { @"aa/bb/c/", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"aa/bb/c/", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"mount:a/b", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"/aa/bb/../..", false, 0, Result.Success }, + { @"/aa/bb/../../..", false, 0, Result.Success }, + { @"/aa/bb/../../..", false, 0, Result.Success }, + { @"aa/bb/../../..", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"aa/bb/../../..", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"", false, 0, ResultFs.InvalidPathFormat.Value }, + { @"/", true, 1, Result.Success }, + { @"/.", false, 2, Result.Success }, + { @"/./", false, 0, Result.Success }, + { @"/..", false, 3, Result.Success }, + { @"//.", false, 0, Result.Success }, + { @"/ ..", true, 4, Result.Success }, + { @"/.. /", false, 5, Result.Success }, + { @"/. /.", false, 5, Result.Success }, + { @"/aa/bb/cc/dd/./.././../..", false, 0, Result.Success }, + { @"/aa/bb/cc/dd/./.././../../..", false, 0, Result.Success }, + { @"/./aa/./bb/./cc/./dd/.", false, 0, Result.Success }, + { @"/aa\bb/cc", true, 9, Result.Success }, + { @"/aa\bb/cc", true, 9, Result.Success }, + { @"/a|/bb/cc", false, 0, ResultFs.InvalidCharacter.Value }, + { @"/>a/bb/cc", false, 0, ResultFs.InvalidCharacter.Value }, + { @"/aa/. TestData_IsSubPath => new() + { + { @"//a/b", @"/a", false }, + { @"/a", @"//a/b", false }, + { @"//a/b", @"\\a", false }, + { @"//a/b", @"//a", true }, + { @"/", @"/a", true }, + { @"/a", @"/", true }, + { @"/", @"/", false }, + { @"", @"", false }, + { @"/", @"", true }, + { @"/", @"mount:/a", false }, + { @"mount:/", @"mount:/", false }, + { @"mount:/a/b", @"mount:/a/b", false }, + { @"mount:/a/b", @"mount:/a/b/c", true }, + { @"/a/b", @"/a/b/c", true }, + { @"/a/b/c", @"/a/b", true }, + { @"/a/b", @"/a/b", false }, + { @"/a/b", @"/a/b\c", false } + }; + + [Theory, MemberData(nameof(TestData_IsSubPath))] + public static void IsSubPath(string path1, string path2, bool expectedResult) + { + bool result = PathUtility12.IsSubPath(path1.ToU8Span(), path2.ToU8Span()); + + Assert.Equal(expectedResult, result); + } + } +}