diff --git a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs index f60105a52..f89ba345e 100644 --- a/src/SharpCompress/Archives/IArchiveEntryExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveEntryExtensions.cs @@ -106,8 +106,7 @@ public void WriteToDirectory( string destinationDirectory, ExtractionOptions? options = null ) => - ExtractionMethods.WriteEntryToDirectory( - entry, + entry.WriteEntryToDirectory( destinationDirectory, options, (path) => entry.WriteToFile(path, options) @@ -121,9 +120,8 @@ public async ValueTask WriteToDirectoryAsync( ExtractionOptions? options = null, CancellationToken cancellationToken = default ) => - await ExtractionMethods + await entry .WriteEntryToDirectoryAsync( - entry, destinationDirectory, options, async (path, ct) => @@ -136,8 +134,7 @@ await entry.WriteToFileAsync(path, options, ct).ConfigureAwait(false), /// Extract to specific file /// public void WriteToFile(string destinationFileName, ExtractionOptions? options = null) => - ExtractionMethods.WriteEntryToFile( - entry, + entry.WriteEntryToFile( destinationFileName, options, (x, fm) => @@ -155,9 +152,8 @@ public async ValueTask WriteToFileAsync( ExtractionOptions? options = null, CancellationToken cancellationToken = default ) => - await ExtractionMethods + await entry .WriteEntryToFileAsync( - entry, destinationFileName, options, async (x, fm, ct) => diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index 80857a25c..d99a7afde 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using SharpCompress.Common; using SharpCompress.Readers; @@ -39,29 +37,27 @@ private void WriteToDirectoryInternal( IProgress? progress ) { + options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath( + destinationDirectory + ); + var totalBytes = archive.TotalUncompressedSize; var bytesRead = 0L; - var seenDirectories = new HashSet(); foreach (var entry in archive.Entries) { if (entry.IsDirectory) { - var dirPath = Path.Combine( - destinationDirectory, - entry.Key.NotNull("Entry Key is null") - ); - if ( - Path.GetDirectoryName(dirPath + "/") is { } parentDirectory - && seenDirectories.Add(dirPath) - ) - { - Directory.CreateDirectory(parentDirectory); - } + entry.WriteEntryToDirectoryCore(fullDestinationDirectoryPath, options, null); continue; } - entry.WriteToDirectory(destinationDirectory, options); + entry.WriteEntryToDirectoryCore( + fullDestinationDirectoryPath, + options, + path => entry.WriteToFile(path, options) + ); bytesRead += entry.Size; progress?.Report( diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index 9ba599776..21d2730cb 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -1,6 +1,4 @@ using System; -using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; using SharpCompress.Common; @@ -15,7 +13,6 @@ public static class IAsyncArchiveExtensions /// /// Extract to specific directory asynchronously with progress reporting and cancellation support /// - /// The archive to extract. /// The folder to extract into. /// Extraction options. /// Optional progress reporter for tracking extraction progress. @@ -59,9 +56,13 @@ private async ValueTask WriteToDirectoryAsyncInternal( CancellationToken cancellationToken ) { + options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath( + destinationDirectory + ); + var totalBytes = await archive.TotalUncompressedSizeAsync().ConfigureAwait(false); var bytesRead = 0L; - var seenDirectories = new HashSet(); await foreach (var entry in archive.EntriesAsync.WithCancellation(cancellationToken)) { @@ -69,22 +70,25 @@ CancellationToken cancellationToken if (entry.IsDirectory) { - var dirPath = Path.Combine( - destinationDirectory, - entry.Key.NotNull("Entry Key is null") - ); - if ( - Path.GetDirectoryName(dirPath + "/") is { } parentDirectory - && seenDirectories.Add(dirPath) - ) - { - Directory.CreateDirectory(parentDirectory); - } + await entry + .WriteEntryToDirectoryAsyncCore( + fullDestinationDirectoryPath, + options, + null, + cancellationToken + ) + .ConfigureAwait(false); continue; } await entry - .WriteToDirectoryAsync(destinationDirectory, options, cancellationToken) + .WriteEntryToDirectoryAsyncCore( + fullDestinationDirectoryPath, + options, + async (path, ct) => + await entry.WriteToFileAsync(path, options, ct).ConfigureAwait(false), + cancellationToken + ) .ConfigureAwait(false); bytesRead += entry.Size; diff --git a/src/SharpCompress/Common/DirectoryManagement.cs b/src/SharpCompress/Common/DirectoryManagement.cs new file mode 100644 index 000000000..4bd4585d1 --- /dev/null +++ b/src/SharpCompress/Common/DirectoryManagement.cs @@ -0,0 +1,77 @@ +using System.IO; + +namespace SharpCompress.Common; + +internal static class DirectoryManagement +{ + internal const string CreateDirectoryOutsideDestinationMessage = + "Entry is trying to create a directory outside of the destination directory."; + internal const string WriteFileOutsideDestinationMessage = + "Entry is trying to write a file outside of the destination directory."; + + internal static string GetFullDestinationDirectoryPath(string destinationDirectory) + { + var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); + + // Keep the trailing separator so prefix checks cannot match sibling directories. + if ( + !IsDirectorySeparator( + fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1] + ) + ) + { + fullDestinationDirectoryPath += Path.DirectorySeparatorChar; + } + + if (!Directory.Exists(fullDestinationDirectoryPath)) + { + throw new ExtractionException( + $"Directory does not exist to extract to: {fullDestinationDirectoryPath}" + ); + } + + return fullDestinationDirectoryPath; + } + + internal static void EnsurePathInDestinationDirectory( + string destinationPath, + string fullDestinationDirectoryPath, + string exceptionMessage + ) + { + if (destinationPath.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) + { + return; + } + + if ( + string.Equals( + destinationPath, + TrimTrailingDirectorySeparators(fullDestinationDirectoryPath), + Utility.PathComparison + ) + ) + { + return; + } + + throw new ExtractionException(exceptionMessage); + } + + private static bool IsDirectorySeparator(char value) => + value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar; + + private static string TrimTrailingDirectorySeparators(string path) + { + var root = Path.GetPathRoot(path); + var rootLength = root?.Length ?? 0; + var end = path.Length; + + while (end > rootLength && IsDirectorySeparator(path[end - 1])) + { + end--; + } + + return end == path.Length ? path : path.Substring(0, end); + } +} diff --git a/src/SharpCompress/Common/ExtractionMethods.Async.cs b/src/SharpCompress/Common/ExtractionMethods.Async.cs deleted file mode 100644 index 053e62f74..000000000 --- a/src/SharpCompress/Common/ExtractionMethods.Async.cs +++ /dev/null @@ -1,109 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace SharpCompress.Common; - -internal static partial class ExtractionMethods -{ - public static async ValueTask WriteEntryToDirectoryAsync( - IEntry entry, - string destinationDirectory, - ExtractionOptions? options, - Func writeAsync, - CancellationToken cancellationToken = default - ) - { - string destinationFileName; - var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); - options ??= new ExtractionOptions(); - - //check for trailing slash. - if ( - fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1] - != Path.DirectorySeparatorChar - ) - { - fullDestinationDirectoryPath += Path.DirectorySeparatorChar; - } - - if (!Directory.Exists(fullDestinationDirectoryPath)) - { - throw new ExtractionException( - $"Directory does not exist to extract to: {fullDestinationDirectoryPath}" - ); - } - - var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null"); - file = Utility.ReplaceInvalidFileNameChars(file); - if (options.ExtractFullPath) - { - var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); - - if (!Directory.Exists(destdir)) - { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) - { - throw new ExtractionException( - "Entry is trying to create a directory outside of the destination directory." - ); - } - - Directory.CreateDirectory(destdir); - } - destinationFileName = Path.Combine(destdir, file); - } - else - { - destinationFileName = Path.Combine(fullDestinationDirectoryPath, file); - } - - if (!entry.IsDirectory) - { - destinationFileName = Path.GetFullPath(destinationFileName); - - if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) - { - throw new ExtractionException( - "Entry is trying to write a file outside of the destination directory." - ); - } - await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); - } - else if (options.ExtractFullPath && !Directory.Exists(destinationFileName)) - { - Directory.CreateDirectory(destinationFileName); - } - } - - public static async ValueTask WriteEntryToFileAsync( - IEntry entry, - string destinationFileName, - ExtractionOptions? options, - Func openAndWriteAsync, - CancellationToken cancellationToken = default - ) - { - options ??= new ExtractionOptions(); - if (entry.LinkTarget != null) - { - options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); - } - else - { - var fm = FileMode.Create; - - if (!options.Overwrite) - { - fm = FileMode.CreateNew; - } - - await openAndWriteAsync(destinationFileName, fm, cancellationToken) - .ConfigureAwait(false); - entry.PreserveExtractionOptions(destinationFileName, options); - } - } -} diff --git a/src/SharpCompress/Common/ExtractionMethods.cs b/src/SharpCompress/Common/ExtractionMethods.cs deleted file mode 100644 index f7980fa80..000000000 --- a/src/SharpCompress/Common/ExtractionMethods.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System; -using System.IO; -using System.Runtime.InteropServices; - -namespace SharpCompress.Common; - -internal static partial class ExtractionMethods -{ - /// - /// Gets the appropriate StringComparison for path checks based on the file system. - /// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems. - /// - private static StringComparison PathComparison => - RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; - - /// - /// Extract to specific directory, retaining filename - /// - public static void WriteEntryToDirectory( - IEntry entry, - string destinationDirectory, - ExtractionOptions? options, - Action write - ) - { - string destinationFileName; - var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); - options ??= new ExtractionOptions(); - - //check for trailing slash. - if ( - fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1] - != Path.DirectorySeparatorChar - ) - { - fullDestinationDirectoryPath += Path.DirectorySeparatorChar; - } - - if (!Directory.Exists(fullDestinationDirectoryPath)) - { - throw new ExtractionException( - $"Directory does not exist to extract to: {fullDestinationDirectoryPath}" - ); - } - - var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null"); - file = Utility.ReplaceInvalidFileNameChars(file); - if (options.ExtractFullPath) - { - var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); - - if (!Directory.Exists(destdir)) - { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) - { - throw new ExtractionException( - "Entry is trying to create a directory outside of the destination directory." - ); - } - - Directory.CreateDirectory(destdir); - } - destinationFileName = Path.Combine(destdir, file); - } - else - { - destinationFileName = Path.Combine(fullDestinationDirectoryPath, file); - } - - if (!entry.IsDirectory) - { - destinationFileName = Path.GetFullPath(destinationFileName); - - if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) - { - throw new ExtractionException( - "Entry is trying to write a file outside of the destination directory." - ); - } - write(destinationFileName); - } - else if (options.ExtractFullPath && !Directory.Exists(destinationFileName)) - { - Directory.CreateDirectory(destinationFileName); - } - } - - public static void WriteEntryToFile( - IEntry entry, - string destinationFileName, - ExtractionOptions? options, - Action openAndWrite - ) - { - options ??= new ExtractionOptions(); - if (entry.LinkTarget != null) - { - options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); - } - else - { - var fm = FileMode.Create; - - if (!options.Overwrite) - { - fm = FileMode.CreateNew; - } - - openAndWrite(destinationFileName, fm); - entry.PreserveExtractionOptions(destinationFileName, options); - } - } -} diff --git a/src/SharpCompress/Common/IEntry.Extensions.cs b/src/SharpCompress/Common/IEntry.Extensions.cs deleted file mode 100644 index 7f0cda432..000000000 --- a/src/SharpCompress/Common/IEntry.Extensions.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.IO; - -namespace SharpCompress.Common; - -internal static class EntryExtensions -{ - internal static void PreserveExtractionOptions( - this IEntry entry, - string destinationFileName, - ExtractionOptions options - ) - { - if (options.PreserveFileTime || options.PreserveAttributes) - { - var nf = new FileInfo(destinationFileName); - if (!nf.Exists) - { - return; - } - - // update file time to original packed time - if (options.PreserveFileTime) - { - if (entry.CreatedTime.HasValue) - { - try - { - nf.CreationTime = entry.CreatedTime.Value; - } - catch - { - // Invalid time or the OS rejected - } - } - - if (entry.LastModifiedTime.HasValue) - { - try - { - nf.LastWriteTime = entry.LastModifiedTime.Value; - } - catch - { - // Invalid time or the OS rejected - } - } - - if (entry.LastAccessedTime.HasValue) - { - try - { - nf.LastAccessTime = entry.LastAccessedTime.Value; - } - catch - { - // Invalid time or the OS rejected - } - } - } - - if (options.PreserveAttributes) - { - if (entry.Attrib.HasValue) - { - nf.Attributes = (FileAttributes) - System.Enum.ToObject(typeof(FileAttributes), entry.Attrib.Value); - } - } - } - } -} diff --git a/src/SharpCompress/Common/IEntryExtensions.Async.cs b/src/SharpCompress/Common/IEntryExtensions.Async.cs new file mode 100644 index 000000000..7b42f1c85 --- /dev/null +++ b/src/SharpCompress/Common/IEntryExtensions.Async.cs @@ -0,0 +1,106 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Common; + +internal static partial class IEntryExtensions +{ + extension(IEntry entry) + { + public async ValueTask WriteEntryToDirectoryAsync( + string destinationDirectory, + ExtractionOptions? options, + Func writeAsync, + CancellationToken cancellationToken = default + ) + { + options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath( + destinationDirectory + ); + + await WriteEntryToDirectoryAsyncCore( + entry, + fullDestinationDirectoryPath, + options, + writeAsync, + cancellationToken + ) + .ConfigureAwait(false); + } + + internal async ValueTask WriteEntryToDirectoryAsyncCore( + string fullDestinationDirectoryPath, + ExtractionOptions options, + Func? writeAsync, + CancellationToken cancellationToken = default + ) + { + var destinationFileName = GetEntryDestinationFileName( + entry, + fullDestinationDirectoryPath, + options + ); + + if (!entry.IsDirectory) + { + destinationFileName = Path.GetFullPath(destinationFileName); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.WriteFileOutsideDestinationMessage + ); + + if (writeAsync != null) + { + await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); + } + } + else if (options.ExtractFullPath) + { + destinationFileName = Path.GetFullPath(destinationFileName); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.CreateDirectoryOutsideDestinationMessage + ); + + if (!Directory.Exists(destinationFileName)) + { + Directory.CreateDirectory(destinationFileName); + } + } + } + + public async ValueTask WriteEntryToFileAsync( + string destinationFileName, + ExtractionOptions? options, + Func openAndWriteAsync, + CancellationToken cancellationToken = default + ) + { + options ??= new ExtractionOptions(); + if (entry.LinkTarget != null) + { + options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); + } + else + { + var fm = FileMode.Create; + + if (!options.Overwrite) + { + fm = FileMode.CreateNew; + } + + await openAndWriteAsync(destinationFileName, fm, cancellationToken) + .ConfigureAwait(false); + entry.PreserveExtractionOptions(destinationFileName, options); + } + } + } +} diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs new file mode 100644 index 000000000..77c5207cb --- /dev/null +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -0,0 +1,190 @@ +using System; +using System.IO; + +namespace SharpCompress.Common; + +internal static partial class IEntryExtensions +{ + extension(IEntry entry) + { + /// + /// Extract to specific directory, retaining filename + /// + public void WriteEntryToDirectory( + string destinationDirectory, + ExtractionOptions? options, + Action write + ) + { + options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath( + destinationDirectory + ); + + WriteEntryToDirectoryCore(entry, fullDestinationDirectoryPath, options, write); + } + + internal void WriteEntryToDirectoryCore( + string fullDestinationDirectoryPath, + ExtractionOptions options, + Action? write + ) + { + var destinationFileName = GetEntryDestinationFileName( + entry, + fullDestinationDirectoryPath, + options + ); + + if (!entry.IsDirectory) + { + destinationFileName = Path.GetFullPath(destinationFileName); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.WriteFileOutsideDestinationMessage + ); + write?.Invoke(destinationFileName); + } + else if (options.ExtractFullPath) + { + destinationFileName = Path.GetFullPath(destinationFileName); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.CreateDirectoryOutsideDestinationMessage + ); + + if (!Directory.Exists(destinationFileName)) + { + Directory.CreateDirectory(destinationFileName); + } + } + } + + private string GetEntryDestinationFileName( + string fullDestinationDirectoryPath, + ExtractionOptions options + ) + { + var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")) + .NotNull("File is null"); + file = Utility.ReplaceInvalidFileNameChars(file); + + if (options.ExtractFullPath) + { + var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) + .NotNull("Directory is null"); + var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destdir, + fullDestinationDirectoryPath, + entry.IsDirectory + ? DirectoryManagement.CreateDirectoryOutsideDestinationMessage + : DirectoryManagement.WriteFileOutsideDestinationMessage + ); + + if (!Directory.Exists(destdir)) + { + Directory.CreateDirectory(destdir); + } + + return Path.Combine(destdir, file); + } + + return Path.Combine(fullDestinationDirectoryPath, file); + } + + public void WriteEntryToFile( + string destinationFileName, + ExtractionOptions? options, + Action openAndWrite + ) + { + options ??= new ExtractionOptions(); + if (entry.LinkTarget != null) + { + options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); + } + else + { + var fm = FileMode.Create; + + if (!options.Overwrite) + { + fm = FileMode.CreateNew; + } + + openAndWrite(destinationFileName, fm); + entry.PreserveExtractionOptions(destinationFileName, options); + } + } + + internal void PreserveExtractionOptions( + string destinationFileName, + ExtractionOptions options + ) + { + if (options.PreserveFileTime || options.PreserveAttributes) + { + var nf = new FileInfo(destinationFileName); + if (!nf.Exists) + { + return; + } + + // update file time to original packed time + if (options.PreserveFileTime) + { + if (entry.CreatedTime.HasValue) + { + try + { + nf.CreationTime = entry.CreatedTime.Value; + } + catch + { + // Invalid time or the OS rejected + } + } + + if (entry.LastModifiedTime.HasValue) + { + try + { + nf.LastWriteTime = entry.LastModifiedTime.Value; + } + catch + { + // Invalid time or the OS rejected + } + } + + if (entry.LastAccessedTime.HasValue) + { + try + { + nf.LastAccessTime = entry.LastAccessedTime.Value; + } + catch + { + // Invalid time or the OS rejected + } + } + } + + if (options.PreserveAttributes) + { + if (entry.Attrib.HasValue) + { + nf.Attributes = (FileAttributes) + Enum.ToObject(typeof(FileAttributes), entry.Attrib.Value); + } + } + } + } + } +} diff --git a/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs b/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs index c0b25ebb5..57aa20a55 100644 --- a/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs +++ b/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs @@ -45,7 +45,21 @@ public virtual async ValueTask ReadBytesAsync( { CurrentReadByteCount += count; var bytes = new byte[count]; - await _reader.ReadBytesAsync(bytes, 0, count, cancellationToken).ConfigureAwait(false); + try + { + await _reader.ReadBytesAsync(bytes, 0, count, cancellationToken).ConfigureAwait(false); + } + catch (IncompleteArchiveException ex) + { + throw new InvalidFormatException( + string.Format( + Constants.DefaultCultureInfo, + "Could not read the requested amount of bytes. End of stream reached. Requested: {0}", + count + ), + ex + ); + } return bytes; } diff --git a/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs b/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs index 793dccaed..6405423ae 100644 --- a/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs +++ b/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs @@ -281,13 +281,13 @@ private void ClearMask() internal bool DecodeInit(IRarUnpack unpackRead, int escChar) { - var maxOrder = unpackRead.Char & 0xff; + var maxOrder = unpackRead.ReadChar() & 0xff; var reset = ((maxOrder & 0x20) != 0); var maxMb = 0; if (reset) { - maxMb = unpackRead.Char; + maxMb = unpackRead.ReadChar(); } else { @@ -298,7 +298,7 @@ internal bool DecodeInit(IRarUnpack unpackRead, int escChar) } if ((maxOrder & 0x40) != 0) { - escChar = unpackRead.Char; + escChar = unpackRead.ReadChar(); unpackRead.PpmEscChar = escChar; } Coder = new RangeCoder(unpackRead); @@ -333,6 +333,65 @@ internal bool DecodeInit(IRarUnpack unpackRead, int escChar) return (_minContext.Address != 0); } + internal async ValueTask DecodeInitAsync( + IRarUnpack unpackRead, + int escChar, + CancellationToken cancellationToken = default + ) + { + var maxOrder = + await unpackRead.ReadCharAsync(cancellationToken).ConfigureAwait(false) & 0xff; + var reset = ((maxOrder & 0x20) != 0); + + var maxMb = 0; + if (reset) + { + maxMb = await unpackRead.ReadCharAsync(cancellationToken).ConfigureAwait(false); + } + else + { + if (SubAlloc.GetAllocatedMemory() == 0) + { + return false; + } + } + if ((maxOrder & 0x40) != 0) + { + escChar = await unpackRead.ReadCharAsync(cancellationToken).ConfigureAwait(false); + unpackRead.PpmEscChar = escChar; + } + Coder = new RangeCoder(); + await Coder.InitAsync(unpackRead, cancellationToken).ConfigureAwait(false); + if (reset) + { + maxOrder = (maxOrder & 0x1f) + 1; + if (maxOrder > 16) + { + maxOrder = 16 + ((maxOrder - 16) * 3); + } + if (maxOrder == 1) + { + SubAlloc.StopSubAllocator(); + return false; + } + SubAlloc.StartSubAllocator((maxMb + 1) << 20); + _minContext = new PpmContext(Heap); + + _maxContext = new PpmContext(Heap); + FoundState = new State(Heap); + _dummySee2Cont = new See2Context(); + for (var i = 0; i < 25; i++) + { + for (var j = 0; j < 16; j++) + { + _see2Cont[i][j] = new See2Context(); + } + } + StartModelRare(maxOrder); + } + return _minContext.Address != 0; + } + public virtual int DecodeChar() { // Debug diff --git a/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs b/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs index 0a3b2321e..7cda0fecc 100644 --- a/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs +++ b/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs @@ -19,7 +19,7 @@ internal class RangeCoder private long _low, _code, _range; - private readonly IRarUnpack _unpackRead; + private IRarUnpack _unpackRead; private readonly Stream _stream; internal RangeCoder(IRarUnpack unpackRead) @@ -36,6 +36,24 @@ internal RangeCoder(Stream stream) internal RangeCoder() { } + internal async ValueTask InitAsync( + IRarUnpack unpackRead, + CancellationToken cancellationToken = default + ) + { + _unpackRead = unpackRead; + SubRange = new SubRange(); + + _low = _code = 0L; + _range = 0xFFFFffffL; + for (var i = 0; i < 4; i++) + { + _code = + ((_code << 8) | await ReadCharAsync(cancellationToken).ConfigureAwait(false)) + & UINT_MASK; + } + } + private void Init() { SubRange = new SubRange(); @@ -44,7 +62,7 @@ private void Init() _range = 0xFFFFffffL; for (var i = 0; i < 4; i++) { - _code = ((_code << 8) | Char) & UINT_MASK; + _code = ((_code << 8) | ReadChar()) & UINT_MASK; } } @@ -73,20 +91,17 @@ internal int CurrentCount } } - private long Char + private long ReadChar() { - get + if (_unpackRead != null) { - if (_unpackRead != null) - { - return (_unpackRead.Char); - } - if (_stream != null) - { - return _stream.ReadByte(); - } - return -1; + return (_unpackRead.ReadChar()); + } + if (_stream != null) + { + return _stream.ReadByte(); } + return -1; } internal SubRange SubRange { get; private set; } @@ -121,7 +136,7 @@ internal void AriDecNormalize() _range = (-_low & (BOT - 1)) & UINT_MASK; c2 = false; } - _code = ((_code << 8) | Char) & UINT_MASK; + _code = ((_code << 8) | ReadChar()) & UINT_MASK; _range = (_range << 8) & UINT_MASK; _low = (_low << 8) & UINT_MASK; } @@ -131,7 +146,7 @@ private async ValueTask ReadCharAsync(CancellationToken cancellationToken { if (_unpackRead != null) { - return _unpackRead.Char; + return await _unpackRead.ReadCharAsync(cancellationToken).ConfigureAwait(false); } if (_stream != null) { diff --git a/src/SharpCompress/Compressors/Rar/IRarUnpack.cs b/src/SharpCompress/Compressors/Rar/IRarUnpack.cs index 7626f4ace..45729bf57 100644 --- a/src/SharpCompress/Compressors/Rar/IRarUnpack.cs +++ b/src/SharpCompress/Compressors/Rar/IRarUnpack.cs @@ -22,6 +22,7 @@ CancellationToken cancellationToken bool Suspended { get; set; } long DestSize { get; } - int Char { get; } + int ReadChar(); + ValueTask ReadCharAsync(CancellationToken cancellationToken); int PpmEscChar { get; set; } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs index 37655cf14..de4d63e9e 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs @@ -1,5 +1,6 @@ using System; using System.Buffers; +using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; @@ -112,7 +113,10 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken { return; } - if ((!solid || !tablesRead) && !ReadTables()) + if ( + (!solid || !tablesRead) + && !await ReadTablesAsync(cancellationToken).ConfigureAwait(false) + ) { return; } @@ -137,7 +141,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken if (((wrPtr - unpPtr) & PackDef.MAXWINMASK) < 260 && wrPtr != unpPtr) { - UnpWriteBuf(); + await UnpWriteBufAsync(cancellationToken).ConfigureAwait(false); if (destUnpSize < 0) { return; @@ -150,7 +154,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (unpBlockType == BlockTypes.BLOCK_PPM) { - var Ch = ppm.DecodeChar(); + var Ch = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); if (Ch == -1) { ppmError = true; @@ -158,10 +162,10 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (Ch == PpmEscChar) { - var NextCh = ppm.DecodeChar(); + var NextCh = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); if (NextCh == 0) { - if (!ReadTables()) + if (!await ReadTablesAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -173,7 +177,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (NextCh == 3) { - if (!ReadVMCodePPM()) + if (!await ReadVMCodePPMAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -186,7 +190,8 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken var failed = false; for (var I = 0; I < 4 && !failed; I++) { - var ch = ppm.DecodeChar(); + var ch = await ppm.DecodeCharAsync(cancellationToken) + .ConfigureAwait(false); if (ch == -1) { failed = true; @@ -212,7 +217,8 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (NextCh == 5) { - var Length = ppm.DecodeChar(); + var Length = await ppm.DecodeCharAsync(cancellationToken) + .ConfigureAwait(false); if (Length == -1) { break; @@ -294,7 +300,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (Number == 256) { - if (!ReadEndOfBlock()) + if (!await ReadEndOfBlockAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -302,7 +308,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (Number == 257) { - if (!ReadVMCode()) + if (!await ReadVMCodeAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -350,7 +356,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken CopyString(2, Distance); } } - UnpWriteBuf(); + await UnpWriteBufAsync(cancellationToken).ConfigureAwait(false); } private async Task UnpWriteBufAsync(CancellationToken cancellationToken = default) @@ -600,4 +606,267 @@ await writeStream writtenFileSize += size; destUnpSize -= size; } + + private async Task ReadTablesAsync(CancellationToken cancellationToken = default) + { + var bitLengthArray = ArrayPool.Shared.Rent(PackDef.BC); + var bitLength = new Memory(bitLengthArray, 0, PackDef.BC); + var tableArray = ArrayPool.Shared.Rent(PackDef.HUFF_TABLE_SIZE); + var table = new Memory(tableArray, 0, PackDef.HUFF_TABLE_SIZE); + + try + { + if (inAddr > readTop - 25) + { + if (!await unpReadBufAsync(cancellationToken).ConfigureAwait(false)) + { + return false; + } + } + + AddBits((8 - inBit) & 7); + long bitField = GetBits() & unchecked((int)0xffFFffFF); + if ((bitField & 0x8000) != 0) + { + unpBlockType = BlockTypes.BLOCK_PPM; + return await ppm.DecodeInitAsync(this, PpmEscChar, cancellationToken) + .ConfigureAwait(false); + } + + unpBlockType = BlockTypes.BLOCK_LZ; + + prevLowDist = 0; + lowDistRepCount = 0; + + if ((bitField & 0x4000) == 0) + { + new Span(unpOldTable).Clear(); + } + + AddBits(2); + + for (var i = 0; i < PackDef.BC; i++) + { + var length = (Utility.URShift(GetBits(), 12)) & 0xFF; + AddBits(4); + if (length == 15) + { + var zeroCount = (Utility.URShift(GetBits(), 12)) & 0xFF; + AddBits(4); + if (zeroCount == 0) + { + bitLength.Span[i] = 15; + } + else + { + zeroCount += 2; + while (zeroCount-- > 0 && i < bitLength.Length) + { + bitLength.Span[i++] = 0; + } + + i--; + } + } + else + { + bitLength.Span[i] = (byte)length; + } + } + + UnpackUtility.makeDecodeTables(bitLength.Span, 0, BD, PackDef.BC); + + var TableSize = PackDef.HUFF_TABLE_SIZE; + + for (var i = 0; i < TableSize; ) + { + if (inAddr > readTop - 5) + { + if (!await unpReadBufAsync(cancellationToken).ConfigureAwait(false)) + { + return false; + } + } + + var Number = this.decodeNumber(BD); + if (Number < 16) + { + table.Span[i] = (byte)((Number + unpOldTable[i]) & 0xf); + i++; + } + else if (Number < 18) + { + int N; + if (Number == 16) + { + N = (Utility.URShift(GetBits(), 13)) + 3; + AddBits(3); + } + else + { + N = (Utility.URShift(GetBits(), 9)) + 11; + AddBits(7); + } + + while (N-- > 0 && i < TableSize) + { + table.Span[i] = table.Span[i - 1]; + i++; + } + } + else + { + int N; + if (Number == 18) + { + N = (Utility.URShift(GetBits(), 13)) + 3; + AddBits(3); + } + else + { + N = (Utility.URShift(GetBits(), 9)) + 11; + AddBits(7); + } + + while (N-- > 0 && i < TableSize) + { + table.Span[i++] = 0; + } + } + } + + tablesRead = true; + if (inAddr > readTop) + { + return false; + } + + UnpackUtility.makeDecodeTables(table.Span, 0, LD, PackDef.NC); + UnpackUtility.makeDecodeTables(table.Span, PackDef.NC, DD, PackDef.DC); + UnpackUtility.makeDecodeTables(table.Span, PackDef.NC + PackDef.DC, LDD, PackDef.LDC); + UnpackUtility.makeDecodeTables( + table.Span, + PackDef.NC + PackDef.DC + PackDef.LDC, + RD, + PackDef.RC + ); + + table.Span.CopyTo(unpOldTable); + return true; + } + finally + { + ArrayPool.Shared.Return(bitLengthArray); + ArrayPool.Shared.Return(tableArray); + } + } + + private async Task ReadEndOfBlockAsync(CancellationToken cancellationToken = default) + { + var BitField = GetBits(); + bool NewTable, + NewFile = false; + if ((BitField & 0x8000) != 0) + { + NewTable = true; + AddBits(1); + } + else + { + NewFile = true; + NewTable = (BitField & 0x4000) != 0; + AddBits(2); + } + tablesRead = !NewTable; + return !( + NewFile || NewTable && !await ReadTablesAsync(cancellationToken).ConfigureAwait(false) + ); + } + + private async Task ReadVMCodeAsync(CancellationToken cancellationToken = default) + { + var FirstByte = GetBits() >> 8; + AddBits(8); + var Length = (FirstByte & 7) + 1; + if (Length == 7) + { + Length = (GetBits() >> 8) + 7; + AddBits(8); + } + else if (Length == 8) + { + Length = GetBits(); + AddBits(16); + } + + var vmCode = new List(); + for (var I = 0; I < Length; I++) + { + if ( + inAddr >= readTop - 1 + && !await unpReadBufAsync(cancellationToken).ConfigureAwait(false) + && I < Length - 1 + ) + { + return false; + } + vmCode.Add((byte)(GetBits() >> 8)); + AddBits(8); + } + return AddVMCode(FirstByte, vmCode); + } + + public async ValueTask ReadCharAsync(CancellationToken cancellationToken = default) + { + if (inAddr > MAX_SIZE - 30) + { + await unpReadBufAsync(cancellationToken).ConfigureAwait(false); + } + return InBuf[inAddr++] & 0xff; + } + + private async Task ReadVMCodePPMAsync(CancellationToken cancellationToken = default) + { + var FirstByte = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); + if (FirstByte == -1) + { + return false; + } + var Length = (FirstByte & 7) + 1; + if (Length == 7) + { + var B1 = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); + if (B1 == -1) + { + return false; + } + Length = B1 + 7; + } + else if (Length == 8) + { + var B1 = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); + if (B1 == -1) + { + return false; + } + var B2 = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); + if (B2 == -1) + { + return false; + } + Length = (B1 * 256) + B2; + } + + var vmCode = new List(); + for (var I = 0; I < Length; I++) + { + var Ch = await ppm.DecodeCharAsync(cancellationToken).ConfigureAwait(false); + if (Ch == -1) + { + return false; + } + vmCode.Add((byte)Ch); + } + return AddVMCode(FirstByte, vmCode); + } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.cs index 929c4eb72..65697a6a4 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.cs @@ -55,16 +55,13 @@ public bool Suspended set => suspended = value; } - public int Char + public int ReadChar() { - get + if (inAddr > MAX_SIZE - 30) { - if (inAddr > MAX_SIZE - 30) - { - unpReadBuf(); - } - return (InBuf[inAddr++] & 0xff); + unpReadBuf(); } + return (InBuf[inAddr++] & 0xff); } public int PpmEscChar { get; set; } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack15.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack15.Async.cs index 2975fbbfa..f2e8c4ca8 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack15.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack15.Async.cs @@ -48,7 +48,7 @@ private async Task unpack15Async(bool solid, CancellationToken cancellationToken } if (((wrPtr - unpPtr) & PackDef.MAXWINMASK) < 270 && wrPtr != unpPtr) { - oldUnpWriteBuf(); + await oldUnpWriteBufAsync(cancellationToken).ConfigureAwait(false); if (suspended) { return; @@ -105,7 +105,7 @@ private async Task unpack15Async(bool solid, CancellationToken cancellationToken } } } - oldUnpWriteBuf(); + await oldUnpWriteBufAsync(cancellationToken).ConfigureAwait(false); } private async Task unpReadBufAsync(CancellationToken cancellationToken = default) diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack20.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack20.Async.cs index 69bc6de0c..9ca54f312 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack20.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack20.Async.cs @@ -45,7 +45,7 @@ private async Task unpack20Async(bool solid, CancellationToken cancellationToken } if (((wrPtr - unpPtr) & PackDef.MAXWINMASK) < 270 && wrPtr != unpPtr) { - oldUnpWriteBuf(); + await oldUnpWriteBufAsync(cancellationToken).ConfigureAwait(false); if (suspended) { return; @@ -157,8 +157,8 @@ private async Task unpack20Async(bool solid, CancellationToken cancellationToken CopyString20(2, Distance); } } - ReadLastTables(); - oldUnpWriteBuf(); + await ReadLastTablesAsync(cancellationToken).ConfigureAwait(false); + await oldUnpWriteBufAsync(cancellationToken).ConfigureAwait(false); } private async Task ReadTables20Async(CancellationToken cancellationToken = default) @@ -272,4 +272,25 @@ private async Task ReadTables20Async(CancellationToken cancellationToken = } return true; } + + private async Task ReadLastTablesAsync(CancellationToken cancellationToken = default) + { + if (readTop >= inAddr + 5) + { + if (UnpAudioBlock != 0) + { + if (this.decodeNumber(MD[UnpCurChannel]) == 256) + { + await ReadTables20Async(cancellationToken).ConfigureAwait(false); + } + } + else + { + if (this.decodeNumber(LD) == 269) + { + await ReadTables20Async(cancellationToken).ConfigureAwait(false); + } + } + } + } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs index 48df787ec..42f7d4396 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs @@ -70,7 +70,7 @@ public async Task Unpack5Async(bool Solid, CancellationToken cancellationToken = // So we can safefly use these tables below. if ( !await ReadBlockHeaderAsync(cancellationToken).ConfigureAwait(false) - || !ReadTables() + || !await ReadTablesAsync(cancellationToken).ConfigureAwait(false) || !TablesRead5 ) { @@ -101,7 +101,7 @@ public async Task Unpack5Async(bool Solid, CancellationToken cancellationToken = } if ( !await ReadBlockHeaderAsync(cancellationToken).ConfigureAwait(false) - || !ReadTables() + || !await ReadTablesAsync(cancellationToken).ConfigureAwait(false) ) { return; @@ -118,7 +118,7 @@ public async Task Unpack5Async(bool Solid, CancellationToken cancellationToken = && WriteBorder != UnpPtr ) { - UnpWriteBuf(); + await UnpWriteBufAsync(cancellationToken).ConfigureAwait(false); if (WrittenFileSize > DestUnpSize) { return; @@ -197,7 +197,7 @@ public async Task Unpack5Async(bool Solid, CancellationToken cancellationToken = var Filter = new UnpackFilter(); if ( !await ReadFilterAsync(Filter, cancellationToken).ConfigureAwait(false) - || !AddFilter(Filter) + || !await AddFilterAsync(Filter, cancellationToken).ConfigureAwait(false) ) { break; @@ -232,7 +232,7 @@ public async Task Unpack5Async(bool Solid, CancellationToken cancellationToken = continue; } } - UnpWriteBuf(); + await UnpWriteBufAsync(cancellationToken).ConfigureAwait(false); } private async Task ReadBlockHeaderAsync(CancellationToken cancellationToken = default) @@ -318,4 +318,24 @@ private async Task ReadFilterAsync( return true; } + + private async Task AddFilterAsync( + UnpackFilter Filter, + CancellationToken cancellationToken = default + ) + { + if (Filters.Count >= MAX_UNPACK_FILTERS) + { + await UnpWriteBufAsync(cancellationToken).ConfigureAwait(false); + if (Filters.Count >= MAX_UNPACK_FILTERS) + { + InitFilters(); + } + } + + Filter.NextWindow = WrPtr != UnpPtr && ((WrPtr - UnpPtr) & MaxWinMask) <= Filter.BlockStart; + Filter.uBlockStart = (uint)((Filter.BlockStart + UnpPtr) & MaxWinMask); + Filters.Add(Filter); + return true; + } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.cs b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.cs index 7784d98ac..27cf0a670 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.cs @@ -154,17 +154,25 @@ private async Task UnstoreFileAsync(CancellationToken cancellationToken = defaul public long DestSize => DestUnpSize; - public int Char + public int ReadChar() { - get + // TODO: coderb: not sure where the "MAXSIZE-30" comes from, ported from V1 code + if (InAddr > MAX_SIZE - 30) { - // TODO: coderb: not sure where the "MAXSIZE-30" comes from, ported from V1 code - if (InAddr > MAX_SIZE - 30) - { - UnpReadBuf(); - } - return InBuf[InAddr++]; + UnpReadBuf(); + } + return InBuf[InAddr++]; + } + + public async ValueTask ReadCharAsync(CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + // TODO: coderb: not sure where the "MAXSIZE-30" comes from, ported from V1 code + if (InAddr > MAX_SIZE - 30) + { + await UnpReadBufAsync(cancellationToken).ConfigureAwait(false); } + return InBuf[InAddr++]; } public int PpmEscChar diff --git a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack15_async.cs b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack15_async.cs index e615527ed..de8036b28 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack15_async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack15_async.cs @@ -41,7 +41,7 @@ private async Task Unpack15Async(bool Solid, CancellationToken cancellationToken if (((WrPtr - UnpPtr) & MaxWinMask) < 270 && WrPtr != UnpPtr) { - UnpWriteBuf20(); + await UnpWriteBuf20Async(cancellationToken).ConfigureAwait(false); } if (StMode != 0) @@ -95,6 +95,6 @@ private async Task Unpack15Async(bool Solid, CancellationToken cancellationToken } } } - UnpWriteBuf20(); + await UnpWriteBuf20Async(cancellationToken).ConfigureAwait(false); } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack20_async.cs b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack20_async.cs index 0619189a3..89083eb40 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack20_async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV2017/Unpack.unpack20_async.cs @@ -49,7 +49,7 @@ private async Task Unpack20Async(bool Solid, CancellationToken cancellationToken if (((WrPtr - UnpPtr) & MaxWinMask) < 270 && WrPtr != UnpPtr) { - UnpWriteBuf20(); + await UnpWriteBuf20Async(cancellationToken).ConfigureAwait(false); if (Suspended) { return; @@ -165,8 +165,8 @@ private async Task Unpack20Async(bool Solid, CancellationToken cancellationToken continue; } } - ReadLastTables(); - UnpWriteBuf20(); + await ReadLastTables20Async(cancellationToken).ConfigureAwait(false); + await UnpWriteBuf20Async(cancellationToken).ConfigureAwait(false); } private async Task UnpWriteBuf20Async(CancellationToken cancellationToken = default) @@ -316,4 +316,22 @@ private async Task ReadTables20Async(CancellationToken cancellationToken = Array.Copy(Table, 0, this.UnpOldTable20, 0, UnpOldTable20.Length); return true; } + + private async Task ReadLastTables20Async(CancellationToken cancellationToken = default) + { + if (ReadTop >= Inp.InAddr + 5) + { + if (UnpAudioBlock) + { + if (DecodeNumber(Inp, MD[UnpCurChannel]) == 256) + { + await ReadTables20Async(cancellationToken).ConfigureAwait(false); + } + } + else if (DecodeNumber(Inp, BlockTables.LD) == 269) + { + await ReadTables20Async(cancellationToken).ConfigureAwait(false); + } + } + } } diff --git a/src/SharpCompress/IO/AsyncBinaryReader.cs b/src/SharpCompress/IO/AsyncBinaryReader.cs index 9fef056a1..f3e8c91b6 100644 --- a/src/SharpCompress/IO/AsyncBinaryReader.cs +++ b/src/SharpCompress/IO/AsyncBinaryReader.cs @@ -60,15 +60,10 @@ public async ValueTask ReadBytesAsync( int offset, int count, CancellationToken ct = default - ) - { - await _stream.ReadExactAsync(bytes, offset, count, ct).ConfigureAwait(false); - } + ) => await _stream.ReadExactAsync(bytes, offset, count, ct).ConfigureAwait(false); - public async ValueTask SkipAsync(int count, CancellationToken ct = default) - { + public async ValueTask SkipAsync(int count, CancellationToken ct = default) => await _stream.SkipAsync(count, ct).ConfigureAwait(false); - } public void Dispose() { diff --git a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs index 120e2f69e..3c1d04086 100644 --- a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs +++ b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs @@ -17,9 +17,8 @@ public async ValueTask WriteEntryToDirectoryAsync( ExtractionOptions? options = null, CancellationToken cancellationToken = default ) => - await ExtractionMethods - .WriteEntryToDirectoryAsync( - reader.Entry, + await reader + .Entry.WriteEntryToDirectoryAsync( destinationDirectory, options, async (path, ct) => @@ -36,14 +35,13 @@ public async ValueTask WriteEntryToFileAsync( ExtractionOptions? options = null, CancellationToken cancellationToken = default ) => - await ExtractionMethods - .WriteEntryToFileAsync( - reader.Entry, + await reader + .Entry.WriteEntryToFileAsync( destinationFileName, options, async (x, fm, ct) => { - using var fs = File.Open(destinationFileName, fm); + using var fs = File.Open(x, fm); await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false); }, cancellationToken @@ -72,14 +70,13 @@ public async ValueTask WriteEntryToAsync( ExtractionOptions? options = null, CancellationToken cancellationToken = default ) => - await ExtractionMethods - .WriteEntryToFileAsync( - reader.Entry, + await reader + .Entry.WriteEntryToFileAsync( destinationFileName, options, async (x, fm, ct) => { - using var fs = File.Open(destinationFileName, fm); + using var fs = File.Open(x, fm); await reader.WriteEntryToAsync(fs, ct).ConfigureAwait(false); }, cancellationToken diff --git a/src/SharpCompress/Readers/IReaderExtensions.cs b/src/SharpCompress/Readers/IReaderExtensions.cs index 54dfa14fc..74c708a5b 100644 --- a/src/SharpCompress/Readers/IReaderExtensions.cs +++ b/src/SharpCompress/Readers/IReaderExtensions.cs @@ -40,8 +40,7 @@ public void WriteEntryToDirectory( string destinationDirectory, ExtractionOptions? options = null ) => - ExtractionMethods.WriteEntryToDirectory( - reader.Entry, + reader.Entry.WriteEntryToDirectory( destinationDirectory, options, (path) => reader.WriteEntryToFile(path, options) @@ -54,8 +53,7 @@ public void WriteEntryToFile( string destinationFileName, ExtractionOptions? options = null ) => - ExtractionMethods.WriteEntryToFile( - reader.Entry, + reader.Entry.WriteEntryToFile( destinationFileName, options, (x, fm) => diff --git a/src/SharpCompress/SharpCompress.csproj b/src/SharpCompress/SharpCompress.csproj index bcde6224e..991eccb9e 100644 --- a/src/SharpCompress/SharpCompress.csproj +++ b/src/SharpCompress/SharpCompress.csproj @@ -6,7 +6,7 @@ 0.0.0.0 0.0.0.0 Adam Hathcock - net48;netstandard2.0;netstandard2.1;net6.0;net7.0;net8.0;net9.0;net10.0 + net48;netstandard2.0;netstandard2.1;net6.0;net8.0;net10.0 SharpCompress ../../SharpCompress.snk true diff --git a/src/SharpCompress/Utility.cs b/src/SharpCompress/Utility.cs index af8572d76..017bc965e 100644 --- a/src/SharpCompress/Utility.cs +++ b/src/SharpCompress/Utility.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.IO; +using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -13,6 +14,15 @@ namespace SharpCompress; internal static partial class Utility { + /// + /// Gets the appropriate StringComparison for path checks based on the file system. + /// Windows uses case-insensitive file systems, while Unix-like systems use case-sensitive file systems. + /// + internal static StringComparison PathComparison => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + public static bool UseSyncOverAsyncDispose() { var useSyncOverAsync = false; diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 03c03a9a4..fdba2fb1e 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -355,48 +355,6 @@ "contentHash": "Mk1IMb9q5tahC2NltxYXFkLBtuBvfBoCQ3pIxYQWfzbCE9o1OB9SsHe0hnNGo7lWgTA/ePbFAJLWu6nLL9K17A==" } }, - "net7.0": { - "Microsoft.NETFramework.ReferenceAssemblies": { - "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", - "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net461": "1.0.3" - } - }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.102, )", - "resolved": "10.0.102", - "contentHash": "Oxq3RCIJSdtpIU4hLqO7XaDe/Ra3HS9Wi8rJl838SAg6Zu1iQjerA0+xXWBgUFYbgknUGCLOU0T+lzMLkvY9Qg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.102", - "Microsoft.SourceLink.Common": "10.0.102" - } - }, - "Microsoft.VisualStudio.Threading.Analyzers": { - "type": "Direct", - "requested": "[17.14.15, )", - "resolved": "17.14.15", - "contentHash": "mXQPJsbuUD2ydq4/ffd8h8tSOFCXec+2xJOVNCvXjuMOq/+5EKHq3D2m2MC2+nUaXeFMSt66VS/J4HdKBixgcw==" - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.102", - "contentHash": "0i81LYX31U6UiXz4NOLbvc++u+/mVDmOt+PskrM/MygpDxkv9THKQyRUmavBpLK6iBV0abNWnn+CQgSRz//Pwg==" - }, - "Microsoft.NETFramework.ReferenceAssemblies.net461": { - "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "AmOJZwCqnOCNp6PPcf9joyogScWLtwy0M1WkqfEQ0M9nYwyDD7EX9ZjscKS5iYnyvteX7kzSKFCKt9I9dXA6mA==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.102", - "contentHash": "Mk1IMb9q5tahC2NltxYXFkLBtuBvfBoCQ3pIxYQWfzbCE9o1OB9SsHe0hnNGo7lWgTA/ePbFAJLWu6nLL9K17A==" - } - }, "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", @@ -444,48 +402,6 @@ "resolved": "10.0.102", "contentHash": "Mk1IMb9q5tahC2NltxYXFkLBtuBvfBoCQ3pIxYQWfzbCE9o1OB9SsHe0hnNGo7lWgTA/ePbFAJLWu6nLL9K17A==" } - }, - "net9.0": { - "Microsoft.NETFramework.ReferenceAssemblies": { - "type": "Direct", - "requested": "[1.0.3, )", - "resolved": "1.0.3", - "contentHash": "vUc9Npcs14QsyOD01tnv/m8sQUnGTGOw1BCmKcv77LBJY7OxhJ+zJF7UD/sCL3lYNFuqmQEVlkfS4Quif6FyYg==", - "dependencies": { - "Microsoft.NETFramework.ReferenceAssemblies.net461": "1.0.3" - } - }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[10.0.102, )", - "resolved": "10.0.102", - "contentHash": "Oxq3RCIJSdtpIU4hLqO7XaDe/Ra3HS9Wi8rJl838SAg6Zu1iQjerA0+xXWBgUFYbgknUGCLOU0T+lzMLkvY9Qg==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "10.0.102", - "Microsoft.SourceLink.Common": "10.0.102" - } - }, - "Microsoft.VisualStudio.Threading.Analyzers": { - "type": "Direct", - "requested": "[17.14.15, )", - "resolved": "17.14.15", - "contentHash": "mXQPJsbuUD2ydq4/ffd8h8tSOFCXec+2xJOVNCvXjuMOq/+5EKHq3D2m2MC2+nUaXeFMSt66VS/J4HdKBixgcw==" - }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "10.0.102", - "contentHash": "0i81LYX31U6UiXz4NOLbvc++u+/mVDmOt+PskrM/MygpDxkv9THKQyRUmavBpLK6iBV0abNWnn+CQgSRz//Pwg==" - }, - "Microsoft.NETFramework.ReferenceAssemblies.net461": { - "type": "Transitive", - "resolved": "1.0.3", - "contentHash": "AmOJZwCqnOCNp6PPcf9joyogScWLtwy0M1WkqfEQ0M9nYwyDD7EX9ZjscKS5iYnyvteX7kzSKFCKt9I9dXA6mA==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "10.0.102", - "contentHash": "Mk1IMb9q5tahC2NltxYXFkLBtuBvfBoCQ3pIxYQWfzbCE9o1OB9SsHe0hnNGo7lWgTA/ePbFAJLWu6nLL9K17A==" - } } } } \ No newline at end of file diff --git a/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs b/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs index 07a679ef0..cf6416fea 100644 --- a/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs +++ b/tests/SharpCompress.Test/Mocks/AsyncOnlyStream.cs @@ -5,14 +5,9 @@ namespace SharpCompress.Test.Mocks; -public class AsyncOnlyStream : Stream +public class AsyncOnlyStream(Stream stream) : Stream { - private readonly Stream _stream; - - public AsyncOnlyStream(Stream stream) - { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - } + private readonly Stream _stream = stream ?? throw new ArgumentNullException(nameof(stream)); public override bool CanRead => _stream.CanRead; public override bool CanSeek => _stream.CanSeek; diff --git a/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs index eebdd38d6..b3cd79bd1 100644 --- a/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs @@ -13,6 +13,31 @@ namespace SharpCompress.Test.Rar; public class RarArchiveAsyncTests : ArchiveTests { + [Theory] + [InlineData("Rar15.rar")] + [InlineData("Rar2.rar")] + [InlineData("Rar.rar")] + [InlineData("Rar.Audio_program.rar")] + [InlineData("Rar5.rar")] + [InlineData("Rar5.solid.rar")] + public async ValueTask Rar_Archive_Recently_Changed_Unpackers_Async(string filename) + { + var extractedEntries = 0; + await using var archive = await RarArchive.OpenAsyncArchive( + Path.Combine(TEST_ARCHIVES_PATH, filename), + new ReaderOptions { LookForHeader = true } + ); + + await foreach (var entry in archive.EntriesAsync.Where(entry => !entry.IsDirectory)) + { + using var output = new AsyncOnlyStream(new MemoryStream()); + await entry.WriteToAsync(output); + extractedEntries++; + } + + Assert.True(extractedEntries > 0); + } + [Fact] public async ValueTask Rar_EncryptedFileAndHeader_Archive_Async() => await ReadRarPasswordAsync("Rar.encrypted_filesAndHeader.rar", "test"); diff --git a/tests/SharpCompress.Test/Rar/RarArchiveTests.cs b/tests/SharpCompress.Test/Rar/RarArchiveTests.cs index c4ef386cc..65f5a0d26 100644 --- a/tests/SharpCompress.Test/Rar/RarArchiveTests.cs +++ b/tests/SharpCompress.Test/Rar/RarArchiveTests.cs @@ -13,6 +13,32 @@ namespace SharpCompress.Test.Rar; public class RarArchiveTests : ArchiveTests { + [Theory] + [InlineData("Rar15.rar")] + [InlineData("Rar2.rar")] + [InlineData("Rar.rar")] + [InlineData("Rar.Audio_program.rar")] + [InlineData("Rar5.rar")] + [InlineData("Rar5.solid.rar")] + public void Rar_Archive_Recently_Changed_Unpackers_Sync(string filename) + { + var extractedEntries = 0; + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename)); + using var archive = RarArchive.OpenArchive( + stream, + new ReaderOptions { LookForHeader = true } + ); + + foreach (var entry in archive.Entries.Where(entry => !entry.IsDirectory)) + { + using var output = new MemoryStream(); + entry.WriteTo(output); + extractedEntries++; + } + + Assert.True(extractedEntries > 0); + } + [Fact] public void Rar_EncryptedFileAndHeader_Archive() => ReadRarPassword("Rar.encrypted_filesAndHeader.rar", "test"); diff --git a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs index 188784c53..3cec11e2a 100644 --- a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs @@ -14,6 +14,30 @@ namespace SharpCompress.Test.Rar; public class RarReaderAsyncTests : ReaderTests { + [Theory] + [InlineData("Rar15.rar")] + [InlineData("Rar.rar")] + [InlineData("Rar.Audio_program.rar")] + [InlineData("Rar5.rar")] + [InlineData("Rar5.solid.rar")] + public async ValueTask Rar_Reader_Async_Uses_Only_Async_Stream_Operations(string filename) + { + using var stream = File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, filename)); + await using var reader = await ReaderFactory.OpenAsyncReader( + new AsyncOnlyStream(stream), + new ReaderOptions { LookForHeader = true } + ); + + while (await reader.MoveToNextEntryAsync()) + { + if (!reader.Entry.IsDirectory) + { + using var output = new AsyncOnlyStream(new MemoryStream()); + await reader.WriteEntryToAsync(output); + } + } + } + [Fact] public async ValueTask Rar_Multi_Reader_Async() => await DoRar_Multi_Reader_Async([ diff --git a/tests/SharpCompress.Test/Security/ExtractionPathTraversalTests.cs b/tests/SharpCompress.Test/Security/ExtractionPathTraversalTests.cs new file mode 100644 index 000000000..69ebddd8f --- /dev/null +++ b/tests/SharpCompress.Test/Security/ExtractionPathTraversalTests.cs @@ -0,0 +1,238 @@ +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; +using Xunit; +using SysZip = System.IO.Compression.ZipArchive; +using SysZipMode = System.IO.Compression.ZipArchiveMode; + +namespace SharpCompress.Test.Security; + +public class ExtractionPathTraversalTests : TestBase +{ + [Theory] + [InlineData("ReaderAll")] + [InlineData("ReaderEntry")] + [InlineData("Archive")] + [InlineData("ArchiveEntry")] + [InlineData("AsyncReaderAll")] + [InlineData("AsyncReaderEntry")] + [InlineData("AsyncArchive")] + [InlineData("AsyncArchiveEntry")] + public async Task DirectoryTraversalToExistingOutsideDirectory_ShouldThrow(string api) + { + var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract"); + Directory.CreateDirectory(extractDir); + var escapedDirectory = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_existing")); + Directory.CreateDirectory(escapedDirectory); + var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip"); + BuildZip(archivePath, "../../escaped_existing/"); + + var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir); + + var extractionException = Assert.IsType(exception); + Assert.Contains("outside of the destination", extractionException.Message); + } + + [Theory] + [InlineData("ReaderAll")] + [InlineData("ReaderEntry")] + [InlineData("Archive")] + [InlineData("ArchiveEntry")] + [InlineData("AsyncReaderAll")] + [InlineData("AsyncReaderEntry")] + [InlineData("AsyncArchive")] + [InlineData("AsyncArchiveEntry")] + public async Task FileTraversalToSiblingDirectory_ShouldThrow(string api) + { + var extractDir = Path.Combine(SCRATCH_FILES_PATH, "extract"); + Directory.CreateDirectory(extractDir); + var siblingDirectory = Path.Combine(SCRATCH_FILES_PATH, "extract2"); + Directory.CreateDirectory(siblingDirectory); + var archivePath = Path.Combine(SCRATCH2_FILES_PATH, $"{api}.zip"); + BuildZip(archivePath, "../extract2/evil.txt"); + + var exception = await RecordExtractionExceptionAsync(api, archivePath, extractDir); + + var extractionException = Assert.IsType(exception); + Assert.Contains("outside of the destination", extractionException.Message); + Assert.False(File.Exists(Path.Combine(siblingDirectory, "evil.txt"))); + } + + private static void BuildZip(string path, string entryName) + { + using var fs = File.Create(path); + using var zip = new SysZip(fs, SysZipMode.Create); + var entry = zip.CreateEntry(entryName); + + if (entryName.EndsWith('/')) + { + return; + } + + using var writer = new StreamWriter(entry.Open()); + writer.Write("evil"); + } + + private static async Task RecordExtractionExceptionAsync( + string api, + string archivePath, + string extractDir + ) + { + var options = new ExtractionOptions { ExtractFullPath = true, Overwrite = true }; + + return api switch + { + "ReaderAll" => RecordException(() => + ExtractWithReaderAll(archivePath, extractDir, options) + ), + "ReaderEntry" => RecordException(() => + ExtractWithReaderEntry(archivePath, extractDir, options) + ), + "Archive" => RecordException(() => + ExtractWithArchive(archivePath, extractDir, options) + ), + "ArchiveEntry" => RecordException(() => + ExtractWithArchiveEntry(archivePath, extractDir, options) + ), + "AsyncReaderAll" => await RecordExceptionAsync(() => + ExtractWithAsyncReaderAllAsync(archivePath, extractDir, options) + ), + "AsyncReaderEntry" => await RecordExceptionAsync(() => + ExtractWithAsyncReaderEntryAsync(archivePath, extractDir, options) + ), + "AsyncArchive" => await RecordExceptionAsync(() => + ExtractWithAsyncArchiveAsync(archivePath, extractDir, options) + ), + "AsyncArchiveEntry" => await RecordExceptionAsync(() => + ExtractWithAsyncArchiveEntryAsync(archivePath, extractDir, options) + ), + _ => throw new ArgumentOutOfRangeException(nameof(api), api, null), + }; + } + + private static Exception? RecordException(Action action) + { + try + { + action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static async Task RecordExceptionAsync(Func action) + { + try + { + await action(); + return null; + } + catch (Exception exception) + { + return exception; + } + } + + private static void ExtractWithReaderAll( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var stream = File.OpenRead(archivePath); + using var reader = ReaderFactory.OpenReader(stream); + reader.WriteAllToDirectory(extractDir, options); + } + + private static void ExtractWithReaderEntry( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var stream = File.OpenRead(archivePath); + using var reader = ReaderFactory.OpenReader(stream); + Assert.True(reader.MoveToNextEntry()); + reader.WriteEntryToDirectory(extractDir, options); + } + + private static void ExtractWithArchive( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var archive = ArchiveFactory.OpenArchive(archivePath); + archive.WriteToDirectory(extractDir, options); + } + + private static void ExtractWithArchiveEntry( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var archive = ArchiveFactory.OpenArchive(archivePath); + archive.Entries.Single().WriteToDirectory(extractDir, options); + } + + private static async Task ExtractWithAsyncReaderAllAsync( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var stream = File.OpenRead(archivePath); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + await reader.WriteAllToDirectoryAsync(extractDir, options); + } + + private static async Task ExtractWithAsyncReaderEntryAsync( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + using var stream = File.OpenRead(archivePath); + await using var reader = await ReaderFactory.OpenAsyncReader(stream); + Assert.True(await reader.MoveToNextEntryAsync()); + await reader.WriteEntryToDirectoryAsync(extractDir, options); + } + + private static async Task ExtractWithAsyncArchiveAsync( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath); + await archive.WriteToDirectoryAsync(extractDir, options); + } + + private static async Task ExtractWithAsyncArchiveEntryAsync( + string archivePath, + string extractDir, + ExtractionOptions options + ) + { + await using var archive = await ArchiveFactory.OpenAsyncArchive(archivePath); + + await foreach (var entry in archive.EntriesAsync) + { + await entry.WriteToDirectoryAsync(extractDir, options); + return; + } + + throw new InvalidOperationException("Archive did not contain an entry."); + } +} +#endif diff --git a/tests/SharpCompress.Test/Security/ZipSlip.cs b/tests/SharpCompress.Test/Security/ZipSlip.cs new file mode 100644 index 000000000..208c31920 --- /dev/null +++ b/tests/SharpCompress.Test/Security/ZipSlip.cs @@ -0,0 +1,134 @@ +#if NET8_0_OR_GREATER +using System; +using System.IO; +using System.Threading.Tasks; +using AwesomeAssertions; +using SharpCompress.Archives; +using SharpCompress.Common; +using Xunit; +using SysZip = System.IO.Compression.ZipArchive; +using SysZipMode = System.IO.Compression.ZipArchiveMode; + +namespace SharpCompress.Test.Security; + +public class ZipSlip : TestBase +{ + [Fact] + public void RunSync() + { + Console.WriteLine("--- Sync: archive.WriteToDirectory() ---"); + var (extractDir, parentDir) = SetupDirs("sync"); + Directory.CreateDirectory(extractDir); + var archivePath = Path.Combine(parentDir, "malicious.zip"); + + BuildMaliciousZip(archivePath); + + using (var archive = ArchiveFactory.OpenArchive(archivePath)) + { + var ex = Assert.Throws(() => + archive.WriteToDirectory( + extractDir, + new ExtractionOptions { ExtractFullPath = true } + ) + ); + ex.Message.Should() + .Contain( + "Entry is trying to create a directory outside of the destination directory" + ); + } + + CheckResults(archivePath, parentDir, extractDir); + } + + [Fact] + public async Task RunAsync() + { + Console.WriteLine("--- Async: archive.WriteToDirectoryAsync() ---"); + var (extractDir, parentDir) = SetupDirs("async"); + Directory.CreateDirectory(extractDir); + var archivePath = Path.Combine(parentDir, "malicious.zip"); + + BuildMaliciousZip(archivePath); + + var archive = await ArchiveFactory.OpenAsyncArchive(archivePath); + await using (archive) + { + var ex = await Assert.ThrowsAsync(async () => + await archive.WriteToDirectoryAsync( + extractDir, + new ExtractionOptions { ExtractFullPath = true } + ) + ); + ex.Message.Should() + .Contain( + "Entry is trying to create a directory outside of the destination directory" + ); + } + + CheckResults(archivePath, parentDir, extractDir); + } + + // Craft a ZIP with malicious directory entries using System.IO.Compression + // so we bypass any SharpCompress write-side normalisation. + static void BuildMaliciousZip(string path) + { + using var fs = File.Create(path); + using var zip = new SysZip(fs, SysZipMode.Create); + + // 1. Relative traversal: two levels up, then "escaped_relative/" + zip.CreateEntry("../../escaped_relative/"); + + // 2. Absolute Unix path (Path.Combine discards the base when second arg is rooted) + zip.CreateEntry("/tmp/escaped_absolute/"); + + // 3. A legitimate entry for contrast + zip.CreateEntry("safe_subdir/"); + } + + private (string extractDir, string parentDir) SetupDirs(string label) + { + var parentDir = Path.Combine( + SCRATCH_FILES_PATH, + $"sc_poc_{label}_{Path.GetRandomFileName()}" + ); + Directory.CreateDirectory(parentDir); + var extractDir = Path.Combine(parentDir, "extract_target"); + + Console.WriteLine($" Parent : {parentDir}"); + Console.WriteLine($" Target : {extractDir}"); + return (extractDir, parentDir); + } + + static void CheckResults(string archivePath, string parentDir, string extractDir) + { + Console.WriteLine(" Directories created after extraction:"); + foreach (var d in Directory.GetDirectories(parentDir, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(parentDir, d); + var escaped = !d.StartsWith(extractDir, StringComparison.Ordinal); + Console.WriteLine($" {(escaped ? "[ESCAPED]" : "[ ok ]")} {relative}"); + } + + // Relative traversal "../../escaped_relative/" escapes two levels above extractDir + // (which is parentDir/extract_target), landing in Path.GetTempPath() + var relTarget = Path.GetFullPath(Path.Combine(extractDir, "../../escaped_relative")); + if (Directory.Exists(relTarget)) + { + Console.WriteLine($" [ESCAPED] relative traversal created: {relTarget}"); + Directory.Delete(relTarget); + } + File.Delete(archivePath); + if (Directory.Exists(extractDir)) + { + Directory.Delete(extractDir); + } + + var absTarget = "/tmp/escaped_absolute"; + if (Directory.Exists(absTarget)) + { + Console.WriteLine($" [ESCAPED] absolute path created: {absTarget}"); + Directory.Delete(absTarget); + } + } +} +#endif diff --git a/tests/SharpCompress.Test/packages.lock.json b/tests/SharpCompress.Test/packages.lock.json index 9a2199223..ca6ffc35f 100644 --- a/tests/SharpCompress.Test/packages.lock.json +++ b/tests/SharpCompress.Test/packages.lock.json @@ -309,6 +309,30 @@ } } }, + ".NETFramework,Version=v4.8/win-x86": { + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==", + "dependencies": { + "System.Security.AccessControl": "5.0.0", + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.AccessControl": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dagJ1mHZO3Ani8GH0PHpPEe/oYO+rVdbQjvjJkBRNQkX4t0r1iaeGn8+/ybkSLEan3/slM0t59SVdHzuHf2jmw==", + "dependencies": { + "System.Security.Principal.Windows": "5.0.0" + } + }, + "System.Security.Principal.Windows": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "t0MGLukB5WAVU9bO3MGzvlGnyJPgUlcwerXn1kzBRjwLKixT96XV0Uza41W49gVd8zEMFu9vQEFlv0IOrytICA==" + } + }, "net10.0": { "AwesomeAssertions": { "type": "Direct", @@ -521,6 +545,13 @@ "resolved": "8.0.0", "contentHash": "3WA9q9yVqJp222P3x1wYIGDAkpjAku0TMUaaQV22g6L67AI0LdOIrVS7Ht2vJfLHGSPVuqN94vIr15qn+HEkHw==" } + }, + "net10.0/win-x86": { + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + } } } } \ No newline at end of file