diff --git a/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj index d6464be9ee414b..ab27d100a17ca7 100644 --- a/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj +++ b/src/libraries/System.Formats.Tar/src/System.Formats.Tar.csproj @@ -60,9 +60,14 @@ + + + + + diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs index e224e5ed28c5f1..6e6e40a3ba5b8a 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Unix.cs @@ -10,6 +10,21 @@ namespace System.Formats.Tar // Unix specific methods for the TarEntry class. public abstract partial class TarEntry { + private static readonly char[] s_directorySeparators = [Path.DirectorySeparatorChar]; + + private static string ResolveExistingPath(string path, out bool isOrdinaryDirectory) + { + isOrdinaryDirectory = false; + if (Interop.Sys.LStat(path, out Interop.Sys.FileStatus status) == 0 && + (status.Mode & Interop.Sys.FileTypes.S_IFMT) == Interop.Sys.FileTypes.S_IFDIR) + { + isOrdinaryDirectory = true; + return Path.GetFullPath(path); + } + + return Path.Exists(path) ? ResolveSymlink(path) : path; + } + // Unix specific implementation of the method that extracts the current entry as a block device. private void ExtractAsBlockDevice(string destinationFileName) { diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs index ca62ab62bf880d..c22843002b2004 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.Windows.cs @@ -10,6 +10,24 @@ namespace System.Formats.Tar // Windows specific methods for the TarEntry class. public abstract partial class TarEntry { + private static readonly char[] s_directorySeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar]; + + private static string ResolveExistingPath(string path, out bool isOrdinaryDirectory) + { + isOrdinaryDirectory = false; + Interop.Kernel32.WIN32_FILE_ATTRIBUTE_DATA data = default; + if (Interop.Kernel32.GetFileAttributesEx(path, Interop.Kernel32.GET_FILEEX_INFO_LEVELS.GetFileExInfoStandard, ref data)) + { + FileAttributes attributes = (FileAttributes)data.dwFileAttributes; + isOrdinaryDirectory = (attributes & (FileAttributes.Directory | FileAttributes.ReparsePoint)) == FileAttributes.Directory; + return (attributes & FileAttributes.ReparsePoint) != 0 ? + ResolveSymlink(path) : + Path.GetFullPath(path); + } + + return Path.Exists(path) ? ResolveSymlink(path) : path; + } + #pragma warning disable IDE0060 // Throws on Windows. Block devices are not supported on this platform. private void ExtractAsBlockDevice(string destinationFileName) diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs index db3ae5ebd0cffd..5ea596773fb13d 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs @@ -6,6 +6,7 @@ using System.IO; using System.Threading; using System.Threading.Tasks; +using Microsoft.Win32.SafeHandles; namespace System.Formats.Tar { @@ -20,6 +21,53 @@ public abstract partial class TarEntry // Used to access the data section of this entry in an unseekable file private TarReader? _readerOfOrigin; + internal sealed class ExtractionContext + { + private string? _resolvedDestinationDirectoryPath; + private bool _destinationDirectoryWasEmpty; + + internal ExtractionContext(string destinationDirectoryPath) + { + DestinationDirectoryPath = destinationDirectoryPath; + } + + internal string DestinationDirectoryPath { get; } + + internal bool HasSymbolicLinkEntry { get; set; } + + internal bool CanSkipRelativeChecks => _destinationDirectoryWasEmpty && !HasSymbolicLinkEntry; + + internal string ResolveDestinationDirectoryPath() + { + if (_resolvedDestinationDirectoryPath is not null) + { + return _resolvedDestinationDirectoryPath; + } + + string resolvedPath = ResolvePhysicalPath(DestinationDirectoryPath, out bool canReuse); + if (canReuse) + { + // Archive entries cannot replace the destination directory or its ancestors. + _resolvedDestinationDirectoryPath = resolvedPath; + if (!HasSymbolicLinkEntry) + { + try + { + using IEnumerator entries = Directory.EnumerateFileSystemEntries(DestinationDirectoryPath).GetEnumerator(); + _destinationDirectoryWasEmpty = !entries.MoveNext(); + } + catch (UnauthorizedAccessException) + { + // Enumeration permission is optional; retain per-entry checks when it is unavailable. + _destinationDirectoryWasEmpty = false; + } + } + } + + return resolvedPath; + } + } + // These formats have a limited numeric range due to the octal number representation. protected bool FormatIsOctalOnly => _header._format is TarEntryFormat.V7 or TarEntryFormat.Ustar; @@ -319,9 +367,9 @@ public Stream? DataStream internal abstract bool IsDataStreamSetterSupported(); // Extracts the current entry to a location relative to the specified directory. - internal void ExtractRelativeToDirectory(string destinationDirectoryPath, bool overwrite, SortedDictionary? pendingModes, Stack<(string, DateTimeOffset)> directoryModificationTimes, TarHardLinkMode hardLinkMode) + internal void ExtractRelativeToDirectory(ExtractionContext context, bool overwrite, SortedDictionary? pendingModes, Stack<(string, DateTimeOffset)> directoryModificationTimes, TarHardLinkMode hardLinkMode) { - (string destinationFullPath, string? linkTargetPath) = GetDestinationAndLinkPaths(destinationDirectoryPath); + (string destinationFullPath, string? linkTargetPath) = GetDestinationAndLinkPaths(context); if (EntryType == TarEntryType.Directory) { @@ -337,14 +385,14 @@ internal void ExtractRelativeToDirectory(string destinationDirectoryPath, bool o } // Asynchronously extracts the current entry to a location relative to the specified directory. - internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, bool overwrite, SortedDictionary? pendingModes, Stack<(string, DateTimeOffset)> directoryModificationTimes, TarHardLinkMode hardLinkMode, CancellationToken cancellationToken) + internal Task ExtractRelativeToDirectoryAsync(ExtractionContext context, bool overwrite, SortedDictionary? pendingModes, Stack<(string, DateTimeOffset)> directoryModificationTimes, TarHardLinkMode hardLinkMode, CancellationToken cancellationToken) { if (cancellationToken.IsCancellationRequested) { return Task.FromCanceled(cancellationToken); } - (string destinationFullPath, string? linkTargetPath) = GetDestinationAndLinkPaths(destinationDirectoryPath); + (string destinationFullPath, string? linkTargetPath) = GetDestinationAndLinkPaths(context); if (EntryType == TarEntryType.Directory) { @@ -361,8 +409,13 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b } // Gets the sanitized paths for the file destination and link target paths to be used when extracting relative to a directory. - private (string, string?) GetDestinationAndLinkPaths(string destinationDirectoryPath) + private (string, string?) GetDestinationAndLinkPaths(ExtractionContext context) { + if (EntryType is TarEntryType.SymbolicLink) + { + context.HasSymbolicLinkEntry = true; + } + string destinationDirectoryPath = context.DestinationDirectoryPath; Debug.Assert(!string.IsNullOrEmpty(destinationDirectoryPath)); Debug.Assert(Path.IsPathFullyQualified(destinationDirectoryPath)); @@ -370,7 +423,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b string? fileDestinationPath = GetFullDestinationPath( destinationDirectoryPath, Path.IsPathFullyQualified(name) ? name : Path.Join(destinationDirectoryPath, name)); - if (fileDestinationPath is null || FilePathEscapesDirectory(destinationDirectoryPath, fileDestinationPath)) + if (fileDestinationPath is null || FilePathEscapesDirectory(context, fileDestinationPath)) { throw new IOException(SR.Format(SR.TarExtractingResultsFileOutside, name, destinationDirectoryPath)); } @@ -391,7 +444,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b string? linkDestination = GetFullDestinationPath( destinationDirectoryPath, Path.IsPathFullyQualified(linkName) ? linkName : Path.Join(Path.GetDirectoryName(fileDestinationPath), linkName)); - if (linkDestination is null || FilePathEscapesDirectory(destinationDirectoryPath, linkDestination)) + if (linkDestination is null || FilePathEscapesDirectory(context, linkDestination)) { throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath)); } @@ -406,7 +459,7 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b string? linkDestination = GetFullDestinationPath( destinationDirectoryPath, Path.Join(destinationDirectoryPath, linkName)); - if (linkDestination is null || FilePathEscapesDirectory(destinationDirectoryPath, linkDestination)) + if (linkDestination is null || FilePathEscapesDirectory(context, linkDestination)) { throw new IOException(SR.Format(SR.TarExtractingResultsLinkOutside, linkName, destinationDirectoryPath)); } @@ -420,12 +473,17 @@ internal Task ExtractRelativeToDirectoryAsync(string destinationDirectoryPath, b // Prevent an archive from escaping the extraction root through symlinks that were created by earlier entries in the same archive. // This protection applies only to links introduced by the archive itself. It is not intended to defend against preexisting symlinks // already present on disk before extraction - private static bool FilePathEscapesDirectory(string destinationDirectoryPath, string fileDestinationPath) + private static bool FilePathEscapesDirectory(ExtractionContext context, string fileDestinationPath) { - string resolvedDest = ResolvePhysicalPath(destinationDirectoryPath); + string resolvedDest = context.ResolveDestinationDirectoryPath(); + if (context.CanSkipRelativeChecks) + { + // An initially empty ordinary root has no archive-created links until a symbolic-link entry is encountered. + return false; + } // Use the logical destination path for computing the relative path - string logicalDest = Path.GetFullPath(destinationDirectoryPath); + string logicalDest = Path.GetFullPath(context.DestinationDirectoryPath); string logicalPrefix = logicalDest.EndsWith(Path.DirectorySeparatorChar) ? logicalDest : logicalDest + Path.DirectorySeparatorChar; @@ -446,22 +504,24 @@ private static bool FilePathEscapesDirectory(string destinationDirectoryPath, st } // Walk relative components, resolving symlinks at each step - string relative = normalizedFile.Substring(logicalPrefix.Length) - .TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - - string[] components = relative.Split(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, - StringSplitOptions.RemoveEmptyEntries); + ReadOnlySpan relative = normalizedFile.AsSpan(logicalPrefix.Length) + .TrimStart(s_directorySeparators); string current = resolvedDest; - foreach (string component in components) + foreach (Range range in relative.SplitAny(s_directorySeparators)) { - current = Path.Combine(current, component); + ReadOnlySpan component = relative[range]; + if (component.IsEmpty) + { + continue; + } + + current = Path.Join(current, component); current = ResolveSymlink(current); - string normalizedCurrent = Path.GetFullPath(current); - if (!normalizedCurrent.StartsWith(destPrefix, StringComparison.Ordinal) && - !normalizedCurrent.Equals(resolvedDest, StringComparison.Ordinal)) + if (!current.StartsWith(destPrefix, StringComparison.Ordinal) && + !current.Equals(resolvedDest, StringComparison.Ordinal)) { return true; } @@ -478,7 +538,7 @@ private static string ResolveSymlink(string path) // are still resolved to their raw target, rather than being treated as a non-link. if (info.LinkTarget is null) { - return Path.GetFullPath(path); + return info.FullName; } FileSystemInfo target = info.ResolveLinkTarget(returnFinalTarget: true) ?? info; @@ -487,26 +547,31 @@ private static string ResolveSymlink(string path) // Resolves the full path of the specified path, resolving symlinks at each step. // This is needed to mitigate malicious entries in the archive that could lead to writing files outside of the intended directory. - private static string ResolvePhysicalPath(string path) + private static string ResolvePhysicalPath(string path, out bool canReuse) { + canReuse = true; string fullPath = Path.GetFullPath(path); string? root = Path.GetPathRoot(fullPath); if (root is null) { + canReuse = false; return fullPath; } - string[] components = fullPath.Substring(root.Length) - .Split(new char[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, StringSplitOptions.RemoveEmptyEntries); + ReadOnlySpan relative = fullPath.AsSpan(root.Length); string current = root; - foreach (string component in components) + foreach (Range range in relative.SplitAny(s_directorySeparators)) { - current = Path.Combine(current, component); - if (Path.Exists(current)) + ReadOnlySpan component = relative[range]; + if (component.IsEmpty) { - current = ResolveSymlink(current); + continue; } + + current = Path.Join(current, component); + current = ResolveExistingPath(current, out bool isOrdinaryDirectory); + canReuse &= isOrdinaryDirectory; } return current; @@ -690,9 +755,9 @@ private void ExtractAsRegularFile(string destinationFileName) // Important: The DataStream will be written from its current position DataStream?.CopyTo(fs); } - } - AttemptSetLastWriteTime(destinationFileName, ModificationTime); + AttemptSetLastWriteTime(fs.SafeFileHandle, ModificationTime); + } } // Asynchronously extracts the current entry as a regular file into the specified destination. @@ -717,16 +782,18 @@ private async Task ExtractAsRegularFileAsync(string destinationFileName, Cancell // Important: The DataStream will be written from its current position await DataStream.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } - } - AttemptSetLastWriteTime(destinationFileName, ModificationTime); + // Exposing the handle otherwise flushes buffered data synchronously. + await fs.FlushAsync(cancellationToken).ConfigureAwait(false); + AttemptSetLastWriteTime(fs.SafeFileHandle, ModificationTime); + } } - private static void AttemptSetLastWriteTime(string destinationFileName, DateTimeOffset lastWriteTime) + private static void AttemptSetLastWriteTime(SafeFileHandle fileHandle, DateTimeOffset lastWriteTime) { try { - File.SetLastWriteTime(destinationFileName, lastWriteTime.UtcDateTime); + File.SetLastWriteTime(fileHandle, lastWriteTime.UtcDateTime); } catch { @@ -749,6 +816,12 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync) Options = isAsync ? FileOptions.Asynchronous : FileOptions.None }; + if (_header._gnuSparseDataStream is null && fileStreamOptions.PreallocationSize < fileStreamOptions.BufferSize) + { + // Sizes 0 and 1 disable buffering needed by short-reading streams. + fileStreamOptions.BufferSize = (int)Math.Max(fileStreamOptions.PreallocationSize, 2); + } + if (!OperatingSystem.IsWindows()) { const UnixFileMode OwnershipPermissions = diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs index 51e3ad13e5b4c7..384c3f5f7f6e24 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarFile.cs @@ -16,6 +16,8 @@ namespace System.Formats.Tar /// public static class TarFile { + private const int ArchiveBufferSize = 64 * 1024; + /// public static void CreateFromDirectory(string sourceDirectoryName, Stream destination, bool includeBaseDirectory) => CreateFromDirectory(sourceDirectoryName, destination, includeBaseDirectory, TarEntryFormat.Pax); @@ -194,7 +196,7 @@ public static void CreateFromDirectory(string sourceDirectoryName, string destin } // Throws if the destination file exists - using FileStream fs = new(destinationFileName, FileMode.CreateNew, FileAccess.Write); + using FileStream fs = new(destinationFileName, FileMode.CreateNew, FileAccess.Write, FileShare.Read, ArchiveBufferSize); CreateFromDirectoryInternal(sourceDirectoryName, fs, includeBaseDirectory, leaveOpen: false, options); } @@ -538,6 +540,7 @@ private static async Task CreateFromDirectoryInternalAsync(string sourceDirector { Access = FileAccess.Write, Mode = FileMode.CreateNew, + BufferSize = ArchiveBufferSize, Options = FileOptions.Asynchronous, }; // Throws if the destination file exists @@ -636,12 +639,13 @@ private static void ExtractToDirectoryInternal(Stream source, string destination SortedDictionary? pendingModes = TarHelpers.CreatePendingModesDictionary(); var directoryModificationTimes = new Stack<(string, DateTimeOffset)>(); + TarEntry.ExtractionContext context = new(destinationDirectoryFullPath); TarEntry? entry; while ((entry = reader.GetNextEntry()) != null) { if (entry.EntryType is not TarEntryType.GlobalExtendedAttributes) { - entry.ExtractRelativeToDirectory(destinationDirectoryFullPath, overwriteFiles, pendingModes, directoryModificationTimes, hardLinkMode); + entry.ExtractRelativeToDirectory(context, overwriteFiles, pendingModes, directoryModificationTimes, hardLinkMode); } } TarHelpers.SetPendingModes(pendingModes); @@ -681,6 +685,7 @@ private static async Task ExtractToDirectoryInternalAsync(Stream source, string SortedDictionary? pendingModes = TarHelpers.CreatePendingModesDictionary(); var directoryModificationTimes = new Stack<(string, DateTimeOffset)>(); + TarEntry.ExtractionContext context = new(destinationDirectoryFullPath); TarReader reader = new TarReader(source, leaveOpen); await using (reader.ConfigureAwait(false)) { @@ -689,7 +694,7 @@ private static async Task ExtractToDirectoryInternalAsync(Stream source, string { if (entry.EntryType is not TarEntryType.GlobalExtendedAttributes) { - await entry.ExtractRelativeToDirectoryAsync(destinationDirectoryFullPath, overwriteFiles, pendingModes, directoryModificationTimes, hardLinkMode, cancellationToken).ConfigureAwait(false); + await entry.ExtractRelativeToDirectoryAsync(context, overwriteFiles, pendingModes, directoryModificationTimes, hardLinkMode, cancellationToken).ConfigureAwait(false); } } } diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs index 340ca4560f761a..8a9ec50e520683 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs @@ -134,7 +134,8 @@ private TarEntry ConstructEntryForWriting(string fullPath, string entryName, Fil Debug.Assert(entry._header._dataStream == null); try { - entry._header._dataStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, fileOptions); + entry._header._dataStream = new FileStream(fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, + fileOptions | FileOptions.SequentialScan); } catch (Exception e) when ((attributes & FileAttributes.ReparsePoint) != 0 && (e is IOException or UnauthorizedAccessException)) { diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs index 7d8ba14752ae8c..237e693c198f8c 100644 --- a/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/TarEntry.ExtractToFile.Tests.cs @@ -24,6 +24,23 @@ public Task ExtractToFileAsync_Cancel(TarEntryFormat format) return Assert.ThrowsAsync(() => entry.ExtractToFileAsync("dir", overwrite: true, cs.Token)); } + [Theory] + [InlineData(1)] + [InlineData(256)] + [InlineData(8192)] + public async Task ExtractToFileAsync_CanceledAfterCopy_Throws(int length) + { + using TempDirectory root = new TempDirectory(); + using CancellationTokenSource cancellationSource = new CancellationTokenSource(); + using CancelAfterCopyStream source = new(new byte[length], cancellationSource); + PaxTarEntry entry = new(TarEntryType.RegularFile, "file.txt") { DataStream = source }; + + OperationCanceledException exception = await Assert.ThrowsAnyAsync(() => + entry.ExtractToFileAsync(Path.Join(root.Path, "file.txt"), overwrite: false, cancellationSource.Token)); + + Assert.Equal(cancellationSource.Token, exception.CancellationToken); + } + [Theory] [MemberData(nameof(GetFormatBooleanData))] public async Task Constructor_Name_FullPath_DestinationDirectory_Mismatch_Throws(TarEntryFormat format, bool async) @@ -110,5 +127,54 @@ public async Task Extract(TarEntryFormat format, TarEntryType entryType, bool as Verify_Extract(destination, entry, entryType); } + + [Theory] + [PlatformSpecific(TestPlatforms.Windows | TestPlatforms.Linux | TestPlatforms.OSX)] + [InlineData(0, false)] + [InlineData(1, false)] + [InlineData(256, false)] + [InlineData(4095, false)] + [InlineData(4096, false)] + [InlineData(8192, false)] + [InlineData(0, true)] + [InlineData(1, true)] + [InlineData(256, true)] + [InlineData(4095, true)] + [InlineData(4096, true)] + [InlineData(8192, true)] + public async Task ExtractBufferedData_RestoresModificationTime(int length, bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "file.txt"); + byte[] expected = Enumerable.Range(0, length).Select(value => (byte)(value % 251)).ToArray(); + using MemoryStream source = new MemoryStream(expected); + PaxTarEntry entry = new(TarEntryType.RegularFile, "file.txt") + { + DataStream = source, + ModificationTime = TestModificationTime + }; + + await ExtractToFile(entry, destination, overwrite: false, async); + + Assert.Equal(expected, File.ReadAllBytes(destination)); + Assert.Equal(TestModificationTime.UtcDateTime, File.GetLastWriteTimeUtc(destination)); + } + + private sealed class CancelAfterCopyStream : MemoryStream + { + private readonly CancellationTokenSource _cancellationSource; + + internal CancelAfterCopyStream(byte[] data, CancellationTokenSource cancellationSource) + : base(data, writable: false) + { + _cancellationSource = cancellationSource; + } + + public override async Task CopyToAsync(Stream destination, int bufferSize, CancellationToken cancellationToken) + { + await base.CopyToAsync(destination, bufferSize, cancellationToken); + _cancellationSource.Cancel(); + } + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs index 9704403bc277a3..bbf6baed7b1140 100644 --- a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Unix.cs @@ -10,6 +10,149 @@ namespace System.Formats.Tar.Tests { public partial class TarFile_ExtractToDirectory_File_Tests : TarFile_ExtractToDirectory_Tests { + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExtractToDirectory_SymbolicLinkRootIsResolvedForEachEntry(bool ancestorLink, bool async) + { + using TempDirectory root = new TempDirectory(); + string firstPhysical = Path.Join(root.Path, "first"); + string secondPhysical = Path.Join(root.Path, "second"); + string logical = Path.Join(root.Path, "logical"); + Directory.CreateDirectory(firstPhysical); + Directory.CreateDirectory(secondPhysical); + Directory.CreateSymbolicLink(logical, firstPhysical); + string destination = ancestorLink ? Path.Join(logical, "destination") : logical; + string firstDestination = ancestorLink ? Path.Join(firstPhysical, "destination") : firstPhysical; + string secondDestination = ancestorLink ? Path.Join(secondPhysical, "destination") : secondPhysical; + Directory.CreateDirectory(firstDestination); + Directory.CreateDirectory(secondDestination); + byte[] expected = [1, 2, 3]; + long secondHeaderOffset; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarEntryFormat.Ustar, leaveOpen: true)) + { + using MemoryStream firstData = new MemoryStream(expected); + writer.WriteEntry(new UstarTarEntry(TarEntryType.RegularFile, "nested/first.txt") { DataStream = firstData }); + secondHeaderOffset = archive.Position; + using MemoryStream secondData = new MemoryStream(expected); + writer.WriteEntry(new UstarTarEntry(TarEntryType.RegularFile, "nested/second.txt") { DataStream = secondData }); + } + + bool switched = false; + using RootChangeStream source = new(archive.ToArray(), secondHeaderOffset, () => + { + Assert.Equal(expected, File.ReadAllBytes(Path.Join(firstDestination, "nested", "first.txt"))); + Directory.Delete(logical); + Directory.CreateSymbolicLink(logical, secondPhysical); + switched = true; + }); + + await ExtractToDirectory(source, destination, overwriteFiles: false, async); + + Assert.True(switched); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(secondDestination, "nested", "second.txt"))); + Assert.False(File.Exists(Path.Join(firstDestination, "nested", "second.txt"))); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExtractToDirectory_LateDirectorySymbolicLink_ExtractsInsideTarget(bool missingDestination, bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + if (!missingDestination) + { + Directory.CreateDirectory(destination); + } + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string directory in new[] { "first", "second" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, $"{directory}/file.txt") { DataStream = data }); + } + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "alias") { LinkName = "second" }); + using MemoryStream finalData = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "alias/nested/final.txt") { DataStream = finalData }); + } + archive.Position = 0; + + await ExtractToDirectory(archive, destination, overwriteFiles: true, async); + + Assert.Equal("second", new DirectoryInfo(Path.Join(destination, "alias")).LinkTarget); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "second", "nested", "final.txt"))); + Assert.False(File.Exists(Path.Join(destination, "first", "nested", "final.txt"))); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task ExtractToDirectory_ExistingDirectorySymbolicLink_CannotBeOverwritten(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + Directory.CreateDirectory(destination); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string directory in new[] { "first", "second" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, $"{directory}/file.txt") { DataStream = data }); + } + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "alias") { LinkName = "first" }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "alias") { LinkName = "second" }); + } + archive.Position = 0; + + await Assert.ThrowsAsync(() => ExtractToDirectory(archive, destination, overwriteFiles: true, async)); + + Assert.Equal("first", new DirectoryInfo(Path.Join(destination, "alias")).LinkTarget); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "first", "file.txt"))); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "second", "file.txt"))); + } + + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotPrivilegedProcess))] + [MemberData(nameof(GetBooleanData))] + public async Task ExtractToDirectory_DestinationWithoutEnumerationPermission(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + Directory.CreateDirectory(destination); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string name in new[] { "first.txt", "second.txt" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data }); + } + } + archive.Position = 0; + UnixFileMode originalMode = File.GetUnixFileMode(destination); + File.SetUnixFileMode(destination, UnixFileMode.UserWrite | UnixFileMode.UserExecute); + try + { + await ExtractToDirectory(archive, destination, overwriteFiles: false, async); + + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "first.txt"))); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "second.txt"))); + } + finally + { + File.SetUnixFileMode(destination, originalMode); + } + } + [ConditionalTheory(typeof(PlatformDetection), nameof(PlatformDetection.IsNotPrivilegedProcess))] [MemberData(nameof(GetBooleanData))] public async Task Extract_SpecialFiles_Unix_Unelevated_ThrowsUnauthorizedAccess(bool async) @@ -28,5 +171,6 @@ public async Task Extract_SpecialFiles_Unix_Unelevated_ThrowsUnauthorizedAccess( Assert.Equal(0, Directory.GetFileSystemEntries(destination).Count()); } + } } diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs index 2e6b2fda060f66..fb9e63e557f36c 100644 --- a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.Windows.cs @@ -46,5 +46,82 @@ public void ExtractToDirectory_RejectsSymlinkWithRootedTargetOutsideDestination( Assert.Throws(() => TarFile.ExtractToDirectory(tarPath, destDir, overwriteFiles: true)); Assert.Empty(Directory.EnumerateFileSystemEntries(destDir)); } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task ExtractToDirectory_DestinationThroughSymbolicLink(bool async) + { + using TempDirectory root = new TempDirectory(); + string physicalDirectory = Path.Join(root.Path, "physical"); + string logicalDirectory = Path.Join(root.Path, "logical"); + string archive = Path.Join(root.Path, "input.tar"); + byte[] expected = [1, 2, 3]; + Directory.CreateDirectory(physicalDirectory); + Directory.CreateSymbolicLink(logicalDirectory, physicalDirectory); + using MemoryStream data = new MemoryStream(expected); + using (FileStream stream = new FileStream(archive, FileMode.CreateNew, FileAccess.Write)) + using (TarWriter writer = new TarWriter(stream)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "nested/file.txt") + { + DataStream = data, + ModificationTime = TestModificationTime + }); + } + + await ExtractToDirectory(archive, logicalDirectory, overwriteFiles: false, async); + + string destination = Path.Join(physicalDirectory, "nested", "file.txt"); + Assert.Equal(expected, File.ReadAllBytes(destination)); + Assert.Equal(TestModificationTime.UtcDateTime, File.GetLastWriteTimeUtc(destination)); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task ExtractToDirectory_ReparseRootIsResolvedForEachEntry(bool ancestorLink, bool async) + { + using TempDirectory root = new TempDirectory(); + string firstPhysical = Path.Join(root.Path, "first"); + string secondPhysical = Path.Join(root.Path, "second"); + string logical = Path.Join(root.Path, "logical"); + Directory.CreateDirectory(firstPhysical); + Directory.CreateDirectory(secondPhysical); + Directory.CreateSymbolicLink(logical, firstPhysical); + string destination = ancestorLink ? Path.Join(logical, "destination") : logical; + string firstDestination = ancestorLink ? Path.Join(firstPhysical, "destination") : firstPhysical; + string secondDestination = ancestorLink ? Path.Join(secondPhysical, "destination") : secondPhysical; + Directory.CreateDirectory(firstDestination); + Directory.CreateDirectory(secondDestination); + byte[] expected = [1, 2, 3]; + long secondHeaderOffset; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, TarEntryFormat.Ustar, leaveOpen: true)) + { + using MemoryStream firstData = new MemoryStream(expected); + writer.WriteEntry(new UstarTarEntry(TarEntryType.RegularFile, "first.txt") { DataStream = firstData }); + secondHeaderOffset = archive.Position; + using MemoryStream secondData = new MemoryStream(expected); + writer.WriteEntry(new UstarTarEntry(TarEntryType.RegularFile, "second.txt") { DataStream = secondData }); + } + + bool switched = false; + using RootChangeStream source = new(archive.ToArray(), secondHeaderOffset, () => + { + Assert.Equal(expected, File.ReadAllBytes(Path.Join(firstDestination, "first.txt"))); + Directory.Delete(logical); + Directory.CreateSymbolicLink(logical, secondPhysical); + switched = true; + }); + + await ExtractToDirectory(source, destination, overwriteFiles: false, async); + + Assert.True(switched); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(secondDestination, "second.txt"))); + Assert.False(File.Exists(Path.Join(firstDestination, "second.txt"))); + } + } } diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs index e9b02a72d26303..25eae3ea347775 100644 --- a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.File.Tests.cs @@ -518,6 +518,41 @@ public void ExtractToDirectory_RejectsSymlinkDirectoryTraversal_WithNestedFile() Assert.False(File.Exists(outsideFilePath) || Directory.Exists(outsideFilePath), "traversal link should not have been created."); } + private sealed class RootChangeStream : MemoryStream + { + private readonly long _changePosition; + private readonly Action _changeRoot; + private bool _changed; + + internal RootChangeStream(byte[] data, long changePosition, Action changeRoot) + : base(data, writable: false) + { + _changePosition = changePosition; + _changeRoot = changeRoot; + } + + public override int Read(Span buffer) + { + ChangeRootIfNeeded(); + return base.Read(buffer); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + ChangeRootIfNeeded(); + return base.ReadAsync(buffer, cancellationToken); + } + + private void ChangeRootIfNeeded() + { + if (!_changed && Position >= _changePosition) + { + _changed = true; + _changeRoot(); + } + } + } + [ConditionalFact(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] public void ExtractToDirectory_RejectsChainedSymlinkDirectoryTraversal_WithNestedFile() { diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs index ba3768bf425908..f9930103fa9a70 100644 --- a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs @@ -109,6 +109,196 @@ public async Task ExtractEntry_ManySubfolderSegments_NoPrecedingDirectoryEntries Assert.True(File.Exists(Path.Join(root.Path, fileWithTwoSegments))); } + [Theory] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Extract_NoFilesystemEntries_DoesNotCreateDestination(bool globalAttributes, bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "missing", "destination"); + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + if (globalAttributes) + { + writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry(new Dictionary { ["custom"] = "value" })); + } + } + archive.Position = 0; + + await ExtractToDirectory(archive, destination, overwriteFiles: false, async); + + Assert.False(Directory.Exists(Path.Join(root.Path, "missing"))); + } + + [Theory] + [MemberData(nameof(GetBooleanData))] + public async Task Extract_MissingDestination_MultipleEntries(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "missing", "destination"); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string name in new[] { "first/file.txt", "second/file.txt" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data }); + } + } + archive.Position = 0; + + await ExtractToDirectory(archive, destination, overwriteFiles: false, async); + + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "first", "file.txt"))); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "second", "file.txt"))); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task Extract_PreexistingDirectoryLinkOutsideDestination_AfterOrdinaryEntries(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + string outside = Path.Join(root.Path, "outside"); + Directory.CreateDirectory(destination); + Directory.CreateDirectory(outside); + Directory.CreateSymbolicLink(Path.Join(destination, "link"), outside); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string name in new[] { "ordinary/nested/first.txt", "ordinary/nested/second.txt", "link/escaped.txt" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data }); + } + } + archive.Position = 0; + + await Assert.ThrowsAsync(() => ExtractToDirectory(archive, destination, overwriteFiles: true, async)); + + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "first.txt"))); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "second.txt"))); + Assert.Empty(Directory.EnumerateFileSystemEntries(outside)); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [InlineData(false, false)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(true, true)] + public async Task Extract_SymbolicLinkAfterOrdinaryEntries_RejectsOutsideTarget(bool missingDestination, bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + if (!missingDestination) + { + Directory.CreateDirectory(destination); + } + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string name in new[] { "ordinary/nested/first.txt", "ordinary/nested/second.txt" }) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data }); + } + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "ordinary/nested/link") { LinkName = "../../../outside" }); + } + archive.Position = 0; + + await Assert.ThrowsAsync(() => ExtractToDirectory(archive, destination, overwriteFiles: true, async)); + + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "first.txt"))); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "second.txt"))); + Assert.Null(new FileInfo(Path.Join(destination, "ordinary", "nested", "link")).LinkTarget); + Assert.False(Path.Exists(Path.Join(root.Path, "outside"))); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task Extract_SymbolicLinkAfterOrdinaryEntries_CanBeOverwritten(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + Directory.CreateDirectory(destination); + byte[] original = [1, 2, 3]; + byte[] replacement = [4, 5, 6]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + foreach (string name in new[] { "ordinary/nested/first.txt", "ordinary/nested/second.txt" }) + { + using MemoryStream data = new MemoryStream(original); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, name) { DataStream = data }); + } + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "ordinary/nested/link.txt") { LinkName = "first.txt" }); + using MemoryStream replacementData = new MemoryStream(replacement); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "ordinary/nested/link.txt") { DataStream = replacementData }); + } + archive.Position = 0; + + await ExtractToDirectory(archive, destination, overwriteFiles: true, async); + + Assert.Equal(original, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "first.txt"))); + Assert.Equal(original, File.ReadAllBytes(Path.Join(destination, "ordinary", "nested", "second.txt"))); + string replacedPath = Path.Join(destination, "ordinary", "nested", "link.txt"); + Assert.Equal(replacement, File.ReadAllBytes(replacedPath)); + Assert.Null(new FileInfo(replacedPath).LinkTarget); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task Extract_ArchiveCannotReplaceCreatedDirectoryWithSymbolicLink(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + Directory.CreateDirectory(destination); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "ordinary/nested/file.txt") { DataStream = data }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "ordinary/nested") { LinkName = "target" }); + } + archive.Position = 0; + + await Assert.ThrowsAsync(() => ExtractToDirectory(archive, destination, overwriteFiles: true, async)); + + string directory = Path.Join(destination, "ordinary", "nested"); + Assert.True(Directory.Exists(directory)); + Assert.Null(new DirectoryInfo(directory).LinkTarget); + Assert.Equal(expected, File.ReadAllBytes(Path.Join(directory, "file.txt"))); + } + + [ConditionalTheory(typeof(MountHelper), nameof(MountHelper.CanCreateSymbolicLinks))] + [MemberData(nameof(GetBooleanData))] + public async Task Extract_ArchiveCannotReplaceDestinationRoot(bool async) + { + using TempDirectory root = new TempDirectory(); + string destination = Path.Join(root.Path, "destination"); + byte[] expected = [1, 2, 3]; + using MemoryStream archive = new MemoryStream(); + using (TarWriter writer = new TarWriter(archive, leaveOpen: true)) + { + using MemoryStream data = new MemoryStream(expected); + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "file.txt") { DataStream = data }); + writer.WriteEntry(new PaxTarEntry(TarEntryType.SymbolicLink, "./") { LinkName = "inside" }); + } + archive.Position = 0; + + await Assert.ThrowsAsync(() => ExtractToDirectory(archive, destination, overwriteFiles: true, async)); + + Assert.Equal(expected, File.ReadAllBytes(Path.Join(destination, "file.txt"))); + Assert.Null(new DirectoryInfo(destination).LinkTarget); + } + [Theory] [MemberData(nameof(GetBooleanData))] public async Task ExtractToDirectory_DifferentlyCasedSiblingDirectory_Throws(bool async)