From 2d4d9c285a132eae76ec2c8d07a91a031039b013 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:45:49 +0000 Subject: [PATCH 01/19] Fix sync methods called in async RAR unpacker paths (Unpack29Async, Unpack5Async) Agent-Logs-Url: https://github.com/adamhathcock/sharpcompress/sessions/7f2fcb59-4a41-4b27-ab32-17afec510b5e Co-authored-by: adamhathcock <527620+adamhathcock@users.noreply.github.com> --- .../Compressors/Rar/UnpackV1/Unpack.Async.cs | 201 +++++++++++++++++- .../Rar/UnpackV1/Unpack50.Async.cs | 4 +- src/SharpCompress/packages.lock.json | 12 +- 3 files changed, 205 insertions(+), 12 deletions(-) diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs index 37655cf14..0581ba26f 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; } @@ -161,7 +165,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken var NextCh = ppm.DecodeChar(); if (NextCh == 0) { - if (!ReadTables()) + if (!await ReadTablesAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -294,7 +298,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (Number == 256) { - if (!ReadEndOfBlock()) + if (!await ReadEndOfBlockAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -302,7 +306,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (Number == 257) { - if (!ReadVMCode()) + if (!await ReadVMCodeAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -600,4 +604,193 @@ await writeStream writtenFileSize += size; destUnpSize -= size; } + + private async Task ReadTablesAsync(CancellationToken cancellationToken = default) + { + var bitLength = new byte[PackDef.BC]; + var table = new byte[PackDef.HUFF_TABLE_SIZE]; + + 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 ppm.DecodeInit(this, PpmEscChar); + } + 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[i] = 15; + } + else + { + zeroCount += 2; + while (zeroCount-- > 0 && i < bitLength.Length) + { + bitLength[i++] = 0; + } + i--; + } + } + else + { + bitLength[i] = (byte)length; + } + } + + UnpackUtility.makeDecodeTables(bitLength, 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[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[i] = table[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[i++] = 0; + } + } + } + tablesRead = true; + if (inAddr > readTop) + { + return false; + } + UnpackUtility.makeDecodeTables(table, 0, LD, PackDef.NC); + UnpackUtility.makeDecodeTables(table, PackDef.NC, DD, PackDef.DC); + UnpackUtility.makeDecodeTables(table, PackDef.NC + PackDef.DC, LDD, PackDef.LDC); + UnpackUtility.makeDecodeTables( + table, + PackDef.NC + PackDef.DC + PackDef.LDC, + RD, + PackDef.RC + ); + + new Span(table).CopyTo(unpOldTable); + return true; + } + + 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); + } } diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs index 48df787ec..432922c46 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; diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 7d5f04481..5059aafeb 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -268,9 +268,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.5, )", - "resolved": "10.0.5", - "contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA==" + "requested": "[10.0.0, )", + "resolved": "10.0.0", + "contentHash": "kICGrGYEzCNI3wPzfEXcwNHgTvlvVn9yJDhSdRK+oZQy4jvYH529u7O0xf5ocQKzOMjfS07+3z9PKRIjrFMJDA==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -442,9 +442,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.25, )", - "resolved": "8.0.25", - "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" + "requested": "[8.0.22, )", + "resolved": "8.0.22", + "contentHash": "MhcMithKEiyyNkD2ZfbDZPmcOdi0GheGfg8saEIIEfD/fol3iHmcV8TsZkD4ZYz5gdUuoX4YtlVySUU7Sxl9SQ==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", From 896dfd6537a9d2e1115e3ed018671dc53cf1da24 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 08:32:44 +0100 Subject: [PATCH 02/19] some AI changes --- .../Compressors/PPMd/H/ModelPPM.cs | 59 ++++++++++++++ .../Compressors/PPMd/H/RangeCoder.cs | 22 +++++- .../Compressors/Rar/UnpackV1/Unpack.Async.cs | 73 ++++++++++++++++-- .../Rar/UnpackV1/Unpack15.Async.cs | 4 +- .../Rar/UnpackV1/Unpack20.Async.cs | 27 ++++++- .../Rar/UnpackV1/Unpack50.Async.cs | 26 ++++++- .../Rar/RarReaderAsyncTests.cs | 76 +++++++++++++++++++ 7 files changed, 269 insertions(+), 18 deletions(-) diff --git a/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs b/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs index 793dccaed..1718cbb42 100644 --- a/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs +++ b/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs @@ -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..59bdd9262 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(); @@ -131,7 +149,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/UnpackV1/Unpack.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs index 0581ba26f..2bea6ce82 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs @@ -141,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; @@ -154,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; @@ -162,7 +162,7 @@ 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 (!await ReadTablesAsync(cancellationToken).ConfigureAwait(false)) @@ -177,7 +177,7 @@ private async Task Unpack29Async(bool solid, CancellationToken cancellationToken } if (NextCh == 3) { - if (!ReadVMCodePPM()) + if (!await ReadVMCodePPMAsync(cancellationToken).ConfigureAwait(false)) { break; } @@ -190,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; @@ -216,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; @@ -354,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) @@ -622,7 +624,8 @@ private async Task ReadTablesAsync(CancellationToken cancellationToken = d if ((bitField & 0x8000) != 0) { unpBlockType = BlockTypes.BLOCK_PPM; - return ppm.DecodeInit(this, PpmEscChar); + return await ppm.DecodeInitAsync(this, PpmEscChar, cancellationToken) + .ConfigureAwait(false); } unpBlockType = BlockTypes.BLOCK_LZ; @@ -793,4 +796,58 @@ private async Task ReadVMCodeAsync(CancellationToken cancellationToken = d } 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/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 432922c46..42f7d4396 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack50.Async.cs @@ -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/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs index d6d82e261..b922ee7e5 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 SyncWriteNotSupportedStream(new MemoryStream()); + await reader.WriteEntryToAsync(output); + } + } + } + [Fact] public async ValueTask Rar_Multi_Reader_Async() => await DoRar_Multi_Reader_Async([ @@ -371,4 +395,56 @@ private async ValueTask ReadAsync( } VerifyFiles(); } + + private sealed class SyncWriteNotSupportedStream(Stream stream) : Stream + { + public override bool CanRead => stream.CanRead; + + public override bool CanSeek => stream.CanSeek; + + public override bool CanWrite => stream.CanWrite; + + public override long Length => stream.Length; + + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); + + public override void SetLength(long value) => stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException("Synchronous Write is not supported"); + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + System.Threading.CancellationToken cancellationToken + ) => stream.WriteAsync(buffer, offset, count, cancellationToken); + +#if NET8_0_OR_GREATER + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + System.Threading.CancellationToken cancellationToken = default + ) => stream.WriteAsync(buffer, cancellationToken); +#endif + + protected override void Dispose(bool disposing) + { + if (disposing) + { + stream.Dispose(); + } + base.Dispose(disposing); + } + } } From 18fade571e60587a07564fe4ca048a86d64979ef Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 08:39:54 +0100 Subject: [PATCH 03/19] made Char be a method to match async method --- .../Compressors/PPMd/H/ModelPPM.cs | 6 ++--- .../Compressors/PPMd/H/RangeCoder.cs | 23 ++++++++---------- .../Compressors/Rar/IRarUnpack.cs | 3 ++- .../Compressors/Rar/UnpackV1/Unpack.cs | 11 ++++----- .../Compressors/Rar/UnpackV2017/Unpack.cs | 24 ++++++++++++------- 5 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs b/src/SharpCompress/Compressors/PPMd/H/ModelPPM.cs index 1718cbb42..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); diff --git a/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs b/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs index 59bdd9262..7cda0fecc 100644 --- a/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs +++ b/src/SharpCompress/Compressors/PPMd/H/RangeCoder.cs @@ -62,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; } } @@ -91,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; } @@ -139,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; } 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.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/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 From fc096e1996af99f5733a91be189c061d5391b6ba Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 08:48:30 +0100 Subject: [PATCH 04/19] new tests --- .../Mocks/SyncWriteNotSupportedStream.cs | 58 +++++++++++++++++++ .../Rar/RarArchiveAsyncTests.cs | 25 ++++++++ .../SharpCompress.Test/Rar/RarArchiveTests.cs | 26 +++++++++ .../Rar/RarReaderAsyncTests.cs | 52 ----------------- 4 files changed, 109 insertions(+), 52 deletions(-) create mode 100644 tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs diff --git a/tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs b/tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs new file mode 100644 index 000000000..6ee984807 --- /dev/null +++ b/tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace SharpCompress.Test.Mocks; + +public sealed class SyncWriteNotSupportedStream(Stream stream) : Stream +{ + public override bool CanRead => stream.CanRead; + + public override bool CanSeek => stream.CanSeek; + + public override bool CanWrite => stream.CanWrite; + + public override long Length => stream.Length; + + public override long Position + { + get => stream.Position; + set => stream.Position = value; + } + + public override void Flush() => stream.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + stream.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); + + public override void SetLength(long value) => stream.SetLength(value); + + public override void Write(byte[] buffer, int offset, int count) => + throw new NotSupportedException("Synchronous Write is not supported"); + + public override Task WriteAsync( + byte[] buffer, + int offset, + int count, + CancellationToken cancellationToken + ) => stream.WriteAsync(buffer, offset, count, cancellationToken); + +#if NET8_0_OR_GREATER + public override ValueTask WriteAsync( + ReadOnlyMemory buffer, + CancellationToken cancellationToken = default + ) => stream.WriteAsync(buffer, cancellationToken); +#endif + + protected override void Dispose(bool disposing) + { + if (disposing) + { + stream.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs index bba174d6f..09c274c5f 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 SyncWriteNotSupportedStream(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 e4fef73fe..130c20b62 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 b922ee7e5..1ed4b8f50 100644 --- a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs @@ -395,56 +395,4 @@ private async ValueTask ReadAsync( } VerifyFiles(); } - - private sealed class SyncWriteNotSupportedStream(Stream stream) : Stream - { - public override bool CanRead => stream.CanRead; - - public override bool CanSeek => stream.CanSeek; - - public override bool CanWrite => stream.CanWrite; - - public override long Length => stream.Length; - - public override long Position - { - get => stream.Position; - set => stream.Position = value; - } - - public override void Flush() => stream.Flush(); - - public override int Read(byte[] buffer, int offset, int count) => - stream.Read(buffer, offset, count); - - public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); - - public override void SetLength(long value) => stream.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) => - throw new NotSupportedException("Synchronous Write is not supported"); - - public override Task WriteAsync( - byte[] buffer, - int offset, - int count, - System.Threading.CancellationToken cancellationToken - ) => stream.WriteAsync(buffer, offset, count, cancellationToken); - -#if NET8_0_OR_GREATER - public override ValueTask WriteAsync( - ReadOnlyMemory buffer, - System.Threading.CancellationToken cancellationToken = default - ) => stream.WriteAsync(buffer, cancellationToken); -#endif - - protected override void Dispose(bool disposing) - { - if (disposing) - { - stream.Dispose(); - } - base.Dispose(disposing); - } - } } From 0352740ea10fc8f97478decb9173d3868f7750d6 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 09:14:31 +0100 Subject: [PATCH 05/19] more async tests --- .../Rar/UnpackV2017/Unpack.unpack15_async.cs | 4 +- .../Rar/UnpackV2017/Unpack.unpack20_async.cs | 24 +++++++- .../Mocks/AsyncOnlyStream.cs | 9 +-- .../Mocks/SyncWriteNotSupportedStream.cs | 58 ------------------- .../Rar/RarArchiveAsyncTests.cs | 2 +- .../Rar/RarReaderAsyncTests.cs | 2 +- 6 files changed, 27 insertions(+), 72 deletions(-) delete mode 100644 tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs 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/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/Mocks/SyncWriteNotSupportedStream.cs b/tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs deleted file mode 100644 index 6ee984807..000000000 --- a/tests/SharpCompress.Test/Mocks/SyncWriteNotSupportedStream.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace SharpCompress.Test.Mocks; - -public sealed class SyncWriteNotSupportedStream(Stream stream) : Stream -{ - public override bool CanRead => stream.CanRead; - - public override bool CanSeek => stream.CanSeek; - - public override bool CanWrite => stream.CanWrite; - - public override long Length => stream.Length; - - public override long Position - { - get => stream.Position; - set => stream.Position = value; - } - - public override void Flush() => stream.Flush(); - - public override int Read(byte[] buffer, int offset, int count) => - stream.Read(buffer, offset, count); - - public override long Seek(long offset, SeekOrigin origin) => stream.Seek(offset, origin); - - public override void SetLength(long value) => stream.SetLength(value); - - public override void Write(byte[] buffer, int offset, int count) => - throw new NotSupportedException("Synchronous Write is not supported"); - - public override Task WriteAsync( - byte[] buffer, - int offset, - int count, - CancellationToken cancellationToken - ) => stream.WriteAsync(buffer, offset, count, cancellationToken); - -#if NET8_0_OR_GREATER - public override ValueTask WriteAsync( - ReadOnlyMemory buffer, - CancellationToken cancellationToken = default - ) => stream.WriteAsync(buffer, cancellationToken); -#endif - - protected override void Dispose(bool disposing) - { - if (disposing) - { - stream.Dispose(); - } - base.Dispose(disposing); - } -} diff --git a/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs index 09c274c5f..3293fe752 100644 --- a/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarArchiveAsyncTests.cs @@ -30,7 +30,7 @@ public async ValueTask Rar_Archive_Recently_Changed_Unpackers_Async(string filen await foreach (var entry in archive.EntriesAsync.Where(entry => !entry.IsDirectory)) { - using var output = new SyncWriteNotSupportedStream(new MemoryStream()); + using var output = new AsyncOnlyStream(new MemoryStream()); await entry.WriteToAsync(output); extractedEntries++; } diff --git a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs index 1ed4b8f50..11c40f777 100644 --- a/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Rar/RarReaderAsyncTests.cs @@ -32,7 +32,7 @@ public async ValueTask Rar_Reader_Async_Uses_Only_Async_Stream_Operations(string { if (!reader.Entry.IsDirectory) { - using var output = new SyncWriteNotSupportedStream(new MemoryStream()); + using var output = new AsyncOnlyStream(new MemoryStream()); await reader.WriteEntryToAsync(output); } } From 2c0a15e0f0750d1e43cb95c25682181293af035a Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 09:39:23 +0100 Subject: [PATCH 06/19] make async and sync the same --- .../Common/Rar/AsyncMarkingBinaryReader.cs | 16 +++++++++++++++- src/SharpCompress/IO/AsyncBinaryReader.cs | 9 ++------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs b/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs index c0b25ebb5..5135e62c4 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/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() { From 9156a75c56ce056cb5bc72d87453726dfab8d8ef Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 10:26:18 +0100 Subject: [PATCH 07/19] pool arrays --- .../Compressors/Rar/UnpackV1/Unpack.Async.cs | 215 ++++++++++-------- 1 file changed, 117 insertions(+), 98 deletions(-) diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs index 2bea6ce82..c5096f47c 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs @@ -609,137 +609,156 @@ await writeStream private async Task ReadTablesAsync(CancellationToken cancellationToken = default) { - var bitLength = new byte[PackDef.BC]; - var table = new byte[PackDef.HUFF_TABLE_SIZE]; + 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); - if (inAddr > readTop - 25) + try { - if (!await unpReadBufAsync(cancellationToken).ConfigureAwait(false)) + if (inAddr > readTop - 25) { - return false; + 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; + 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); + } - if ((bitField & 0x4000) == 0) - { - new Span(unpOldTable).Clear(); - } - AddBits(2); + unpBlockType = BlockTypes.BLOCK_LZ; - for (var i = 0; i < PackDef.BC; i++) - { - var length = (Utility.URShift(GetBits(), 12)) & 0xFF; - AddBits(4); - if (length == 15) + prevLowDist = 0; + lowDistRepCount = 0; + + if ((bitField & 0x4000) == 0) { - var zeroCount = (Utility.URShift(GetBits(), 12)) & 0xFF; + new Span(unpOldTable).Clear(); + } + + AddBits(2); + + for (var i = 0; i < PackDef.BC; i++) + { + var length = (Utility.URShift(GetBits(), 12)) & 0xFF; AddBits(4); - if (zeroCount == 0) + if (length == 15) { - bitLength[i] = 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 { - zeroCount += 2; - while (zeroCount-- > 0 && i < bitLength.Length) - { - bitLength[i++] = 0; - } - i--; + bitLength.Span[i] = (byte)length; } } - else - { - bitLength[i] = (byte)length; - } - } - UnpackUtility.makeDecodeTables(bitLength, 0, BD, PackDef.BC); + UnpackUtility.makeDecodeTables(bitLength.Span, 0, BD, PackDef.BC); - var TableSize = PackDef.HUFF_TABLE_SIZE; + 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[i] = (byte)((Number + unpOldTable[i]) & 0xf); - i++; - } - else if (Number < 18) + for (var i = 0; i < TableSize;) { - int N; - if (Number == 16) + if (inAddr > readTop - 5) { - N = (Utility.URShift(GetBits(), 13)) + 3; - AddBits(3); - } - else - { - N = (Utility.URShift(GetBits(), 9)) + 11; - AddBits(7); + if (!await unpReadBufAsync(cancellationToken).ConfigureAwait(false)) + { + return false; + } } - while (N-- > 0 && i < TableSize) + + var Number = this.decodeNumber(BD); + if (Number < 16) { - table[i] = table[i - 1]; + table.Span[i] = (byte)((Number + unpOldTable[i]) & 0xf); i++; } - } - else - { - int N; - if (Number == 18) + else if (Number < 18) { - N = (Utility.URShift(GetBits(), 13)) + 3; - AddBits(3); + 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 { - N = (Utility.URShift(GetBits(), 9)) + 11; - AddBits(7); - } - while (N-- > 0 && i < TableSize) - { - table[i++] = 0; + 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; } - tablesRead = true; - if (inAddr > readTop) + finally { - return false; + ArrayPool.Shared.Return(bitLengthArray); + ArrayPool.Shared.Return(tableArray); } - UnpackUtility.makeDecodeTables(table, 0, LD, PackDef.NC); - UnpackUtility.makeDecodeTables(table, PackDef.NC, DD, PackDef.DC); - UnpackUtility.makeDecodeTables(table, PackDef.NC + PackDef.DC, LDD, PackDef.LDC); - UnpackUtility.makeDecodeTables( - table, - PackDef.NC + PackDef.DC + PackDef.LDC, - RD, - PackDef.RC - ); - - new Span(table).CopyTo(unpOldTable); - return true; } private async Task ReadEndOfBlockAsync(CancellationToken cancellationToken = default) From 4f41b6f7937f7b12676f455a6335e449113b8e58 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 10:28:50 +0100 Subject: [PATCH 08/19] review fix --- src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs b/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs index 5135e62c4..57aa20a55 100644 --- a/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs +++ b/src/SharpCompress/Common/Rar/AsyncMarkingBinaryReader.cs @@ -54,7 +54,7 @@ public virtual async ValueTask ReadBytesAsync( throw new InvalidFormatException( string.Format( Constants.DefaultCultureInfo, - "Could not read the requested amount of bytes. End of stream reached. Requested: {0}", + "Could not read the requested amount of bytes. End of stream reached. Requested: {0}", count ), ex From c629dc5903645fe719157155f9c9a29d08e18a85 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 28 Apr 2026 11:03:08 +0100 Subject: [PATCH 09/19] format Unpack --- src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs index c5096f47c..de4d63e9e 100644 --- a/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs +++ b/src/SharpCompress/Compressors/Rar/UnpackV1/Unpack.Async.cs @@ -630,7 +630,7 @@ private async Task ReadTablesAsync(CancellationToken cancellationToken = d { unpBlockType = BlockTypes.BLOCK_PPM; return await ppm.DecodeInitAsync(this, PpmEscChar, cancellationToken) - .ConfigureAwait(false); + .ConfigureAwait(false); } unpBlockType = BlockTypes.BLOCK_LZ; @@ -678,7 +678,7 @@ private async Task ReadTablesAsync(CancellationToken cancellationToken = d var TableSize = PackDef.HUFF_TABLE_SIZE; - for (var i = 0; i < TableSize;) + for (var i = 0; i < TableSize; ) { if (inAddr > readTop - 5) { From 2021a06626d0555a4d69471386e763ca5f5d5dfb Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 16:52:16 +0100 Subject: [PATCH 10/19] add zipslip tests and fixes --- .../Archives/IArchiveExtensions.cs | 52 ++++++-- .../Archives/IAsyncArchiveExtensions.cs | 51 ++++++-- tests/SharpCompress.Test/Security/ZipSlip.cs | 118 ++++++++++++++++++ 3 files changed, 202 insertions(+), 19 deletions(-) create mode 100644 tests/SharpCompress.Test/Security/ZipSlip.cs diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index 80857a25c..9e80c47f2 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using SharpCompress.Common; using SharpCompress.Readers; @@ -8,6 +9,15 @@ namespace SharpCompress.Archives; public static class IArchiveExtensions { + + /// + /// 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; extension(IArchive archive) { /// @@ -39,6 +49,25 @@ private void WriteToDirectoryInternal( IProgress? progress ) { + 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 totalBytes = archive.TotalUncompressedSize; var bytesRead = 0L; var seenDirectories = new HashSet(); @@ -47,16 +76,21 @@ private void WriteToDirectoryInternal( { if (entry.IsDirectory) { - var dirPath = Path.Combine( - destinationDirectory, - entry.Key.NotNull("Entry Key is null") - ); - if ( - Path.GetDirectoryName(dirPath + "/") is { } parentDirectory - && seenDirectories.Add(dirPath) - ) + + 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) && seenDirectories.Add(destdir)) { - Directory.CreateDirectory(parentDirectory); + if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + { + throw new ExtractionException( + "Entry is trying to create a directory outside of the destination directory." + ); + } + + Directory.CreateDirectory(destdir); } continue; } diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index 9ba599776..5d9ef2615 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using SharpCompress.Common; @@ -10,6 +11,14 @@ namespace SharpCompress.Archives; public static class IAsyncArchiveExtensions { + /// + /// 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; extension(IAsyncArchive archive) { /// @@ -58,7 +67,25 @@ private async ValueTask WriteToDirectoryAsyncInternal( IProgress? progress, CancellationToken cancellationToken ) - { + { 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 totalBytes = await archive.TotalUncompressedSizeAsync().ConfigureAwait(false); var bytesRead = 0L; var seenDirectories = new HashSet(); @@ -69,16 +96,20 @@ 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) - ) + 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) && seenDirectories.Add(destdir)) { - Directory.CreateDirectory(parentDirectory); + if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + { + throw new ExtractionException( + "Entry is trying to create a directory outside of the destination directory." + ); + } + + Directory.CreateDirectory(destdir); } continue; } diff --git a/tests/SharpCompress.Test/Security/ZipSlip.cs b/tests/SharpCompress.Test/Security/ZipSlip.cs new file mode 100644 index 000000000..0b4bcd3d8 --- /dev/null +++ b/tests/SharpCompress.Test/Security/ZipSlip.cs @@ -0,0 +1,118 @@ +#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(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(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/"); +} + +static (string extractDir, string parentDir) SetupDirs(string label) +{ + var parentDir = Path.Combine(TEST_ARCHIVES_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 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); + } + + var absTarget = "/tmp/escaped_absolute"; + if (Directory.Exists(absTarget)) + { + Console.WriteLine($" [ESCAPED] absolute path created: {absTarget}"); + Directory.Delete(absTarget); + } +} +} +#endif From 3b29de3954a7e1e21e48bb2380da9bf0bab70535 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 16:57:15 +0100 Subject: [PATCH 11/19] test clean up and formatting --- .../Archives/IArchiveExtensions.cs | 10 +- .../Archives/IAsyncArchiveExtensions.cs | 11 +- tests/SharpCompress.Test/Security/ZipSlip.cs | 174 +++++++++--------- 3 files changed, 104 insertions(+), 91 deletions(-) diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index 9e80c47f2..a1ac2226c 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -9,7 +9,6 @@ namespace SharpCompress.Archives; public static class IArchiveExtensions { - /// /// 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. @@ -76,12 +75,13 @@ private void WriteToDirectoryInternal( { if (entry.IsDirectory) { - var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); + .NotNull("Directory is null"); + var destdir = Path.GetFullPath( + Path.Combine(fullDestinationDirectoryPath, folder) + ); - if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) + if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) { if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) { diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index 5d9ef2615..51e8f3f26 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -67,7 +67,8 @@ private async ValueTask WriteToDirectoryAsyncInternal( IProgress? progress, CancellationToken cancellationToken ) - { var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); + { + var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); options ??= new ExtractionOptions(); //check for trailing slash. @@ -97,10 +98,12 @@ CancellationToken cancellationToken if (entry.IsDirectory) { var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); + .NotNull("Directory is null"); + var destdir = Path.GetFullPath( + Path.Combine(fullDestinationDirectoryPath, folder) + ); - if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) + if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) { if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) { diff --git a/tests/SharpCompress.Test/Security/ZipSlip.cs b/tests/SharpCompress.Test/Security/ZipSlip.cs index 0b4bcd3d8..251b48d43 100644 --- a/tests/SharpCompress.Test/Security/ZipSlip.cs +++ b/tests/SharpCompress.Test/Security/ZipSlip.cs @@ -11,108 +11,118 @@ namespace SharpCompress.Test.Security; -public class ZipSlip: TestBase +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"); + 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); + BuildMaliciousZip(archivePath); - using var archive = ArchiveFactory.OpenArchive(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"); + 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(parentDir, extractDir); -} + 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) + public async Task RunAsync() { - - 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"); + 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); } - CheckResults(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/"); + // 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); - // 3. A legitimate entry for contrast - zip.CreateEntry("safe_subdir/"); -} + // 1. Relative traversal: two levels up, then "escaped_relative/" + zip.CreateEntry("../../escaped_relative/"); -static (string extractDir, string parentDir) SetupDirs(string label) -{ - var parentDir = Path.Combine(TEST_ARCHIVES_PATH, $"sc_poc_{label}_{Path.GetRandomFileName()}"); - Directory.CreateDirectory(parentDir); - var extractDir = Path.Combine(parentDir, "extract_target"); + // 2. Absolute Unix path (Path.Combine discards the base when second arg is rooted) + zip.CreateEntry("/tmp/escaped_absolute/"); - Console.WriteLine($" Parent : {parentDir}"); - Console.WriteLine($" Target : {extractDir}"); - return (extractDir, parentDir); -} - -static void CheckResults(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}"); + // 3. A legitimate entry for contrast + zip.CreateEntry("safe_subdir/"); } - // 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)) + static (string extractDir, string parentDir) SetupDirs(string label) { - Console.WriteLine($" [ESCAPED] relative traversal created: {relTarget}"); - Directory.Delete(relTarget); + var parentDir = Path.Combine( + TEST_ARCHIVES_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); } - var absTarget = "/tmp/escaped_absolute"; - if (Directory.Exists(absTarget)) + static void CheckResults(string archivePath, string parentDir, string extractDir) { - Console.WriteLine($" [ESCAPED] absolute path created: {absTarget}"); - Directory.Delete(absTarget); + 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 From 8d6f1014f08bf7e1d28510ae92c0c253fabedb73 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 17:02:28 +0100 Subject: [PATCH 12/19] consolidate path comparsion --- src/SharpCompress/Archives/IArchiveExtensions.cs | 11 +---------- .../Archives/IAsyncArchiveExtensions.cs | 11 +---------- .../Common/ExtractionMethods.Async.cs | 4 ++-- src/SharpCompress/Common/ExtractionMethods.cs | 14 ++------------ src/SharpCompress/Utility.cs | 10 ++++++++++ 5 files changed, 16 insertions(+), 34 deletions(-) diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index a1ac2226c..17a87962d 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Runtime.InteropServices; using SharpCompress.Common; using SharpCompress.Readers; @@ -9,14 +8,6 @@ namespace SharpCompress.Archives; public static class IArchiveExtensions { - /// - /// 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; extension(IArchive archive) { /// @@ -83,7 +74,7 @@ private void WriteToDirectoryInternal( if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index 51e8f3f26..26facb6b7 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using SharpCompress.Common; @@ -11,14 +10,6 @@ namespace SharpCompress.Archives; public static class IAsyncArchiveExtensions { - /// - /// 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; extension(IAsyncArchive archive) { /// @@ -105,7 +96,7 @@ CancellationToken cancellationToken if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." diff --git a/src/SharpCompress/Common/ExtractionMethods.Async.cs b/src/SharpCompress/Common/ExtractionMethods.Async.cs index 8792e06bb..86347310f 100644 --- a/src/SharpCompress/Common/ExtractionMethods.Async.cs +++ b/src/SharpCompress/Common/ExtractionMethods.Async.cs @@ -45,7 +45,7 @@ public static async ValueTask WriteEntryToDirectoryAsync( if (!Directory.Exists(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." @@ -65,7 +65,7 @@ public static async ValueTask WriteEntryToDirectoryAsync( { destinationFileName = Path.GetFullPath(destinationFileName); - if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to write a file outside of the destination directory." diff --git a/src/SharpCompress/Common/ExtractionMethods.cs b/src/SharpCompress/Common/ExtractionMethods.cs index 526c6e263..bde397d40 100644 --- a/src/SharpCompress/Common/ExtractionMethods.cs +++ b/src/SharpCompress/Common/ExtractionMethods.cs @@ -1,20 +1,10 @@ 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 /// @@ -55,7 +45,7 @@ Action write if (!Directory.Exists(destdir)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to create a directory outside of the destination directory." @@ -75,7 +65,7 @@ Action write { destinationFileName = Path.GetFullPath(destinationFileName); - if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, PathComparison)) + if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) { throw new ExtractionException( "Entry is trying to write a file outside of the destination directory." 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; From bd0fa73cce7521874eec88b861f177735d66decc Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 17:22:53 +0100 Subject: [PATCH 13/19] some refactoring --- .../Archives/IArchiveExtensions.cs | 51 ++-- .../Archives/IAsyncArchiveExtensions.cs | 61 ++--- .../Common/DirectoryManagement.cs | 81 ++++++ .../Common/ExtractionMethods.Async.cs | 88 +++---- src/SharpCompress/Common/ExtractionMethods.cs | 99 +++++--- .../Security/ExtractionPathTraversalTests.cs | 238 ++++++++++++++++++ 6 files changed, 452 insertions(+), 166 deletions(-) create mode 100644 src/SharpCompress/Common/DirectoryManagement.cs create mode 100644 tests/SharpCompress.Test/Security/ExtractionPathTraversalTests.cs diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index 17a87962d..0417cdd79 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,54 +37,33 @@ private void WriteToDirectoryInternal( IProgress? progress ) { - 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 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 folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath( - Path.Combine(fullDestinationDirectoryPath, folder) + ExtractionMethods.WriteEntryToDirectoryCore( + entry, + fullDestinationDirectoryPath, + options, + _ => { } ); - - if (!Directory.Exists(destdir) && seenDirectories.Add(destdir)) - { - if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) - { - throw new ExtractionException( - "Entry is trying to create a directory outside of the destination directory." - ); - } - - Directory.CreateDirectory(destdir); - } continue; } - entry.WriteToDirectory(destinationDirectory, options); + ExtractionMethods.WriteEntryToDirectoryCore( + entry, + 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 26facb6b7..3d9db3f2f 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,28 +56,13 @@ private async ValueTask WriteToDirectoryAsyncInternal( CancellationToken cancellationToken ) { - 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 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)) { @@ -88,28 +70,27 @@ CancellationToken cancellationToken if (entry.IsDirectory) { - 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) && seenDirectories.Add(destdir)) - { - if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) - { - throw new ExtractionException( - "Entry is trying to create a directory outside of the destination directory." - ); - } - - Directory.CreateDirectory(destdir); - } + await ExtractionMethods + .WriteEntryToDirectoryAsyncCore( + entry, + fullDestinationDirectoryPath, + options, + (_, _) => new ValueTask(), + cancellationToken + ) + .ConfigureAwait(false); continue; } - await entry - .WriteToDirectoryAsync(destinationDirectory, options, cancellationToken) + await ExtractionMethods + .WriteEntryToDirectoryAsyncCore( + entry, + 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..b4574018e --- /dev/null +++ b/src/SharpCompress/Common/DirectoryManagement.cs @@ -0,0 +1,81 @@ +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 index 86347310f..b9c913cd6 100644 --- a/src/SharpCompress/Common/ExtractionMethods.Async.cs +++ b/src/SharpCompress/Common/ExtractionMethods.Async.cs @@ -15,67 +15,59 @@ public static async ValueTask WriteEntryToDirectoryAsync( CancellationToken cancellationToken = default ) { - string destinationFileName; - var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(destinationDirectory); - //check for trailing slash. - if ( - fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1] - != Path.DirectorySeparatorChar - ) - { - fullDestinationDirectoryPath += Path.DirectorySeparatorChar; - } + await WriteEntryToDirectoryAsyncCore( + entry, + fullDestinationDirectoryPath, + options, + writeAsync, + cancellationToken + ) + .ConfigureAwait(false); + } - if (!Directory.Exists(fullDestinationDirectoryPath)) - { - throw new ExtractionException( - $"Directory does not exist to extract to: {fullDestinationDirectoryPath}" - ); - } + internal static async ValueTask WriteEntryToDirectoryAsyncCore( + IEntry entry, + string fullDestinationDirectoryPath, + ExtractionOptions options, + Func writeAsync, + CancellationToken cancellationToken = default + ) + { + var destinationFileName = GetEntryDestinationFileName( + entry, + fullDestinationDirectoryPath, + options + ); - var file = Path.GetFileName(entry.Key.NotNull("Entry Key is null")).NotNull("File is null"); - file = Utility.ReplaceInvalidFileNameChars(file); - if (options.ExtractFullPath) + if (!entry.IsDirectory) { - var folder = Path.GetDirectoryName(entry.Key.NotNull("Entry Key is null")) - .NotNull("Directory is null"); - var destdir = Path.GetFullPath(Path.Combine(fullDestinationDirectoryPath, folder)); + destinationFileName = Path.GetFullPath(destinationFileName); - if (!Directory.Exists(destdir)) - { - if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) - { - throw new ExtractionException( - "Entry is trying to create a directory outside of the destination directory." - ); - } + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.WriteFileOutsideDestinationMessage + ); - Directory.CreateDirectory(destdir); - } - destinationFileName = Path.Combine(destdir, file); + await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); } - else - { - destinationFileName = Path.Combine(fullDestinationDirectoryPath, file); - } - - if (!entry.IsDirectory) + else if (options.ExtractFullPath) { destinationFileName = Path.GetFullPath(destinationFileName); - if (!destinationFileName.StartsWith(fullDestinationDirectoryPath, Utility.PathComparison)) + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.CreateDirectoryOutsideDestinationMessage + ); + + if (!Directory.Exists(destinationFileName)) { - throw new ExtractionException( - "Entry is trying to write a file outside of the destination directory." - ); + Directory.CreateDirectory(destinationFileName); } - await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); - } - else if (options.ExtractFullPath && !Directory.Exists(destinationFileName)) - { - Directory.CreateDirectory(destinationFileName); } } diff --git a/src/SharpCompress/Common/ExtractionMethods.cs b/src/SharpCompress/Common/ExtractionMethods.cs index bde397d40..66172b36d 100644 --- a/src/SharpCompress/Common/ExtractionMethods.cs +++ b/src/SharpCompress/Common/ExtractionMethods.cs @@ -15,70 +15,87 @@ public static void WriteEntryToDirectory( Action write ) { - string destinationFileName; - var fullDestinationDirectoryPath = Path.GetFullPath(destinationDirectory); options ??= new ExtractionOptions(); + var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(destinationDirectory); - //check for trailing slash. - if ( - fullDestinationDirectoryPath[fullDestinationDirectoryPath.Length - 1] - != Path.DirectorySeparatorChar - ) + WriteEntryToDirectoryCore(entry, fullDestinationDirectoryPath, options, write); + } + + internal static void WriteEntryToDirectoryCore( + IEntry entry, + string fullDestinationDirectoryPath, + ExtractionOptions options, + Action write + ) + { + var destinationFileName = GetEntryDestinationFileName( + entry, + fullDestinationDirectoryPath, + options + ); + + if (!entry.IsDirectory) { - fullDestinationDirectoryPath += Path.DirectorySeparatorChar; - } + destinationFileName = Path.GetFullPath(destinationFileName); - if (!Directory.Exists(fullDestinationDirectoryPath)) + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.WriteFileOutsideDestinationMessage + ); + + write(destinationFileName); + } + else if (options.ExtractFullPath) { - throw new ExtractionException( - $"Directory does not exist to extract to: {fullDestinationDirectoryPath}" + destinationFileName = Path.GetFullPath(destinationFileName); + + DirectoryManagement.EnsurePathInDestinationDirectory( + destinationFileName, + fullDestinationDirectoryPath, + DirectoryManagement.CreateDirectoryOutsideDestinationMessage ); + + if (!Directory.Exists(destinationFileName)) + { + Directory.CreateDirectory(destinationFileName); + } } + } + private static string GetEntryDestinationFileName( + IEntry entry, + 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)) { - if (!destdir.StartsWith(fullDestinationDirectoryPath, Utility.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, Utility.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); + return Path.Combine(destdir, file); } - } + return Path.Combine(fullDestinationDirectoryPath, file); + } public static void WriteEntryToFile( IEntry entry, string destinationFileName, 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 From 2c35d2d242ff4cc8b8c80353ac09c5bfa58d9dd2 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 17:30:02 +0100 Subject: [PATCH 14/19] made methods extension methods for IEntry --- .../Archives/IArchiveEntryExtensions.cs | 12 +- .../Archives/IArchiveExtensions.cs | 6 +- .../Archives/IAsyncArchiveExtensions.cs | 6 +- .../Common/DirectoryManagement.cs | 4 - .../Common/ExtractionMethods.Async.cs | 109 -------------- src/SharpCompress/Common/ExtractionMethods.cs | 132 ----------------- .../Common/IEntryExtensions.Async.cs | 114 ++++++++++++++ src/SharpCompress/Common/IEntryExtensions.cs | 139 ++++++++++++++++++ .../Readers/IAsyncReaderExtensions.cs | 15 +- .../Readers/IReaderExtensions.cs | 6 +- 10 files changed, 269 insertions(+), 274 deletions(-) delete mode 100644 src/SharpCompress/Common/ExtractionMethods.Async.cs delete mode 100644 src/SharpCompress/Common/ExtractionMethods.cs create mode 100644 src/SharpCompress/Common/IEntryExtensions.Async.cs create mode 100644 src/SharpCompress/Common/IEntryExtensions.cs 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 0417cdd79..2ae6e4ab9 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -49,8 +49,7 @@ private void WriteToDirectoryInternal( { if (entry.IsDirectory) { - ExtractionMethods.WriteEntryToDirectoryCore( - entry, + entry.WriteEntryToDirectoryCore( fullDestinationDirectoryPath, options, _ => { } @@ -58,8 +57,7 @@ private void WriteToDirectoryInternal( continue; } - ExtractionMethods.WriteEntryToDirectoryCore( - entry, + entry.WriteEntryToDirectoryCore( fullDestinationDirectoryPath, options, path => entry.WriteToFile(path, options) diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index 3d9db3f2f..a3e9948e2 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -70,9 +70,8 @@ CancellationToken cancellationToken if (entry.IsDirectory) { - await ExtractionMethods + await entry .WriteEntryToDirectoryAsyncCore( - entry, fullDestinationDirectoryPath, options, (_, _) => new ValueTask(), @@ -82,9 +81,8 @@ await ExtractionMethods continue; } - await ExtractionMethods + await entry .WriteEntryToDirectoryAsyncCore( - entry, fullDestinationDirectoryPath, options, async (path, ct) => diff --git a/src/SharpCompress/Common/DirectoryManagement.cs b/src/SharpCompress/Common/DirectoryManagement.cs index b4574018e..4bd4585d1 100644 --- a/src/SharpCompress/Common/DirectoryManagement.cs +++ b/src/SharpCompress/Common/DirectoryManagement.cs @@ -4,7 +4,6 @@ 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 = @@ -59,8 +58,6 @@ string exceptionMessage throw new ExtractionException(exceptionMessage); } - - private static bool IsDirectorySeparator(char value) => value == Path.DirectorySeparatorChar || value == Path.AltDirectorySeparatorChar; @@ -77,5 +74,4 @@ private static string TrimTrailingDirectorySeparators(string path) 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 b9c913cd6..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 - ) - { - options ??= new ExtractionOptions(); - var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(destinationDirectory); - - await WriteEntryToDirectoryAsyncCore( - entry, - fullDestinationDirectoryPath, - options, - writeAsync, - cancellationToken - ) - .ConfigureAwait(false); - } - - internal static async ValueTask WriteEntryToDirectoryAsyncCore( - IEntry entry, - 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 - ); - - 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 static async ValueTask WriteEntryToFileAsync( - IEntry entry, - string destinationFileName, - ExtractionOptions? options, - Func openAndWriteAsync, - CancellationToken cancellationToken = default - ) - { - options ??= new ExtractionOptions(); - if (entry.LinkTarget != null) - { - if (options.SymbolicLinkHandler is not null) - { - options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - else - { - ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - return; - } - 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 66172b36d..000000000 --- a/src/SharpCompress/Common/ExtractionMethods.cs +++ /dev/null @@ -1,132 +0,0 @@ -using System; -using System.IO; - -namespace SharpCompress.Common; - -internal static partial class ExtractionMethods -{ - /// - /// Extract to specific directory, retaining filename - /// - public static void WriteEntryToDirectory( - IEntry entry, - string destinationDirectory, - ExtractionOptions? options, - Action write - ) - { - options ??= new ExtractionOptions(); - var fullDestinationDirectoryPath = DirectoryManagement.GetFullDestinationDirectoryPath(destinationDirectory); - - WriteEntryToDirectoryCore(entry, fullDestinationDirectoryPath, options, write); - } - - internal static void WriteEntryToDirectoryCore( - IEntry entry, - 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(destinationFileName); - } - else if (options.ExtractFullPath) - { - destinationFileName = Path.GetFullPath(destinationFileName); - - DirectoryManagement.EnsurePathInDestinationDirectory( - destinationFileName, - fullDestinationDirectoryPath, - DirectoryManagement.CreateDirectoryOutsideDestinationMessage - ); - - if (!Directory.Exists(destinationFileName)) - { - Directory.CreateDirectory(destinationFileName); - } - } - } - - private static string GetEntryDestinationFileName( - IEntry entry, - 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 static void WriteEntryToFile( - IEntry entry, - string destinationFileName, - ExtractionOptions? options, - Action openAndWrite - ) - { - options ??= new ExtractionOptions(); - if (entry.LinkTarget != null) - { - if (options.SymbolicLinkHandler is not null) - { - options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - else - { - ExtractionOptions.DefaultSymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - return; - } - else - { - var fm = FileMode.Create; - - if (!options.Overwrite) - { - fm = FileMode.CreateNew; - } - - openAndWrite(destinationFileName, fm); - entry.PreserveExtractionOptions(destinationFileName, options); - } - } -} diff --git a/src/SharpCompress/Common/IEntryExtensions.Async.cs b/src/SharpCompress/Common/IEntryExtensions.Async.cs new file mode 100644 index 000000000..234a1d3c9 --- /dev/null +++ b/src/SharpCompress/Common/IEntryExtensions.Async.cs @@ -0,0 +1,114 @@ +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 + ); + + 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) + { + if (options.SymbolicLinkHandler is not null) + { + options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); + } + else + { + ExtractionOptions.DefaultSymbolicLinkHandler( + destinationFileName, + entry.LinkTarget + ); + } + return; + } + 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..a0b0d5bc8 --- /dev/null +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -0,0 +1,139 @@ +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(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) + { + if (options.SymbolicLinkHandler is not null) + { + options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); + } + else + { + ExtractionOptions.DefaultSymbolicLinkHandler( + destinationFileName, + entry.LinkTarget + ); + } + + return; + } + else + { + var fm = FileMode.Create; + + if (!options.Overwrite) + { + fm = FileMode.CreateNew; + } + + openAndWrite(destinationFileName, fm); + entry.PreserveExtractionOptions(destinationFileName, options); + } + } + } +} diff --git a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs index 120e2f69e..a2c01e52d 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,9 +35,8 @@ 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) => @@ -72,9 +70,8 @@ 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) => 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) => From 5f16216a3f38308bb3dc3d450ec9269e1842307a Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 17:48:39 +0100 Subject: [PATCH 15/19] review and test fixes --- .../Archives/IArchiveExtensions.cs | 6 +---- .../Archives/IAsyncArchiveExtensions.cs | 2 +- .../Common/IEntryExtensions.Async.cs | 9 ++++--- src/SharpCompress/Common/IEntryExtensions.cs | 5 ++-- tests/SharpCompress.Test/Security/ZipSlip.cs | 24 ++++++++++++------- 5 files changed, 25 insertions(+), 21 deletions(-) diff --git a/src/SharpCompress/Archives/IArchiveExtensions.cs b/src/SharpCompress/Archives/IArchiveExtensions.cs index 2ae6e4ab9..d99a7afde 100644 --- a/src/SharpCompress/Archives/IArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IArchiveExtensions.cs @@ -49,11 +49,7 @@ private void WriteToDirectoryInternal( { if (entry.IsDirectory) { - entry.WriteEntryToDirectoryCore( - fullDestinationDirectoryPath, - options, - _ => { } - ); + entry.WriteEntryToDirectoryCore(fullDestinationDirectoryPath, options, null); continue; } diff --git a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs index a3e9948e2..21d2730cb 100644 --- a/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs +++ b/src/SharpCompress/Archives/IAsyncArchiveExtensions.cs @@ -74,7 +74,7 @@ await entry .WriteEntryToDirectoryAsyncCore( fullDestinationDirectoryPath, options, - (_, _) => new ValueTask(), + null, cancellationToken ) .ConfigureAwait(false); diff --git a/src/SharpCompress/Common/IEntryExtensions.Async.cs b/src/SharpCompress/Common/IEntryExtensions.Async.cs index 234a1d3c9..c1bc0dea0 100644 --- a/src/SharpCompress/Common/IEntryExtensions.Async.cs +++ b/src/SharpCompress/Common/IEntryExtensions.Async.cs @@ -34,7 +34,7 @@ await WriteEntryToDirectoryAsyncCore( internal async ValueTask WriteEntryToDirectoryAsyncCore( string fullDestinationDirectoryPath, ExtractionOptions options, - Func writeAsync, + Func? writeAsync, CancellationToken cancellationToken = default ) { @@ -53,8 +53,6 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore( fullDestinationDirectoryPath, DirectoryManagement.WriteFileOutsideDestinationMessage ); - - await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); } else if (options.ExtractFullPath) { @@ -71,6 +69,11 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore( Directory.CreateDirectory(destinationFileName); } } + + if (writeAsync != null) + { + await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); + } } public async ValueTask WriteEntryToFileAsync( diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index a0b0d5bc8..094d997be 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -27,7 +27,7 @@ Action write internal void WriteEntryToDirectoryCore( string fullDestinationDirectoryPath, ExtractionOptions options, - Action write + Action? write ) { var destinationFileName = GetEntryDestinationFileName( @@ -45,8 +45,6 @@ Action write fullDestinationDirectoryPath, DirectoryManagement.WriteFileOutsideDestinationMessage ); - - write(destinationFileName); } else if (options.ExtractFullPath) { @@ -63,6 +61,7 @@ Action write Directory.CreateDirectory(destinationFileName); } } + write?.Invoke(destinationFileName); } private string GetEntryDestinationFileName( diff --git a/tests/SharpCompress.Test/Security/ZipSlip.cs b/tests/SharpCompress.Test/Security/ZipSlip.cs index 251b48d43..208c31920 100644 --- a/tests/SharpCompress.Test/Security/ZipSlip.cs +++ b/tests/SharpCompress.Test/Security/ZipSlip.cs @@ -23,13 +23,19 @@ public void RunSync() 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"); + 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); } @@ -79,10 +85,10 @@ static void BuildMaliciousZip(string path) zip.CreateEntry("safe_subdir/"); } - static (string extractDir, string parentDir) SetupDirs(string label) + private (string extractDir, string parentDir) SetupDirs(string label) { var parentDir = Path.Combine( - TEST_ARCHIVES_PATH, + SCRATCH_FILES_PATH, $"sc_poc_{label}_{Path.GetRandomFileName()}" ); Directory.CreateDirectory(parentDir); From 7de1f07597115586b37b3ac6bafd5add018cff27 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Tue, 5 May 2026 17:58:36 +0100 Subject: [PATCH 16/19] revert change that made no sense --- src/SharpCompress/Common/IEntryExtensions.Async.cs | 10 +++++----- src/SharpCompress/Common/IEntryExtensions.cs | 2 +- src/SharpCompress/Readers/IAsyncReaderExtensions.cs | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/SharpCompress/Common/IEntryExtensions.Async.cs b/src/SharpCompress/Common/IEntryExtensions.Async.cs index c1bc0dea0..fabf43347 100644 --- a/src/SharpCompress/Common/IEntryExtensions.Async.cs +++ b/src/SharpCompress/Common/IEntryExtensions.Async.cs @@ -53,6 +53,11 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore( fullDestinationDirectoryPath, DirectoryManagement.WriteFileOutsideDestinationMessage ); + + if (writeAsync != null) + { + await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); + } } else if (options.ExtractFullPath) { @@ -69,11 +74,6 @@ internal async ValueTask WriteEntryToDirectoryAsyncCore( Directory.CreateDirectory(destinationFileName); } } - - if (writeAsync != null) - { - await writeAsync(destinationFileName, cancellationToken).ConfigureAwait(false); - } } public async ValueTask WriteEntryToFileAsync( diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index 094d997be..813ebfe31 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -45,6 +45,7 @@ internal void WriteEntryToDirectoryCore( fullDestinationDirectoryPath, DirectoryManagement.WriteFileOutsideDestinationMessage ); + write?.Invoke(destinationFileName); } else if (options.ExtractFullPath) { @@ -61,7 +62,6 @@ internal void WriteEntryToDirectoryCore( Directory.CreateDirectory(destinationFileName); } } - write?.Invoke(destinationFileName); } private string GetEntryDestinationFileName( diff --git a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs index a2c01e52d..3c1d04086 100644 --- a/src/SharpCompress/Readers/IAsyncReaderExtensions.cs +++ b/src/SharpCompress/Readers/IAsyncReaderExtensions.cs @@ -41,7 +41,7 @@ await reader 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 @@ -76,7 +76,7 @@ await reader 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 From 69ef2b2a3b6ca51a06c88f9b66af102cbd71e379 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 6 May 2026 10:10:02 +0100 Subject: [PATCH 17/19] merge fixes --- src/SharpCompress/packages.lock.json | 12 ++++---- tests/SharpCompress.Test/packages.lock.json | 31 +++++++++++++++++++++ 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/src/SharpCompress/packages.lock.json b/src/SharpCompress/packages.lock.json index 5059aafeb..7d5f04481 100644 --- a/src/SharpCompress/packages.lock.json +++ b/src/SharpCompress/packages.lock.json @@ -268,9 +268,9 @@ "net10.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[10.0.0, )", - "resolved": "10.0.0", - "contentHash": "kICGrGYEzCNI3wPzfEXcwNHgTvlvVn9yJDhSdRK+oZQy4jvYH529u7O0xf5ocQKzOMjfS07+3z9PKRIjrFMJDA==" + "requested": "[10.0.5, )", + "resolved": "10.0.5", + "contentHash": "A+5ZuQ0f449tM+MQrhf6R9ZX7lYpjk/ODEwLYKrnF6111rtARx8fVsm4YznUnQiKnnXfaXNBqgxmil6RW3L3SA==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", @@ -442,9 +442,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.22, )", - "resolved": "8.0.22", - "contentHash": "MhcMithKEiyyNkD2ZfbDZPmcOdi0GheGfg8saEIIEfD/fol3iHmcV8TsZkD4ZYz5gdUuoX4YtlVySUU7Sxl9SQ==" + "requested": "[8.0.25, )", + "resolved": "8.0.25", + "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" }, "Microsoft.NETFramework.ReferenceAssemblies": { "type": "Direct", 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 From 005c269bbeec6b13b25a212c08b487750446dd26 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 6 May 2026 10:17:36 +0100 Subject: [PATCH 18/19] Merge fixes --- src/SharpCompress/Common/IEntry.Extensions.cs | 71 ----------------- .../Common/IEntryExtensions.Async.cs | 13 +-- src/SharpCompress/Common/IEntryExtensions.cs | 79 ++++++++++++++++--- 3 files changed, 67 insertions(+), 96 deletions(-) delete mode 100644 src/SharpCompress/Common/IEntry.Extensions.cs 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 index fabf43347..7b42f1c85 100644 --- a/src/SharpCompress/Common/IEntryExtensions.Async.cs +++ b/src/SharpCompress/Common/IEntryExtensions.Async.cs @@ -86,18 +86,7 @@ public async ValueTask WriteEntryToFileAsync( options ??= new ExtractionOptions(); if (entry.LinkTarget != null) { - if (options.SymbolicLinkHandler is not null) - { - options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - else - { - ExtractionOptions.DefaultSymbolicLinkHandler( - destinationFileName, - entry.LinkTarget - ); - } - return; + options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); } else { diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index 813ebfe31..feaf8e513 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -107,19 +107,7 @@ Action openAndWrite options ??= new ExtractionOptions(); if (entry.LinkTarget != null) { - if (options.SymbolicLinkHandler is not null) - { - options.SymbolicLinkHandler(destinationFileName, entry.LinkTarget); - } - else - { - ExtractionOptions.DefaultSymbolicLinkHandler( - destinationFileName, - entry.LinkTarget - ); - } - - return; + options.SymbolicLinkHandler?.Invoke(destinationFileName, entry.LinkTarget); } else { @@ -134,5 +122,70 @@ Action openAndWrite 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); + } + } + } + } } } From f92d86fbf340283c83f6772ad4bdcadc5caedeb1 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Wed, 6 May 2026 10:18:27 +0100 Subject: [PATCH 19/19] remove net 7 and 9 --- src/SharpCompress/Common/IEntryExtensions.cs | 1 - src/SharpCompress/SharpCompress.csproj | 2 +- src/SharpCompress/packages.lock.json | 84 -------------------- 3 files changed, 1 insertion(+), 86 deletions(-) diff --git a/src/SharpCompress/Common/IEntryExtensions.cs b/src/SharpCompress/Common/IEntryExtensions.cs index feaf8e513..77c5207cb 100644 --- a/src/SharpCompress/Common/IEntryExtensions.cs +++ b/src/SharpCompress/Common/IEntryExtensions.cs @@ -123,7 +123,6 @@ Action openAndWrite } } - internal void PreserveExtractionOptions( string destinationFileName, ExtractionOptions options 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/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