// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.Buffers;
using System.Collections.Generic;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.IO;
using System.Numerics;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace System.Formats.Tar
{
// Static class containing a variety of helper methods.
internal static partial class TarHelpers
{
private const int MaxSymlinkDepth = 40;
internal const short RecordSize = 512;
internal const int MaxBufferLength = 4096;
internal struct ExtractState
{
internal SortedDictionary<string, UnixFileMode>? PendingModes;
internal Stack<(string, DateTimeOffset)> DirectoryModificationTimes;
internal TarHardLinkMode HardLinkMode;
// Allocated lazily on the first deferred symlink; most archives have none.
internal Dictionary<FileId, (string SymlinkPath, string LinkTarget)>? PendingLinks;
public ExtractState()
{
PendingModes = CreatePendingModesDictionary();
DirectoryModificationTimes = new();
}
internal Dictionary<FileId, (string SymlinkPath, string LinkTarget)> GetOrCreatePendingLinks()
=> PendingLinks ??= new();
internal readonly void Apply(string destinationDirectoryPath)
{
CreatePendingLinks(destinationDirectoryPath, PendingLinks);
SetPendingModes(PendingModes);
SetPendingModificationTimes(DirectoryModificationTimes);
}
}
internal const UnixFileMode ValidUnixFileModes =
UnixFileMode.UserRead |
UnixFileMode.UserWrite |
UnixFileMode.UserExecute |
UnixFileMode.GroupRead |
UnixFileMode.GroupWrite |
UnixFileMode.GroupExecute |
UnixFileMode.OtherRead |
UnixFileMode.OtherWrite |
UnixFileMode.OtherExecute |
UnixFileMode.StickyBit |
UnixFileMode.SetGroup |
UnixFileMode.SetUser;
// Default mode for TarEntry created for a file-type.
private const UnixFileMode DefaultFileMode =
UnixFileMode.UserRead | UnixFileMode.UserWrite |
UnixFileMode.GroupRead |
UnixFileMode.OtherRead;
// Default mode for TarEntry created for a directory-type.
private const UnixFileMode DefaultDirectoryMode =
DefaultFileMode |
UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
internal static int GetDefaultMode(TarEntryType type)
=> type is TarEntryType.Directory or TarEntryType.DirectoryList ? (int)DefaultDirectoryMode : (int)DefaultFileMode;
// Helps advance the stream a total number of bytes larger than int.MaxValue.
internal static async ValueTask AdvanceStreamCoreAsync<TAdapter>(Stream archiveStream, long bytesToDiscard, CancellationToken cancellationToken)
where TAdapter : IReadWriteAdapter
{
cancellationToken.ThrowIfCancellationRequested();
if (archiveStream.CanSeek)
{
archiveStream.Position += bytesToDiscard;
}
else if (bytesToDiscard > 0)
{
byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: (int)Math.Min(MaxBufferLength, bytesToDiscard));
try
{
while (bytesToDiscard > 0)
{
int currentLengthToRead = (int)Math.Min(MaxBufferLength, bytesToDiscard);
await TAdapter.ReadExactlyAsync(archiveStream, buffer.AsMemory(0, currentLengthToRead), cancellationToken).ConfigureAwait(false);
bytesToDiscard -= currentLengthToRead;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
}
// Helps copy a specific number of bytes from one stream into another.
internal static async ValueTask CopyBytesCoreAsync<TAdapter>(Stream origin, Stream destination, long bytesToCopy, CancellationToken cancellationToken)
where TAdapter : IReadWriteAdapter
{
cancellationToken.ThrowIfCancellationRequested();
byte[] buffer = ArrayPool<byte>.Shared.Rent(minimumLength: (int)Math.Min(MaxBufferLength, bytesToCopy));
try
{
while (bytesToCopy > 0)
{
int currentLengthToRead = (int)Math.Min(MaxBufferLength, bytesToCopy);
Memory<byte> memory = buffer.AsMemory(0, currentLengthToRead);
await TAdapter.ReadExactlyAsync(origin, memory, cancellationToken).ConfigureAwait(false);
await TAdapter.WriteAsync(destination, memory, cancellationToken).ConfigureAwait(false);
bytesToCopy -= currentLengthToRead;
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
// Returns the number of bytes until the next multiple of the record size.
internal static int CalculatePadding(long size)
{
long ceilingMultipleOfRecordSize = ((RecordSize - 1) | (size - 1)) + 1;
int padding = (int)(ceilingMultipleOfRecordSize - size);
return padding;
}
// Returns true if all the bytes in the specified array are nulls, false otherwise.
internal static bool IsAllNullBytes(ReadOnlySpan<byte> buffer) =>
!buffer.ContainsAnyExcept((byte)0);
// Converts the specified number of seconds that have passed since the Unix Epoch to a DateTimeOffset.
internal static DateTimeOffset GetDateTimeOffsetFromSecondsSinceEpoch(long secondsSinceUnixEpoch) =>
new DateTimeOffset((secondsSinceUnixEpoch * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero);
// Converts the specified number of seconds that have passed since the Unix Epoch to a DateTimeOffset.
private static DateTimeOffset GetDateTimeOffsetFromSecondsSinceEpoch(decimal secondsSinceUnixEpoch) =>
new DateTimeOffset((long)(secondsSinceUnixEpoch * TimeSpan.TicksPerSecond) + DateTime.UnixEpoch.Ticks, TimeSpan.Zero);
// Converts the specified DateTimeOffset to the number of seconds that have passed since the Unix Epoch.
private static decimal GetSecondsSinceEpochFromDateTimeOffset(DateTimeOffset dateTimeOffset) =>
((decimal)(dateTimeOffset.UtcDateTime - DateTime.UnixEpoch).Ticks) / TimeSpan.TicksPerSecond;
// If the specified fieldName is found in the provided dictionary and it is a valid decimal number, returns true and sets the value in 'dateTimeOffset'.
internal static bool TryGetDateTimeOffsetFromTimestampString(Dictionary<string, string>? dict, string fieldName, out DateTimeOffset dateTimeOffset)
{
return TryGetDateTimeOffsetFromTimestampString(
dict is not null && dict.TryGetValue(fieldName, out string? value) ? value : null,
out dateTimeOffset);
}
internal static bool TryGetDateTimeOffsetFromTimestampString(string? value, out DateTimeOffset dateTimeOffset)
{
if (decimal.TryParse(value, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal secondsSinceEpoch))
{
dateTimeOffset = GetDateTimeOffsetFromSecondsSinceEpoch(secondsSinceEpoch);
return true;
}
dateTimeOffset = default;
return false;
}
// Converts the specified DateTimeOffset to the string representation of seconds since the Unix Epoch.
internal static string GetTimestampStringFromDateTimeOffset(DateTimeOffset timestamp)
{
decimal secondsSinceEpoch = GetSecondsSinceEpochFromDateTimeOffset(timestamp);
// Use 'G' to ensure the decimals get preserved (avoid losing precision).
return secondsSinceEpoch.ToString("G", CultureInfo.InvariantCulture);
}
// If the specified fieldName has a non-empty value, parses it as a base-10 integer and returns true. Parsing exceptions propagate.
internal static bool TryGetStringAsBaseTenInteger(IReadOnlyDictionary<string, string> dict, string fieldName, out int baseTenInteger)
{
return TryGetStringAsBaseTenInteger(
dict.TryGetValue(fieldName, out string? value) ? value : null,
out baseTenInteger);
}
/// <summary>Parses a uid or gid extended attribute value. See <see cref="ParseUidGid"/> for how out of range values are handled.</summary>
internal static bool TryGetStringAsUidGid(string? value, out int id)
{
if (!string.IsNullOrEmpty(value))
{
id = ToUidGid(long.Parse(value, CultureInfo.InvariantCulture));
return true;
}
id = 0;
return false;
}
internal static bool TryGetStringAsBaseTenInteger(string? value, out int baseTenInteger)
{
if (!string.IsNullOrEmpty(value))
{
baseTenInteger = int.Parse(value, CultureInfo.InvariantCulture);
return true;
}
baseTenInteger = 0;
return false;
}
// If the specified fieldName has a non-empty value, parses it as a base-10 long and returns true. Parsing exceptions propagate.
internal static bool TryGetStringAsBaseTenLong(IReadOnlyDictionary<string, string> dict, string fieldName, out long baseTenLong)
{
return TryGetStringAsBaseTenLong(
dict.TryGetValue(fieldName, out string? value) ? value : null,
out baseTenLong);
}
internal static bool TryGetStringAsBaseTenLong(string? value, out long baseTenLong)
{
if (!string.IsNullOrEmpty(value))
{
baseTenLong = long.Parse(value, CultureInfo.InvariantCulture);
return true;
}
baseTenLong = 0;
return false;
}
// Chooses the compatible regular file entry type for the specified format.
internal static TarEntryType GetRegularFileEntryTypeForFormat(TarEntryFormat format) => format is TarEntryFormat.V7 ? TarEntryType.V7RegularFile : TarEntryType.RegularFile;
// When writing an entry that came from an archive of a different format, if its entry type happens to
// be an incompatible regular file entry type, convert it to the compatible one.
// No change for all other entry types.
internal static TarEntryType GetCorrectTypeFlagForFormat(TarEntryFormat format, TarEntryType entryType) =>
(format, entryType) switch
{
(TarEntryFormat.V7, TarEntryType.RegularFile) => TarEntryType.V7RegularFile,
(not TarEntryFormat.V7, TarEntryType.V7RegularFile) => TarEntryType.RegularFile,
_ => entryType,
};
/// <summary>Parses a uid or gid numeric field.</summary>
/// <remarks>
/// Unix uid_t and gid_t are 32-bit unsigned, and archives may contain values larger than <see cref="int.MaxValue"/>
/// (for example, GNU base-256 encoded fields). Such values are reinterpreted as <see cref="int"/> without an
/// overflow check, which matches how <see cref="TarWriter"/> stores uid and gid values read from the file system.
/// Values that don't fit in 32 bits can't be a valid id and are rejected.
/// </remarks>
internal static int ParseUidGid(ReadOnlySpan<byte> buffer) => ToUidGid(ParseNumeric<long>(buffer));
private static int ToUidGid(long value)
{
if (value < int.MinValue || value > uint.MaxValue)
{
ThrowInvalidNumber();
}
return unchecked((int)value);
}
/// <summary>Parses a numeric field.</summary>
internal static T ParseNumeric<T>(ReadOnlySpan<byte> buffer) where T : struct, INumber<T>, IBinaryInteger<T>
{
// The tar standard specifies that numeric fields are stored using an octal representation.
// This limits the range of values that can be stored in the fields.
// To increase the supported range, a GNU extension defines that when the leading byte is
// '0xff'/'0x80' the remaining bytes are a negative/positive big formatted endian value.
// Like the 'tar' tool we are permissive when encountering this representation in non GNU formats.
byte leadingByte = buffer[0];
if (leadingByte == 0xff)
{
return T.ReadBigEndian(buffer, isUnsigned: false);
}
else if (leadingByte == 0x80)
{
return T.ReadBigEndian(buffer.Slice(1), isUnsigned: true);
}
else
{
return ParseOctal<T>(buffer);
}
}
/// <summary>Parses a byte span that represents an ASCII string containing a number in octal base.</summary>
internal static T ParseOctal<T>(ReadOnlySpan<byte> buffer) where T : struct, INumber<T>
{
buffer = TrimNullTerminated(buffer);
// We ignore spaces because some archives seem to have them (even though they shouldn't).
buffer = buffer.Trim((byte)' ');
if (buffer.Length == 0)
{
return T.Zero;
}
T octalFactor = T.CreateTruncating(8u);
T value = T.Zero;
foreach (byte b in buffer)
{
uint digit = (uint)(b - '0');
if (digit >= 8)
{
ThrowInvalidNumber();
}
value = checked((value * octalFactor) + T.CreateTruncating(digit));
}
return value;
}
[DoesNotReturn]
private static void ThrowInvalidNumber() =>
throw new InvalidDataException(SR.TarInvalidNumber);
// Returns the null-terminated UTF8 string contained in the specified buffer.
internal static string ParseUtf8String(ReadOnlySpan<byte> buffer)
{
buffer = TrimNullTerminated(buffer);
return Encoding.UTF8.GetString(buffer);
}
internal static ReadOnlySpan<byte> TrimNullTerminated(ReadOnlySpan<byte> buffer)
{
int i = buffer.IndexOf((byte)0);
if (i != -1)
{
buffer = buffer[0..i];
}
return buffer;
}
// After the file contents, there may be zero or more null characters,
// which exist to ensure the data is aligned to the record size. Skip them and
// set the stream position to the first byte of the next entry.
internal static async ValueTask<int> SkipBlockAlignmentPaddingCoreAsync<TAdapter>(Stream archiveStream, long size, CancellationToken cancellationToken)
where TAdapter : IReadWriteAdapter
{
int bytesToSkip = CalculatePadding(size);
await AdvanceStreamCoreAsync<TAdapter>(archiveStream, bytesToSkip, cancellationToken).ConfigureAwait(false);
return bytesToSkip;
}
// Throws if the specified entry type is not supported for the specified format.
internal static void ThrowIfEntryTypeNotSupported(TarEntryType entryType, TarEntryFormat archiveFormat, [CallerArgumentExpression(nameof(entryType))] string? paramName = null)
{
switch (archiveFormat)
{
case TarEntryFormat.V7:
if (entryType is
TarEntryType.Directory or
TarEntryType.HardLink or
TarEntryType.V7RegularFile or
TarEntryType.SymbolicLink)
{
return;
}
break;
case TarEntryFormat.Ustar:
if (entryType is
TarEntryType.BlockDevice or
TarEntryType.CharacterDevice or
TarEntryType.Directory or
TarEntryType.Fifo or
TarEntryType.HardLink or
TarEntryType.RegularFile or
TarEntryType.SymbolicLink)
{
return;
}
break;
case TarEntryFormat.Pax:
if (entryType is
TarEntryType.BlockDevice or
TarEntryType.CharacterDevice or
TarEntryType.Directory or
TarEntryType.Fifo or
TarEntryType.HardLink or
TarEntryType.RegularFile or
TarEntryType.SymbolicLink)
{
// GlobalExtendedAttributes is handled via PaxGlobalExtendedAttributesEntry
// Not supported for writing - internally autogenerated:
// - ExtendedAttributes
return;
}
break;
case TarEntryFormat.Gnu:
if (entryType is
TarEntryType.BlockDevice or
TarEntryType.CharacterDevice or
TarEntryType.Directory or
TarEntryType.Fifo or
TarEntryType.HardLink or
TarEntryType.RegularFile or
TarEntryType.SymbolicLink)
{
// Not supported for writing:
// - ContiguousFile
// - DirectoryList
// - MultiVolume
// - RenamedOrSymlinked
// - SparseFile
// - TapeVolume
// Also not supported for writing - internally autogenerated:
// - LongLink
// - LongPath
return;
}
break;
case TarEntryFormat.Unknown:
default:
throw new InvalidDataException(SR.Format(SR.TarInvalidFormat, archiveFormat));
}
throw new ArgumentException(SR.Format(SR.TarEntryTypeNotSupportedInFormat, entryType, archiveFormat), paramName);
}
public static void SetPendingModificationTimes(Stack<(string, DateTimeOffset)> directoryModificationTimes)
{
// note: these are ordered child to parent.
while (directoryModificationTimes.TryPop(out (string Path, DateTimeOffset Modified) item))
{
AttemptDirectorySetLastWriteTime(item.Path, item.Modified);
}
}
public static void UpdatePendingModificationTimes(Stack<(string, DateTimeOffset)> directoryModificationTimes, string fullPath, DateTimeOffset modified)
{
// We can't set the modification time when we create the directory because extracting entries into it
// will cause that time to change. Instead, we track the times to set them later.
// We take into account that regular tar files are ordered:
// when we see a new directory which is not a child of the previous directory
// we can set the parent directory timestamps, and stop tracking them.
// This avoids having to track all directory entries until we've finished extracting the entire archive.
while (directoryModificationTimes.TryPeek(out (string Path, DateTimeOffset Modified) previous) &&
!IsChildPath(previous.Path, fullPath))
{
directoryModificationTimes.TryPop(out previous);
AttemptDirectorySetLastWriteTime(previous.Path, previous.Modified);
}
directoryModificationTimes.Push((fullPath, modified));
}
private static bool IsChildPath(string parentFullPath, string childFullPath)
{
// Both paths may end with an additional separator.
// Verify that either the parent path ends with a separator
// or the child path has a separator where the parent path ends.
if (IsDirectorySeparatorChar(parentFullPath[^1]))
{
// The child needs to be at least a char longer than the parent for the name.
if (childFullPath.Length <= parentFullPath.Length)
{
return false;
}
}
else
{
// The child needs to be at least 2 chars longer than the parent:
// one for the separator, and one for the name.
if ((childFullPath.Length < parentFullPath.Length + 2) ||
!IsDirectorySeparatorChar(childFullPath[parentFullPath.Length]))
{
return false;
}
}
return childFullPath.StartsWith(parentFullPath, PathInternal.StringComparison);
// We don't need to check for AltDirectorySeparatorChar, full paths are normalized to DirectorySeparatorChar.
static bool IsDirectorySeparatorChar(char c)
=> c == Path.DirectorySeparatorChar;
}
private static void AttemptDirectorySetLastWriteTime(string fullPath, DateTimeOffset lastWriteTime)
{
try
{
Directory.SetLastWriteTime(fullPath, lastWriteTime.UtcDateTime);
}
catch
{
// Some OSes like Android might not support setting the last write time, the extraction should not fail because of that
}
}
// We delayed creating some symbolic links because they might target locations outside the destination.
// Any symlink that is in the destination directory at the time this method is called is either one that points in the destination
// or a pre-existing symlink that we are allowed to follow outside.
internal static void CreatePendingLinks(string destinationDirectoryFullPath, Dictionary<FileId, (string SymlinkPath, string LinkTarget)>? pendingLinks)
{
Debug.Assert(destinationDirectoryFullPath[^1] == Path.DirectorySeparatorChar);
if (pendingLinks is not { Count: > 0 })
{
// No pending links, nothing to do.
return;
}
// Resolve the destination directory against the filesystem root, following any pre-existing symlinks in its path.
string resolvedDestinationDirectoryFullPath;
int destinationSymlinkDepth;
{
string root = Path.GetPathRoot(destinationDirectoryFullPath)!;
var rootResolver = new PendingLinkResolver(root, root, destinationSymlinkDepth: 0, pendingLinks: null);
resolvedDestinationDirectoryFullPath = rootResolver.ResolveDestinationDirectory(destinationDirectoryFullPath.AsSpan(root.Length));
destinationSymlinkDepth = rootResolver.SymlinkDepth;
// Ensure DirectorySeparatorChar.
if (!resolvedDestinationDirectoryFullPath.EndsWith(Path.DirectorySeparatorChar))
{
resolvedDestinationDirectoryFullPath += Path.DirectorySeparatorChar;
}
}
// Now create all pending links (that have ".." segments)
var resolver = new PendingLinkResolver(destinationDirectoryFullPath, resolvedDestinationDirectoryFullPath, destinationSymlinkDepth, pendingLinks);
// CreateLink removes the link from pendingLinks once it is created, and it may create (and remove) several
// more when a link resolves through other pending links. Because that mutates the dictionary, we can't
// enumerate it directly, so we take a snapshot of the keys and skip the ones that were already created.
// The order of creation doesn't matter.
FileId[] fileIds = new FileId[pendingLinks.Count];
pendingLinks.Keys.CopyTo(fileIds, 0);
foreach (FileId fileId in fileIds)
{
if (pendingLinks.TryGetValue(fileId, out (string SymlinkPath, string LinkTarget) pendingLink))
{
resolver.CreateLink(fileId, pendingLink.SymlinkPath, pendingLink.LinkTarget);
}
}
Debug.Assert(pendingLinks.Count == 0);
}
// checks if a path is equal to or nested under a full path. The full path must end with a directory separator.
// examples:
// FullPathIsEqualOrNested("C:\foo\", "C:\foo\bar") => true
// FullPathIsEqualOrNested("C:\foo\", "C:\foo") => true
// FullPathIsEqualOrNested("C:\foo\", "C:\foobar") => false
internal static bool FullPathIsEqualOrNested(string fullPath, string nestedFullPath)
{
Debug.Assert(fullPath[^1] == Path.DirectorySeparatorChar);
return nestedFullPath.StartsWith(fullPath, PathInternal.StringComparison)
|| string.Equals(nestedFullPath, fullPath.TrimEnd(Path.DirectorySeparatorChar), PathInternal.StringComparison);
}
// Resolves the targets of deferred (pending) symbolic links produced during extraction, ensuring none of them
// resolve outside the destination directory. Pre-existing symlinks that already point outside the destination
// widen the allowed region: a pending link may point under the location such a symlink resolved to.
private sealed class PendingLinkResolver
{
// Invariant for the lifetime of the resolver.
private readonly string _destination;
private readonly string _resolvedDestination;
private readonly int _destinationSymlinkDepth;
private readonly Dictionary<FileId, (string SymlinkPath, string LinkTarget)>? _pendingLinks;
// Reset at the start of each top-level link resolution; shared (via ref semantics) across the recursive walk.
private int _symlinkDepth;
internal PendingLinkResolver(string destination, string resolvedDestination, int destinationSymlinkDepth, Dictionary<FileId, (string SymlinkPath, string LinkTarget)>? pendingLinks)
{
Debug.Assert(destination[^1] == Path.DirectorySeparatorChar);
_destination = destination;
_resolvedDestination = resolvedDestination;
_destinationSymlinkDepth = destinationSymlinkDepth;
_pendingLinks = pendingLinks;
_symlinkDepth = destinationSymlinkDepth;
}
internal int SymlinkDepth => _symlinkDepth;
// Resolves the destination directory itself (used by RealPath). Nothing is ever outside the filesystem root.
internal string ResolveDestinationDirectory(ReadOnlySpan<char> relativePath)
{
string? rootPath = _destination;
Resolve(ref rootPath, _destination, relativePath, out string resolvedPath, visitedLinks: null);
return resolvedPath;
}
// Resolves and creates a single top-level pending link.
internal void CreateLink(FileId fileId, string symlinkPath, string linkTarget)
{
// Each top-level link starts a fresh resolution walk.
_symlinkDepth = _destinationSymlinkDepth;
AddPendingLink(_resolvedDestination, fileId, symlinkPath, linkTarget, visitedLinks: null, out _);
}
private void AddPendingLink(string rootPath, FileId fileId, string symlinkPath, string linkTarget, HashSet<FileId>? visitedLinks, out string resolvedFullPath)
{
Debug.Assert(rootPath is not null);
string? rootPathLocal = rootPath;
ReadOnlySpan<char> relativeLinkPath = symlinkPath.AsSpan(_destination.Length);
string resolvedPath = _resolvedDestination;
ReadOnlySpan<char> directoryPath = Path.GetDirectoryName(relativeLinkPath);
if (!directoryPath.IsEmpty)
{
if (!Resolve(ref rootPathLocal, resolvedPath, directoryPath, out resolvedPath, visitedLinks))
{
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkTarget, _destination));
}
Debug.Assert(rootPathLocal is not null);
}
ReadOnlySpan<char> resolveTarget = linkTarget;
if (Path.IsPathFullyQualified(linkTarget))
{
rootPathLocal = _resolvedDestination;
resolvedPath = Path.GetPathRoot(linkTarget)!;
resolveTarget = linkTarget.AsSpan(resolvedPath.Length);
}
if (!Resolve(ref rootPathLocal, resolvedPath, resolveTarget, out resolvedFullPath, visitedLinks))
{
throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkTarget, _destination));
}
Debug.Assert(rootPathLocal is not null);
if (_pendingLinks!.Remove(fileId))
{
File.Delete(symlinkPath);
File.CreateSymbolicLink(symlinkPath, linkTarget);
}
}
// Returns whether the path formed by combining baseFullPath and relativePath resides in either the root path or the destination path.
// The containmentRootPath path tracks a location that may not be in the destination path but which is reachable (via preexisting links)
// and considered a root that a delayed link target may point under.
private bool Resolve(ref string? containmentRootPath, string baseFullPath, ReadOnlySpan<char> relativePath, out string resolvedFullPath, HashSet<FileId>? visitedLinks)
{
resolvedFullPath = baseFullPath;
bool checkExists = true;
// walk the relative path and resolve each component, following symlinks and checking for loops.
foreach (Range range in relativePath.SplitAny(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar))
{
ReadOnlySpan<char> component = relativePath[range];
if (component.IsEmpty || component is ".")
{
continue;
}
if (component is "..")
{
// Traverse to the parent. Strip any trailing separators first, otherwise
// GetDirectoryName only strips them and doesn't move up a level.
ReadOnlySpan<char> resolvedFullPathSpan = resolvedFullPath.AsSpan();
while (!resolvedFullPathSpan.IsEmpty && PathInternal.IsDirectorySeparator(resolvedFullPathSpan[^1]))
{
resolvedFullPathSpan = resolvedFullPathSpan[..^1];
}
// GetDirectoryName returns null at the root, map back to root (.. on / yields /).
resolvedFullPathSpan = Path.GetDirectoryName(resolvedFullPathSpan);
resolvedFullPath = resolvedFullPathSpan.IsEmpty ? resolvedFullPath : resolvedFullPathSpan.ToString();
if (containmentRootPath is not null && !IsInRootOrDestination(containmentRootPath, resolvedFullPath))
{
return false;
}
continue;
}
string componentPath = Path.Join(resolvedFullPath, component);
// TryGetPathInfo throws on error and returns false when the path does not exist.
if (!checkExists || !TryGetPathInfo(componentPath, out FileId fileId, out bool isSymlink))
{
// Path doesn't exist; remaining components can't exist either.
// short-circuit to simply appending the remaining components to the path for the final check.
resolvedFullPath = componentPath;
checkExists = false;
continue;
}
string? linkTarget = null;
(string SymlinkPath, string LinkTarget) pendingLink = default;
if (isSymlink)
{
linkTarget = new FileInfo(componentPath).LinkTarget;
}
else if (_pendingLinks?.TryGetValue(fileId, out pendingLink) == true)
{
linkTarget = pendingLink.LinkTarget;
}
if (linkTarget is null)
{
resolvedFullPath = componentPath;
continue;
}
if (++_symlinkDepth > MaxSymlinkDepth)
{
throw new IOException(SR.Format(SR.IO_TooManySymbolicLinkLevels, componentPath));
}
// To allow creating symbolic links that cause loops we need to detect the loops.
// visitedLinks tracks the links we've followed.
visitedLinks ??= new HashSet<FileId>();
if (!visitedLinks.Add(fileId))
{
// Already visited.
visitedLinks.Clear(); // Clear the set so callers detect circularity via Remove returning false below.
resolvedFullPath = componentPath;
break;
}
if (isSymlink)
{
if (!FollowExistingSymlink(ref containmentRootPath, resolvedFullPath, linkTarget, visitedLinks, out resolvedFullPath))
{
Debug.Fail("Should be able to follow existing symlinks");
return false;
}
}
else
{
// Pending link. A fully qualified target must point inside the destination.
if (Path.IsPathFullyQualified(linkTarget))
{
containmentRootPath = _resolvedDestination;
}
// If no rootPath was set yet, limit the pending link to the destination dir.
AddPendingLink(containmentRootPath ?? _resolvedDestination, fileId, pendingLink.SymlinkPath!, pendingLink.LinkTarget!, visitedLinks, out resolvedFullPath);
}
if (!visitedLinks.Remove(fileId))
{
// Circular reference detected while resolving the link above.
break;
}
}
return containmentRootPath is null || IsInRootOrDestination(containmentRootPath, resolvedFullPath);
}
// Follows a pre-existing symlink that is already on disk. Existing symlinks are not limited to a root path;
// after resolving, the root path is widened to the resolved location so pending links may also point under it.
private bool FollowExistingSymlink(ref string? rootPath, string linkDirectory, string linkTarget, HashSet<FileId>? visitedLinks, out string resolvedFullPath)
{
string resolveFrom;
ReadOnlySpan<char> resolveTarget;
if (Path.IsPathFullyQualified(linkTarget))
{
resolveFrom = Path.GetPathRoot(linkTarget)!;
resolveTarget = linkTarget.AsSpan(resolveFrom.Length);
}
else
{
resolveFrom = linkDirectory;
resolveTarget = linkTarget;
}
// set root path as null to disable the containment checks inside Resolve
string? symlinkRootPath = null;
if (!Resolve(ref symlinkRootPath, resolveFrom, resolveTarget, out resolvedFullPath, visitedLinks))
{
return false;
}
rootPath = resolvedFullPath;
if (!rootPath.EndsWith(Path.DirectorySeparatorChar))
{
rootPath += Path.DirectorySeparatorChar;
}
return true;
}
private bool IsInRootOrDestination(string rootPath, string resolvedPath)
=> FullPathIsEqualOrNested(rootPath, resolvedPath)
|| (!ReferenceEquals(rootPath, _resolvedDestination) && FullPathIsEqualOrNested(_resolvedDestination, resolvedPath));
}
}
}