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)