diff --git a/SharpCompress.sln b/SharpCompress.sln index af132f4c5..3609183a4 100644 --- a/SharpCompress.sln +++ b/SharpCompress.sln @@ -24,6 +24,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Config", "Config", "{CDB425 .github\workflows\nuget-release.yml = .github\workflows\nuget-release.yml README.md = README.md AGENTS.md = AGENTS.md + docs\TAR_GAP_ANALYSIS.md = docs\TAR_GAP_ANALYSIS.md + docs\TAR_SPEC.md = docs\TAR_SPEC.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpCompress.Performance", "tests\SharpCompress.Performance\SharpCompress.Performance.csproj", "{5BDE6DBC-9E5F-4E21-AB71-F138A3E72B17}" diff --git a/docs/TAR_GAP_ANALYSIS.md b/docs/TAR_GAP_ANALYSIS.md index 3e46db521..0b621261a 100644 --- a/docs/TAR_GAP_ANALYSIS.md +++ b/docs/TAR_GAP_ANALYSIS.md @@ -17,6 +17,21 @@ Primary references: - `src/SharpCompress/Common/Tar/` - `tests/SharpCompress.Test/Tar/` +## Implemented Since Baseline + +- `Tar.XZ` is now documented as read-only (`Writer API = N/A`) in `docs/FORMATS.md`. +- Local PAX extended headers (`x`) are now implemented on the read path for selected keys. +- Global PAX extended headers (`g`) are now implemented on the read path for selected keys. +- Tar tests now include local PAX coverage for reader/archive sync and async paths. +- Tar tests now include global PAX coverage for reader/archive sync and async paths. +- `TarWriterOptions.HeaderFormat` is now honored in sync and async file and directory write paths. +- Tar tests now cover `USTAR` and `GNU_TAR_LONG_LINK`, including USTAR long-name failure scenarios. +- Symlink coverage now includes `TarWithSymlink.tar.gz` for reader sync and async paths. +- Tar tests now explicitly cover unsupported tar wrapper compression writes (`Xz`, `ZStandard`, `Lzw`) for sync and async writer paths. +- `TarArchive.OpenAsyncArchive(Stream)` now enforces the same seekable-stream contract as `TarArchive.OpenArchive(Stream)`. +- Sparse handling remains explicitly unsupported. +- Non-modeled PAX keys remain explicitly unsupported. + ## Claimed vs Actual Support ### `Tar.XZ` is read-only @@ -41,29 +56,36 @@ Recommended action: ## Read-Path Gaps -### PAX headers are not implemented +### Local and global PAX headers are implemented for selected keys -There is no explicit support for POSIX PAX local extended headers. +Local (`x`) and global (`g`) POSIX PAX extended headers are now supported on the read path. -Evidence: +Supported keys in the current implementation: -- `EntryType` does not define the usual local PAX header type value -- `TarHeader.Read` handles `LongName` and `LongLink`, but does not implement PAX record parsing -- there are no tests or test archives covering PAX behavior +- `path` +- `linkpath` +- `size` +- `mtime` +- `uid` +- `gid` +- `mode` -Impact: +Remaining gap: -- archives relying on PAX for long names, metadata, or timestamps may not be interpreted correctly +- non-modeled PAX keys are still ignored +- PAX sparse extensions are still unsupported Recommended action: -- decide whether PAX is intentionally unsupported or should be implemented -- document that decision explicitly +- keep supported-key boundaries documented and test-covered +- keep unsupported-key behavior explicit in docs ### Sparse files are not semantically implemented `EntryType` defines `SparseFile`, but the read path does not contain sparse map handling or sparse reconstruction logic. +PAX sparse extensions are also unsupported (for example `GNU.sparse.*` and similar sparse metadata keys). + Evidence: - `src/SharpCompress/Common/Tar/Headers/EntryType.cs` @@ -76,26 +98,25 @@ Impact: Recommended action: -- document sparse support as unsupported or partial -- add explicit tests if future support is added +- keep sparse support explicitly documented as unsupported +- add sparse fixtures and tests only when sparse reconstruction is implemented -### Global extended headers are not semantically implemented +### Non-modeled PAX keys are still unsupported -`EntryType` defines `GlobalExtendedHeader`, but no semantic handling exists in the read pipeline. +PAX parsing is intentionally limited to modeled keys (`path`, `linkpath`, `size`, `mtime`, `uid`, `gid`, `mode`). -Evidence: - -- `TarHeader.Read` does not special-case `GlobalExtendedHeader` -- `TarEntry` does not surface a global-header model -- no tests cover this case +Not currently modeled/supported: -Impact: - -- global metadata records are not applied in a defined way +- `uname` +- `gname` +- `atime` +- `ctime` +- device-specific values and vendor keys Recommended action: -- document as unsupported until explicit behavior exists +- keep unsupported-key behavior documented as ignored +- add support only when there is a consumer-facing object model for it ### Device and FIFO semantics are not surfaced @@ -112,34 +133,16 @@ Recommended action: ## Write-Path Gaps -### `HeaderFormat` is not honored consistently - -`TarWriterOptions.HeaderFormat` exists and defaults to `GNU_TAR_LONG_LINK`, but the configured value is not consistently applied. - -### Sync directory write path - -`TarWriter.WriteDirectory` creates headers using: - -- `new TarHeader(WriterOptions.ArchiveEncoding)` - -This uses the default tar header format rather than the writer's configured `headerFormat` field. +### `HeaderFormat` consistency is resolved -Impact: - -- directory entries written through the sync path do not follow `TarWriterOptions.HeaderFormat` +`TarWriterOptions.HeaderFormat` is now applied across: -### Async write path +- sync file writes +- sync directory writes +- async file writes +- async directory writes -`TarWriter.WriteAsync` and `WriteDirectoryAsync` also create headers using the default constructor rather than the configured header format. - -Impact: - -- async writes ignore `TarWriterOptions.HeaderFormat` for both file and directory entries - -Recommended action: - -- pass the configured header format to all `TarHeader` constructions in sync and async write paths -- add tests for both `GNU_TAR_LONG_LINK` and `USTAR` +Regression tests now cover both `USTAR` and `GNU_TAR_LONG_LINK` behavior. ### No public link-writing support @@ -182,72 +185,41 @@ This is not inherently wrong, but it should be clearly documented everywhere sup ## Sync and Async API Inconsistencies -### Seekability requirements differ at the API boundary - -Synchronous `TarArchive.OpenArchive(Stream)` explicitly throws if the stream is not seekable. - -Asynchronous `TarArchive.OpenAsyncArchive(Stream)` does not perform the same public guard. +### Seekability contract alignment is resolved -Impact: - -- callers do not see the same contract from sync and async overloads -- behavior is harder to reason about from API docs alone +`TarArchive.OpenArchive(Stream)` and `TarArchive.OpenAsyncArchive(Stream)` now both enforce the same seekable-stream contract and throw `ArgumentException` for non-seekable input. -Recommended action: +Tar tests include an async regression case for non-seekable stream open. -- either align the contracts or document the difference explicitly +### Header format alignment between sync and async is resolved -### Async and sync write behavior do not align on header format handling - -This is the most visible sync/async inconsistency in the current Tar writer implementation. - -Recommended action: - -- fix the implementation first -- add matching sync and async tests to keep the behavior aligned +Sync and async Tar writer paths now both honor `TarWriterOptions.HeaderFormat`, and matching tests are present for both paths. ## Test Coverage Gaps -### Symlink coverage exists in test data but not in assertions +### Symlink coverage is now present for reader paths -There is a tar archive containing symlinks: +Symlink behavior is now asserted for sync and async reader paths using: - `tests/TestArchives/Archives/TarWithSymlink.tar.gz` -Current Tar tests do not assert tar symlink behavior against that fixture. +Archive-path symlink assertions currently rely on small tar fixtures rather than this large compressed sample. -Impact: - -- the code claims practical read support for link targets, but coverage does not verify it +### Header format coverage is now present -Recommended action: - -- add reader and archive tests asserting `EntryType`-derived behavior and `LinkTarget` - -### No tests for `HeaderFormat` - -There are currently no tests covering: +Tar tests now cover: - `TarWriterOptions.HeaderFormat = USTAR` - `TarWriterOptions.HeaderFormat = GNU_TAR_LONG_LINK` -- long-name failures in USTAR mode -- long-name success in GNU mode through the async writer path +- long-name failure in USTAR mode +- long-name success in GNU mode through sync and async writer paths -Impact: - -- the current header-format regressions were able to exist without test coverage +### No tests for sparse tar semantics -Recommended action: +Local and global PAX coverage now exists, but there is still no evidence of coverage for: -- add dedicated sync and async writer tests for header format selection - -### No tests for PAX, sparse, or global headers - -There is no evidence of coverage for: - -- PAX local headers -- global extended headers - sparse tar entries +- sparse PAX extensions Impact: @@ -257,19 +229,15 @@ Recommended action: - either add fixtures and tests or document these as unsupported with no test coverage -### No tests for unsupported write wrappers +### Unsupported-wrapper writer coverage is now present -There are negative tests for an invalid `Rar` compression type, but not for unsupported tar wrappers that a user might reasonably infer from read support. - -Missing negative cases include: +Tar writer tests now explicitly verify `InvalidFormatException` for unsupported tar wrapper compression types: - `CompressionType.Xz` - `CompressionType.ZStandard` - `CompressionType.Lzw` -Recommended action: - -- add explicit negative tests so the supported write matrix stays intentional +Coverage exists in both sync and async writer test paths. ## Documentation Gaps @@ -282,7 +250,7 @@ Missing implementation-specific details include: - GNU long-name and long-link support - USTAR prefix handling - oldgnu numeric quirk handling -- missing PAX support +- partial PAX support boundaries (selected local/global keys supported) - missing sparse support - reader vs archive behavior differences for compressed tar - file-size requirements for writing from non-seekable sources @@ -306,27 +274,10 @@ Recommended action: ## Recommended Follow-Ups -### Priority 0 - -- Correct `docs/FORMATS.md` for `Tar.XZ` write support - ### Priority 1 -- Fix `TarWriterOptions.HeaderFormat` handling in sync and async writer paths -- Add tests for header-format behavior -- Add symlink coverage using `TarWithSymlink.tar.gz` - -### Priority 2 - -- Decide and document the support position for PAX headers -- Decide and document the support position for sparse files -- Decide and document the support position for global extended headers - -### Priority 3 - -- Add negative writer tests for unsupported wrapper compressions -- Evaluate whether sync and async archive open contracts should match exactly - Improve metadata round-trip behavior only if there is a consumer need +- Evaluate whether non-modeled PAX keys should remain ignored or be surfaced in a future metadata API ## Summary @@ -334,7 +285,7 @@ The SharpCompress Tar implementation is strong on common read scenarios and basi - documentation overstating or under-describing support - incomplete feature coverage for less common tar dialect features -- sync/async and file/directory inconsistencies in writer header-format handling -- test coverage holes around links and advanced tar metadata features +- intentionally deferred metadata and API-surface decisions +- test coverage holes around advanced tar metadata features `docs/TAR_SPEC.md` should be treated as the implementation baseline. This document identifies where that baseline is incomplete, inconsistent, or incorrectly reflected elsewhere in the repository. diff --git a/docs/TAR_SPEC.md b/docs/TAR_SPEC.md index 0d3713322..b1870004d 100644 --- a/docs/TAR_SPEC.md +++ b/docs/TAR_SPEC.md @@ -149,11 +149,11 @@ Implementation files: ### Open Behavior -Synchronous `TarArchive.OpenArchive(Stream)` requires a seekable stream and throws `ArgumentException` when `CanSeek` is `false`. +`TarArchive.OpenArchive(Stream)` and `TarArchive.OpenAsyncArchive(Stream)` require a seekable stream and throw `ArgumentException` when `CanSeek` is `false`. `TarArchive.OpenArchive(FileInfo)` and the list-based overloads use `SourceStream` and determine wrapper compression by calling `TarFactory.GetCompressionType`. -Asynchronous `OpenAsyncArchive` overloads use `TarFactory.GetCompressionTypeAsync` and do not enforce the same explicit seekability check at the public API boundary. +Asynchronous `OpenAsyncArchive` overloads use `TarFactory.GetCompressionTypeAsync` for wrapper detection. ### Entry Loading @@ -269,6 +269,8 @@ Tar header parsing is implemented in `TarHeader.Read` and `TarHeader.ReadAsync`. | Hard link target reading | Yes | | GNU long name (`L`) | Yes | | GNU long link (`K`) | Yes | +| PAX local extended header (`x`) | Yes (selected keys) | +| PAX global extended header (`g`) | Yes (selected keys) | | USTAR prefix reconstruction | Yes | | Binary size field parsing | Yes | | oldgnu uid/gid numeric quirk parsing | Yes | @@ -290,6 +292,7 @@ Tar header parsing is implemented in `TarHeader.Read` and `TarHeader.ReadAsync`. - `LongName` - `SparseFile` - `VolumeHeader` +- `LocalExtendedHeader` - `GlobalExtendedHeader` SharpCompress currently has explicit handling for only a subset of those values during read and write. @@ -300,6 +303,38 @@ When `TarHeader.Read` encounters `EntryType.LongName` or `EntryType.LongLink`, i Long-name payload reads are capped at `32768` bytes to avoid memory exhaustion from malformed archives. +### PAX Local Header Reads + +SharpCompress now consumes local PAX extended headers (`x`) and applies supported key overrides to the next real entry. + +Currently supported keys: + +- `path` +- `linkpath` +- `size` +- `mtime` +- `uid` +- `gid` +- `mode` + +Unknown PAX keys are ignored. + +### PAX Global Header Reads + +SharpCompress consumes global PAX extended headers (`g`) and applies supported key overrides to subsequent entries. + +Supported keys match local PAX support: + +- `path` +- `linkpath` +- `size` +- `mtime` +- `uid` +- `gid` +- `mode` + +Global metadata is overridden by local per-entry metadata when both are present. + ### Name Reconstruction For USTAR headers, if the magic field is `ustar` and the prefix field is populated, SharpCompress reconstructs the entry name as `prefix + "/" + name`. @@ -358,7 +393,7 @@ Async tar support is provided by: - `TarHeader.ReadAsync` - `TarHeader.WriteAsync` -The async implementations generally mirror the sync implementations while using async header parsing, decompression, and stream copy paths. The most important current exception is `TarWriterOptions.HeaderFormat`, which is not consistently honored outside the synchronous file write path. +The async implementations generally mirror the sync implementations while using async header parsing, decompression, and stream copy paths. ## Known Limitations @@ -376,14 +411,13 @@ This section documents current implementation limits, not desired future behavio ### Read limitations or partial support -- No explicit PAX local header support +- PAX support is limited to selected keys (`path`, `linkpath`, `size`, `mtime`, `uid`, `gid`, `mode`) - No semantic sparse file handling beyond recognizing the entry type enum value -- No semantic global extended header handling beyond recognizing the entry type enum value - No special device or FIFO object model beyond the raw entry type information available internally ### Archive behavior limitations -- Sync archive open requires a seekable input stream +- Stream-based archive open requires a seekable input stream - Compressed tar archive access is not full random-access in the same sense as uncompressed seekable tar ## Test Coverage Map @@ -414,6 +448,8 @@ Representative tar test archives in `tests/TestArchives/Archives/`: - `very long filename.tar` - `ustar with long names.tar` - `Tar.LongPathsWithLongNameExtension.tar` +- `Tar.PaxGlobalHeader.tar` +- `Tar.PaxGlobalHeader.Link.tar` - `Tar.Empty.tar` - `TarCorrupted.tar` - `TarWithSymlink.tar.gz` @@ -427,4 +463,5 @@ SharpCompress Tar support is centered around: - seekable archive support for uncompressed tar and archive rewrite workflows - narrower write support than read support - GNU long-name and USTAR write support +- PAX local header (`x`) read support for selected metadata keys - partial coverage for less common tar dialect features diff --git a/src/SharpCompress/Archives/Tar/TarArchive.Async.cs b/src/SharpCompress/Archives/Tar/TarArchive.Async.cs index 45e5b1cac..5d8833af5 100644 --- a/src/SharpCompress/Archives/Tar/TarArchive.Async.cs +++ b/src/SharpCompress/Archives/Tar/TarArchive.Async.cs @@ -6,7 +6,6 @@ using SharpCompress.Common; using SharpCompress.Common.Options; using SharpCompress.Common.Tar; -using SharpCompress.Common.Tar.Headers; using SharpCompress.IO; using SharpCompress.Readers; using SharpCompress.Readers.Tar; @@ -105,71 +104,29 @@ IAsyncEnumerable volumes ? StreamingMode.Seekable : StreamingMode.Streaming; - // Always use async header reading in LoadEntriesAsync for consistency - { - // Use async header reading for async-only streams - TarHeader? previousHeader = null; - await foreach ( - var header in TarHeaderFactory.ReadHeaderAsync( - streamingMode, - stream, - ReaderOptions.ArchiveEncoding - ) + await foreach ( + var header in TarHeaderFactory.ReadHeaderAsync( + streamingMode, + stream, + ReaderOptions.ArchiveEncoding ) + ) + { + if (header != null) { - if (header != null) - { - if (header.EntryType == EntryType.LongName) - { - previousHeader = header; - } - else - { - if (previousHeader != null) - { - var entry = new TarArchiveEntry( - this, - new TarFilePart( - previousHeader, - _compressionType == CompressionType.None ? stream : null - ), - CompressionType.None, - ReaderOptions - ); - - var oldStreamPos = stream.Position; - - using (var entryStream = entry.OpenEntryStream()) - { - using var memoryStream = new PooledMemoryStream(); - await entryStream.CopyToAsync(memoryStream).ConfigureAwait(false); - memoryStream.Position = 0; - var bytes = memoryStream.ToArray(); - - header.Name = ReaderOptions - .ArchiveEncoding.Decode(bytes) - .TrimNulls(); - } - - stream.Position = oldStreamPos; - - previousHeader = null; - } - yield return new TarArchiveEntry( - this, - new TarFilePart( - header, - _compressionType == CompressionType.None ? stream : null - ), - CompressionType.None, - ReaderOptions - ); - } - } - else - { - throw new IncompleteArchiveException("Failed to read TAR header"); - } + yield return new TarArchiveEntry( + this, + new TarFilePart( + header, + _compressionType == CompressionType.None ? stream : null + ), + CompressionType.None, + ReaderOptions + ); + } + else + { + throw new IncompleteArchiveException("Failed to read TAR header"); } } } diff --git a/src/SharpCompress/Archives/Tar/TarArchive.cs b/src/SharpCompress/Archives/Tar/TarArchive.cs index 442385498..038fd32d9 100644 --- a/src/SharpCompress/Archives/Tar/TarArchive.cs +++ b/src/SharpCompress/Archives/Tar/TarArchive.cs @@ -6,13 +6,11 @@ using System.Threading.Tasks; using SharpCompress.Common; using SharpCompress.Common.Tar; -using SharpCompress.Common.Tar.Headers; using SharpCompress.IO; using SharpCompress.Providers; using SharpCompress.Readers; using SharpCompress.Readers.Tar; using SharpCompress.Writers.Tar; -using Constants = SharpCompress.Common.Constants; namespace SharpCompress.Archives.Tar; @@ -116,7 +114,6 @@ protected override IEnumerable LoadEntries(IEnumerable ReadAsync(AsyncBinaryReader reader) + internal async ValueTask ReadAsync( + AsyncBinaryReader reader, + PaxMetadata? globalPaxMetadata = null + ) { - string? longName = null; - string? longLinkName = null; - var hasLongValue = true; - byte[] buffer; + globalPaxMetadata ??= new PaxMetadata(); + var pendingMetadata = globalPaxMetadata.Clone(); + var buffer = ArrayPool.Shared.Rent(BLOCK_SIZE); EntryType entryType; - - do + try { - buffer = await ReadBlockAsync(reader).ConfigureAwait(false); - - if (buffer.Length == 0) + while (true) { - return false; - } + await reader.ReadBytesAsync(buffer, 0, BLOCK_SIZE).ConfigureAwait(false); + entryType = ReadEntryType(buffer); - entryType = ReadEntryType(buffer); + // LongName and LongLink headers can follow each other and need + // to apply to the header that follows them. + if (entryType == EntryType.LongName) + { + pendingMetadata.Name = await ReadLongNameAsync(reader, buffer) + .ConfigureAwait(false); + continue; + } - // LongName and LongLink headers can follow each other and need - // to apply to the header that follows them. - if (entryType == EntryType.LongName) - { - longName = await ReadLongNameAsync(reader, buffer).ConfigureAwait(false); - continue; - } - else if (entryType == EntryType.LongLink) - { - longLinkName = await ReadLongNameAsync(reader, buffer).ConfigureAwait(false); - continue; - } + if (entryType == EntryType.LongLink) + { + pendingMetadata.LinkName = await ReadLongNameAsync(reader, buffer) + .ConfigureAwait(false); + continue; + } - hasLongValue = false; - } while (hasLongValue); + if (entryType == EntryType.LocalExtendedHeader) + { + await ReadPaxMetadataAsync(reader, buffer, pendingMetadata) + .ConfigureAwait(false); + continue; + } + + if (entryType == EntryType.GlobalExtendedHeader) + { + await ReadPaxMetadataAsync(reader, buffer, globalPaxMetadata) + .ConfigureAwait(false); + pendingMetadata = globalPaxMetadata.Clone(); + continue; + } + + break; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } // Check header checksum if (!checkChecksum(buffer)) @@ -241,23 +261,18 @@ internal async ValueTask ReadAsync(AsyncBinaryReader reader) return false; } - Name = longName ?? ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); + Name = ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); EntryType = entryType; Size = ReadSize(buffer); + LinkName = null; // for symlinks, additionally read the linkname if (entryType == EntryType.SymLink || entryType == EntryType.HardLink) { - LinkName = longLinkName ?? ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); + LinkName = ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); } Mode = ReadAsciiInt64Base8(buffer, 100, 7); - - if (entryType == EntryType.Directory) - { - Mode |= 0b1_000_000_000; - } - UserId = ReadAsciiInt64Base8oldGnu(buffer, 108, 7); GroupId = ReadAsciiInt64Base8oldGnu(buffer, 116, 7); @@ -276,6 +291,13 @@ internal async ValueTask ReadAsync(AsyncBinaryReader reader) } } + pendingMetadata.ApplyTo(this); + + if (entryType == EntryType.Directory) + { + Mode |= 0b1_000_000_000; + } + if (entryType != EntryType.LongName && Name.Length == 0) { return false; @@ -284,19 +306,12 @@ internal async ValueTask ReadAsync(AsyncBinaryReader reader) return true; } - private static async ValueTask ReadBlockAsync(AsyncBinaryReader reader) + private static async ValueTask ReadLengthAsync(AsyncBinaryReader reader, int length) { - var buffer = ArrayPool.Shared.Rent(BLOCK_SIZE); + var buffer = ArrayPool.Shared.Rent(length); try { - await reader.ReadBytesAsync(buffer, 0, BLOCK_SIZE).ConfigureAwait(false); - - if (buffer.Length != 0 && buffer.Length < BLOCK_SIZE) - { - throw new InvalidFormatException("Buffer is invalid size"); - } - - return buffer; + await reader.ReadBytesAsync(buffer, 0, length).ConfigureAwait(false); } finally { @@ -305,45 +320,62 @@ private static async ValueTask ReadBlockAsync(AsyncBinaryReader reader) } private async ValueTask ReadLongNameAsync(AsyncBinaryReader reader, byte[] buffer) + { + var nameBytes = await ReadMetadataPayloadAsync( + reader, + buffer, + MAX_LONG_NAME_SIZE, + "Long name" + ) + .ConfigureAwait(false); + + return ArchiveEncoding.Decode(nameBytes, 0, nameBytes.Length).TrimNulls(); + } + + private async ValueTask ReadPaxMetadataAsync( + AsyncBinaryReader reader, + byte[] buffer, + PaxMetadata pendingMetadata + ) + { + var payload = await ReadMetadataPayloadAsync( + reader, + buffer, + MAX_PAX_HEADER_SIZE, + "PAX header" + ) + .ConfigureAwait(false); + + ParsePaxRecords(payload, pendingMetadata); + } + + private async ValueTask ReadMetadataPayloadAsync( + AsyncBinaryReader reader, + byte[] buffer, + int maxSize, + string payloadName + ) { var size = ReadSize(buffer); // Validate size to prevent memory exhaustion from malformed headers - if (size < 0 || size > MAX_LONG_NAME_SIZE) + if (size < 0 || size > maxSize) { throw new InvalidFormatException( - $"Long name size {size} is invalid or exceeds maximum allowed size of {MAX_LONG_NAME_SIZE} bytes" + $"{payloadName} size {size} is invalid or exceeds maximum allowed size of {maxSize} bytes" ); } - var nameLength = (int)size; - var nameBytes = ArrayPool.Shared.Rent(nameLength); - try - { - await reader.ReadBytesAsync(nameBytes, 0, nameLength).ConfigureAwait(false); - var remainingBytesToRead = BLOCK_SIZE - (nameLength % BLOCK_SIZE); - - // Read the rest of the block and discard the data - if (remainingBytesToRead < BLOCK_SIZE) - { - var remainingBytes = ArrayPool.Shared.Rent(remainingBytesToRead); - try - { - await reader - .ReadBytesAsync(remainingBytes, 0, remainingBytesToRead) - .ConfigureAwait(false); - } - finally - { - ArrayPool.Shared.Return(remainingBytes); - } - } + var payloadLength = (int)size; + var payload = new byte[payloadLength]; + await reader.ReadBytesAsync(payload, 0, payloadLength).ConfigureAwait(false); - return ArchiveEncoding.Decode(nameBytes, 0, nameLength).TrimNulls(); - } - finally + var paddingLength = GetPaddingLength(payloadLength); + if (paddingLength > 0) { - ArrayPool.Shared.Return(nameBytes); + await ReadLengthAsync(reader, paddingLength).ConfigureAwait(false); } + + return payload; } } diff --git a/src/SharpCompress/Common/Tar/Headers/TarHeader.cs b/src/SharpCompress/Common/Tar/Headers/TarHeader.cs index 233970b23..0239b1188 100644 --- a/src/SharpCompress/Common/Tar/Headers/TarHeader.cs +++ b/src/SharpCompress/Common/Tar/Headers/TarHeader.cs @@ -2,6 +2,7 @@ using System.Buffers; using System.Buffers.Binary; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.IO.Compression; using System.Text; @@ -40,6 +41,68 @@ public TarHeader( // Maximum size for long name/link headers to prevent memory exhaustion attacks // This is generous enough for most real-world scenarios (32KB) private const int MAX_LONG_NAME_SIZE = 32768; + private const int MAX_PAX_HEADER_SIZE = 65536; + + internal sealed class PaxMetadata + { + internal string? Name { get; set; } + internal string? LinkName { get; set; } + internal long? Mode { get; set; } + internal long? UserId { get; set; } + internal long? GroupId { get; set; } + internal long? Size { get; set; } + internal DateTime? LastModifiedTime { get; set; } + + internal PaxMetadata Clone() => + new() + { + Name = Name, + LinkName = LinkName, + Mode = Mode, + UserId = UserId, + GroupId = GroupId, + Size = Size, + LastModifiedTime = LastModifiedTime, + }; + + internal void ApplyTo(TarHeader header) + { + if (Name is not null) + { + header.Name = Name; + } + + if (LinkName is not null) + { + header.LinkName = LinkName; + } + + if (Size.HasValue) + { + header.Size = Size.Value; + } + + if (LastModifiedTime.HasValue) + { + header.LastModifiedTime = LastModifiedTime.Value; + } + + if (Mode.HasValue) + { + header.Mode = Mode.Value; + } + + if (UserId.HasValue) + { + header.UserId = UserId.Value; + } + + if (GroupId.HasValue) + { + header.GroupId = GroupId.Value; + } + } + } internal void Write(Stream output) { @@ -233,15 +296,14 @@ private void WriteLongFilenameHeader(Stream output) output.Write(stackalloc byte[numPaddingBytes]); } - internal bool Read(BinaryReader reader) + internal bool Read(BinaryReader reader, PaxMetadata? globalPaxMetadata = null) { - string? longName = null; - string? longLinkName = null; - var hasLongValue = true; + globalPaxMetadata ??= new PaxMetadata(); + var pendingMetadata = globalPaxMetadata.Clone(); byte[] buffer; EntryType entryType; - do + while (true) { buffer = ReadBlock(reader); @@ -256,17 +318,31 @@ internal bool Read(BinaryReader reader) // to apply to the header that follows them. if (entryType == EntryType.LongName) { - longName = ReadLongName(reader, buffer); + pendingMetadata.Name = ReadLongName(reader, buffer); + continue; + } + + if (entryType == EntryType.LongLink) + { + pendingMetadata.LinkName = ReadLongName(reader, buffer); continue; } - else if (entryType == EntryType.LongLink) + + if (entryType == EntryType.LocalExtendedHeader) { - longLinkName = ReadLongName(reader, buffer); + ReadPaxMetadata(reader, buffer, pendingMetadata); continue; } - hasLongValue = false; - } while (hasLongValue); + if (entryType == EntryType.GlobalExtendedHeader) + { + ReadPaxMetadata(reader, buffer, globalPaxMetadata); + pendingMetadata = globalPaxMetadata.Clone(); + continue; + } + + break; + } // Check header checksum if (!checkChecksum(buffer)) @@ -274,23 +350,18 @@ internal bool Read(BinaryReader reader) return false; } - Name = longName ?? ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); + Name = ArchiveEncoding.Decode(buffer, 0, 100).TrimNulls(); EntryType = entryType; Size = ReadSize(buffer); + LinkName = null; // for symlinks, additionally read the linkname if (entryType == EntryType.SymLink || entryType == EntryType.HardLink) { - LinkName = longLinkName ?? ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); + LinkName = ArchiveEncoding.Decode(buffer, 157, 100).TrimNulls(); } Mode = ReadAsciiInt64Base8(buffer, 100, 7); - - if (entryType == EntryType.Directory) - { - Mode |= 0b1_000_000_000; - } - UserId = ReadAsciiInt64Base8oldGnu(buffer, 108, 7); GroupId = ReadAsciiInt64Base8oldGnu(buffer, 116, 7); @@ -309,6 +380,13 @@ internal bool Read(BinaryReader reader) } } + pendingMetadata.ApplyTo(this); + + if (entryType == EntryType.Directory) + { + Mode |= 0b1_000_000_000; + } + if (entryType != EntryType.LongName && Name.Length == 0) { return false; @@ -318,27 +396,237 @@ internal bool Read(BinaryReader reader) } private string ReadLongName(BinaryReader reader, byte[] buffer) + { + var nameBytes = ReadMetadataPayload(reader, buffer, MAX_LONG_NAME_SIZE, "Long name"); + return ArchiveEncoding.Decode(nameBytes, 0, nameBytes.Length).TrimNulls(); + } + + private void ReadPaxMetadata(BinaryReader reader, byte[] buffer, PaxMetadata pendingMetadata) + { + var payload = ReadMetadataPayload(reader, buffer, MAX_PAX_HEADER_SIZE, "PAX header"); + ParsePaxRecords(payload, pendingMetadata); + } + + private byte[] ReadMetadataPayload( + BinaryReader reader, + byte[] buffer, + int maxSize, + string payloadName + ) { var size = ReadSize(buffer); // Validate size to prevent memory exhaustion from malformed headers - if (size < 0 || size > MAX_LONG_NAME_SIZE) + if (size < 0 || size > maxSize) { throw new InvalidFormatException( - $"Long name size {size} is invalid or exceeds maximum allowed size of {MAX_LONG_NAME_SIZE} bytes" + $"{payloadName} size {size} is invalid or exceeds maximum allowed size of {maxSize} bytes" ); } - var nameLength = (int)size; - var nameBytes = reader.ReadBytes(nameLength); - var remainingBytesToRead = BLOCK_SIZE - (nameLength % BLOCK_SIZE); + var payloadLength = (int)size; + var payload = reader.ReadBytes(payloadLength); - // Read the rest of the block and discard the data - if (remainingBytesToRead < BLOCK_SIZE) + if (payload.Length != payloadLength) { - reader.ReadBytes(remainingBytesToRead); + throw new InvalidFormatException($"{payloadName} data is truncated."); + } + + SkipMetadataPadding(reader, payloadLength); + return payload; + } + + private static void SkipMetadataPadding(BinaryReader reader, int payloadLength) + { + var paddingLength = GetPaddingLength(payloadLength); + if (paddingLength == 0) + { + return; + } + + var padding = reader.ReadBytes(paddingLength); + if (padding.Length != paddingLength) + { + throw new InvalidFormatException("Metadata payload padding is truncated."); + } + } + + private static int GetPaddingLength(int payloadLength) + { + var remainder = payloadLength % BLOCK_SIZE; + return remainder == 0 ? 0 : BLOCK_SIZE - remainder; + } + + private static void ParsePaxRecords(byte[] payload, PaxMetadata pendingMetadata) + { + var index = 0; + while (index < payload.Length) + { + var spaceIndex = Array.IndexOf(payload, (byte)' ', index); + if (spaceIndex <= index) + { + throw new InvalidFormatException("Invalid PAX record: missing length separator."); + } + + var recordLength = ParsePaxRecordLength(payload, index, spaceIndex - index); + if (recordLength <= 0 || recordLength > payload.Length - index) + { + throw new InvalidFormatException( + "Invalid PAX record: record length exceeds payload." + ); + } + + var recordEnd = index + recordLength; + if (payload[recordEnd - 1] != (byte)'\n') + { + throw new InvalidFormatException( + "Invalid PAX record: record does not end with newline." + ); + } + + var keyValueStart = spaceIndex + 1; + var keyValueLength = recordEnd - keyValueStart - 1; + var equalsIndex = Array.IndexOf(payload, (byte)'=', keyValueStart, keyValueLength); + if (equalsIndex <= keyValueStart) + { + throw new InvalidFormatException( + "Invalid PAX record: missing key/value separator." + ); + } + + var key = Encoding.UTF8.GetString(payload, keyValueStart, equalsIndex - keyValueStart); + var valueStart = equalsIndex + 1; + var valueLength = recordEnd - valueStart - 1; + var value = Encoding.UTF8.GetString(payload, valueStart, valueLength); + + ApplyPaxKeyValue(pendingMetadata, key, value); + index = recordEnd; + } + } + + private static int ParsePaxRecordLength(byte[] payload, int offset, int length) + { + var lengthText = Encoding.ASCII.GetString(payload, offset, length); + if ( + !int.TryParse( + lengthText, + NumberStyles.None, + CultureInfo.InvariantCulture, + out var value + ) + ) + { + throw new InvalidFormatException($"Invalid PAX record length '{lengthText}'."); + } + + if (value <= 0) + { + throw new InvalidFormatException("Invalid PAX record length: value must be positive."); + } + + return value; + } + + private static void ApplyPaxKeyValue(PaxMetadata pendingMetadata, string key, string value) + { + switch (key) + { + case "path": + pendingMetadata.Name = value; + break; + case "linkpath": + pendingMetadata.LinkName = value; + break; + case "size": + pendingMetadata.Size = ParsePaxInt64(value, key, allowNegative: false); + break; + case "mtime": + pendingMetadata.LastModifiedTime = ParsePaxTimestamp(value, key); + break; + case "uid": + pendingMetadata.UserId = ParsePaxInt64(value, key); + break; + case "gid": + pendingMetadata.GroupId = ParsePaxInt64(value, key); + break; + case "mode": + pendingMetadata.Mode = ParsePaxMode(value); + break; + } + } + + private static long ParsePaxMode(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidFormatException("Invalid PAX value for 'mode': value is empty."); + } + + if (IsOctalDigitsOnly(value)) + { + return Convert.ToInt64(value, 8); + } + + return ParsePaxInt64(value, "mode", allowNegative: false); + } + + private static bool IsOctalDigitsOnly(string value) + { + foreach (var ch in value) + { + if (ch < '0' || ch > '7') + { + return false; + } + } + + return value.Length > 0; + } + + private static long ParsePaxInt64(string value, string key, bool allowNegative = true) + { + if ( + !long.TryParse( + value, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out var parsed + ) + ) + { + throw new InvalidFormatException($"Invalid PAX value for '{key}': '{value}'."); + } + + if (!allowNegative && parsed < 0) + { + throw new InvalidFormatException($"Invalid PAX value for '{key}': '{value}'."); + } + + return parsed; + } + + private static DateTime ParsePaxTimestamp(string value, string key) + { + if ( + !double.TryParse( + value, + NumberStyles.Float, + CultureInfo.InvariantCulture, + out var seconds + ) + ) + { + throw new InvalidFormatException($"Invalid PAX value for '{key}': '{value}'."); + } + + try + { + return EPOCH.AddSeconds(seconds).ToLocalTime(); + } + catch (ArgumentOutOfRangeException ex) + { + throw new InvalidFormatException($"Invalid PAX value for '{key}': '{value}'.", ex); } - return ArchiveEncoding.Decode(nameBytes, 0, nameBytes.Length).TrimNulls(); } private static EntryType ReadEntryType(byte[] buffer) => (EntryType)buffer[156]; diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs index 05d186d06..772a56651 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs @@ -20,13 +20,15 @@ IArchiveEncoding archiveEncoding using var reader = new AsyncBinaryReader(stream, leaveOpen: true); #endif + var globalPaxMetadata = new TarHeader.PaxMetadata(); + while (true) { TarHeader? header = null; try { header = new TarHeader(archiveEncoding); - if (!await header.ReadAsync(reader).ConfigureAwait(false)) + if (!await header.ReadAsync(reader, globalPaxMetadata).ConfigureAwait(false)) { yield break; } diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs index bcd3a5e9d..df71351e9 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.cs @@ -13,6 +13,7 @@ internal static partial class TarHeaderFactory IArchiveEncoding archiveEncoding ) { + var globalPaxMetadata = new TarHeader.PaxMetadata(); while (true) { TarHeader? header = null; @@ -21,7 +22,7 @@ IArchiveEncoding archiveEncoding var reader = new BinaryReader(stream, archiveEncoding.Default, leaveOpen: false); header = new TarHeader(archiveEncoding); - if (!header.Read(reader)) + if (!header.Read(reader, globalPaxMetadata)) { yield break; } diff --git a/src/SharpCompress/Writers/Tar/TarWriter.Async.cs b/src/SharpCompress/Writers/Tar/TarWriter.Async.cs index 76ec17665..7f4b38861 100644 --- a/src/SharpCompress/Writers/Tar/TarWriter.Async.cs +++ b/src/SharpCompress/Writers/Tar/TarWriter.Async.cs @@ -58,7 +58,7 @@ public override async ValueTask WriteDirectoryAsync( return; } - var header = new TarHeader(WriterOptions.ArchiveEncoding); + var header = new TarHeader(WriterOptions.ArchiveEncoding, _headerFormat); header.LastModifiedTime = modificationTime ?? TarHeader.EPOCH; header.Name = normalizedName; header.Size = 0; @@ -96,7 +96,7 @@ public async ValueTask WriteAsync( var realSize = size ?? source.Length; - var header = new TarHeader(WriterOptions.ArchiveEncoding); + var header = new TarHeader(WriterOptions.ArchiveEncoding, _headerFormat); header.LastModifiedTime = modificationTime ?? TarHeader.EPOCH; header.Name = NormalizeFilename(filename); diff --git a/src/SharpCompress/Writers/Tar/TarWriter.cs b/src/SharpCompress/Writers/Tar/TarWriter.cs index 00cdc14a0..4ed20335d 100644 --- a/src/SharpCompress/Writers/Tar/TarWriter.cs +++ b/src/SharpCompress/Writers/Tar/TarWriter.cs @@ -109,7 +109,7 @@ public override void WriteDirectory(string directoryName, DateTime? modification return; // Skip empty or root directory } - var header = new TarHeader(WriterOptions.ArchiveEncoding); + var header = new TarHeader(WriterOptions.ArchiveEncoding, _headerFormat); header.LastModifiedTime = modificationTime ?? TarHeader.EPOCH; header.Name = normalizedName; header.Size = 0; diff --git a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs index 441b6b2cf..684f05c28 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs @@ -25,10 +25,12 @@ public class TarArchiveAsyncTests : ArchiveTests [Fact] public async ValueTask TarArchiveOpenAsyncStream_Throws_On_NonSeekable_Stream() { - using var stream = new ForwardOnlyStream(new MemoryStream()); + using Stream stream = new ForwardOnlyStream( + File.OpenRead(Path.Combine(TEST_ARCHIVES_PATH, "Tar.tar")) + ); - await Assert.ThrowsAsync(() => - TarArchive.OpenAsyncArchive(stream).AsTask() + await Assert.ThrowsAsync(async () => + await TarArchive.OpenAsyncArchive(new AsyncOnlyStream(stream)) ); } @@ -295,4 +297,101 @@ public async ValueTask Tar_Read_One_At_A_Time_Async() Assert.Equal(2, numberOfEntries); } + + [Fact] + public async ValueTask Tar_PaxLocalHeader_Archive_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.tar"); + await using var archive = await TarArchive.OpenAsyncArchive( + new AsyncOnlyStream(File.OpenRead(archivePath)) + ); + + var firstEntry = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "pax/overridden-name.txt"); + Assert.Equal(10, firstEntry.Size); + Assert.Equal(1234, firstEntry.UserID); + Assert.Equal(2345, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + + var expectedTime = DateTimeOffset.FromUnixTimeSeconds(1700000000).LocalDateTime; + Assert.Equal(expectedTime, firstEntry.LastModifiedTime); + + var secondEntry = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "second.txt"); + Assert.Equal(2, secondEntry.Size); + Assert.Equal(11, secondEntry.UserID); + Assert.Equal(22, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("644", 8), secondEntry.Mode); + } + + [Fact] + public async ValueTask Tar_PaxLocalHeader_Link_Archive_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.Link.tar"); + await using var archive = await TarArchive.OpenAsyncArchive( + new AsyncOnlyStream(File.OpenRead(archivePath)) + ); + + var entry = (TarArchiveEntry)await archive.EntriesAsync.SingleAsync(); + Assert.Equal("pax/link-entry", entry.Key); + Assert.Equal("pax/target-entry", entry.LinkTarget); + } + + [Fact] + public async ValueTask Tar_PaxGlobalHeader_Archive_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.tar"); + await using var archive = await TarArchive.OpenAsyncArchive( + new AsyncOnlyStream(File.OpenRead(archivePath)) + ); + + var globalTime = DateTimeOffset.FromUnixTimeSeconds(1700000100).LocalDateTime; + var localOverrideTime = DateTimeOffset.FromUnixTimeSeconds(1700000200).LocalDateTime; + + var firstEntry = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "global-one.txt"); + Assert.Equal(4000, firstEntry.UserID); + Assert.Equal(5000, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + Assert.Equal(globalTime, firstEntry.LastModifiedTime); + + var secondEntry = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => + entry.Key == "global-local-override.txt" + ); + Assert.Equal(4010, secondEntry.UserID); + Assert.Equal(5010, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("600", 8), secondEntry.Mode); + Assert.Equal(localOverrideTime, secondEntry.LastModifiedTime); + + var thirdEntry = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "global-three.txt"); + Assert.Equal(4000, thirdEntry.UserID); + Assert.Equal(5000, thirdEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), thirdEntry.Mode); + Assert.Equal(globalTime, thirdEntry.LastModifiedTime); + } + + [Fact] + public async ValueTask Tar_PaxGlobalHeader_Link_Archive_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.Link.tar"); + await using var archive = await TarArchive.OpenAsyncArchive( + new AsyncOnlyStream(File.OpenRead(archivePath)) + ); + + var globalLink = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "global-link"); + Assert.Equal("global-target", globalLink.LinkTarget); + Assert.Equal(4100, globalLink.UserID); + Assert.Equal(5100, globalLink.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), globalLink.Mode); + + var localOverrideLink = (TarArchiveEntry) + await archive.EntriesAsync.SingleAsync(entry => entry.Key == "local-link-override"); + Assert.Equal("local-target", localOverrideLink.LinkTarget); + Assert.Equal(4100, localOverrideLink.UserID); + Assert.Equal(5100, localOverrideLink.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), localOverrideLink.Mode); + } } diff --git a/tests/SharpCompress.Test/Tar/TarArchiveTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveTests.cs index 5f522e7ce..1ea5b3f83 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveTests.cs @@ -196,6 +196,93 @@ public void Tar_UstarArchivePathReadLongName() ); } + [Fact] + public void Tar_PaxLocalHeader_Archive() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.tar"); + using var archive = TarArchive.OpenArchive(archivePath); + + var firstEntry = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "pax/overridden-name.txt"); + Assert.Equal(10, firstEntry.Size); + Assert.Equal(1234, firstEntry.UserID); + Assert.Equal(2345, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + + var expectedTime = DateTimeOffset.FromUnixTimeSeconds(1700000000).LocalDateTime; + Assert.Equal(expectedTime, firstEntry.LastModifiedTime); + + var secondEntry = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "second.txt"); + Assert.Equal(2, secondEntry.Size); + Assert.Equal(11, secondEntry.UserID); + Assert.Equal(22, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("644", 8), secondEntry.Mode); + } + + [Fact] + public void Tar_PaxLocalHeader_Link_Archive() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.Link.tar"); + using var archive = TarArchive.OpenArchive(archivePath); + + var entry = (TarArchiveEntry)archive.Entries.Single(); + Assert.Equal("pax/link-entry", entry.Key); + Assert.Equal("pax/target-entry", entry.LinkTarget); + } + + [Fact] + public void Tar_PaxGlobalHeader_Archive() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.tar"); + using var archive = TarArchive.OpenArchive(archivePath); + + var globalTime = DateTimeOffset.FromUnixTimeSeconds(1700000100).LocalDateTime; + var localOverrideTime = DateTimeOffset.FromUnixTimeSeconds(1700000200).LocalDateTime; + + var firstEntry = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "global-one.txt"); + Assert.Equal(4000, firstEntry.UserID); + Assert.Equal(5000, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + Assert.Equal(globalTime, firstEntry.LastModifiedTime); + + var secondEntry = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "global-local-override.txt"); + Assert.Equal(4010, secondEntry.UserID); + Assert.Equal(5010, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("600", 8), secondEntry.Mode); + Assert.Equal(localOverrideTime, secondEntry.LastModifiedTime); + + var thirdEntry = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "global-three.txt"); + Assert.Equal(4000, thirdEntry.UserID); + Assert.Equal(5000, thirdEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), thirdEntry.Mode); + Assert.Equal(globalTime, thirdEntry.LastModifiedTime); + } + + [Fact] + public void Tar_PaxGlobalHeader_Link_Archive() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.Link.tar"); + using var archive = TarArchive.OpenArchive(archivePath); + + var globalLink = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "global-link"); + Assert.Equal("global-target", globalLink.LinkTarget); + Assert.Equal(4100, globalLink.UserID); + Assert.Equal(5100, globalLink.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), globalLink.Mode); + + var localOverrideLink = (TarArchiveEntry) + archive.Entries.Single(entry => entry.Key == "local-link-override"); + Assert.Equal("local-target", localOverrideLink.LinkTarget); + Assert.Equal(4100, localOverrideLink.UserID); + Assert.Equal(5100, localOverrideLink.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), localOverrideLink.Mode); + } + [Fact] public void Tar_Create_New() { diff --git a/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs index 07c0b15b4..5d096c966 100644 --- a/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs @@ -3,6 +3,7 @@ using System.IO; using System.Threading.Tasks; using SharpCompress.Common; +using SharpCompress.Common.Tar; using SharpCompress.Factories; using SharpCompress.Readers; using SharpCompress.Readers.Tar; @@ -150,6 +151,153 @@ public async ValueTask Tar_BZip2_Skip_Entry_Stream_Async() Assert.Equal(3, names.Count); } + [Fact] + public async ValueTask Tar_PaxLocalHeader_Reader_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.tar"); + + using Stream stream = File.OpenRead(archivePath); + await using var reader = await TarReader.OpenAsyncReader(stream); + + Assert.True(await reader.MoveToNextEntryAsync()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("pax/overridden-name.txt", firstEntry.Key); + Assert.Equal(10, firstEntry.Size); + Assert.Equal(1234, firstEntry.UserID); + Assert.Equal(2345, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + + var expectedTime = DateTimeOffset.FromUnixTimeSeconds(1700000000).LocalDateTime; + Assert.Equal(expectedTime, firstEntry.LastModifiedTime); + + using (var entryStream = await reader.OpenEntryStreamAsync()) + using (var memoryStream = new MemoryStream()) + { + await entryStream.CopyToAsync(memoryStream); + Assert.Equal(10, memoryStream.Length); + } + + Assert.True(await reader.MoveToNextEntryAsync()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("second.txt", secondEntry.Key); + Assert.Equal(11, secondEntry.UserID); + Assert.Equal(22, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("644", 8), secondEntry.Mode); + Assert.Equal(2, secondEntry.Size); + + Assert.False(await reader.MoveToNextEntryAsync()); + } + + [Fact] + public async ValueTask Tar_PaxLocalHeader_Link_Reader_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.Link.tar"); + + using Stream stream = File.OpenRead(archivePath); + await using var reader = await TarReader.OpenAsyncReader(stream); + + Assert.True(await reader.MoveToNextEntryAsync()); + Assert.Equal("pax/link-entry", reader.Entry.Key); + Assert.Equal("pax/target-entry", reader.Entry.LinkTarget); + Assert.False(reader.Entry.IsDirectory); + Assert.False(await reader.MoveToNextEntryAsync()); + } + + [Fact] + public async ValueTask Tar_PaxGlobalHeader_Reader_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.tar"); + + using Stream stream = File.OpenRead(archivePath); + await using var reader = await TarReader.OpenAsyncReader(stream); + + var globalTime = DateTimeOffset.FromUnixTimeSeconds(1700000100).LocalDateTime; + var localOverrideTime = DateTimeOffset.FromUnixTimeSeconds(1700000200).LocalDateTime; + + Assert.True(await reader.MoveToNextEntryAsync()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("global-one.txt", firstEntry.Key); + Assert.Equal(4000, firstEntry.UserID); + Assert.Equal(5000, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + Assert.Equal(globalTime, firstEntry.LastModifiedTime); + + Assert.True(await reader.MoveToNextEntryAsync()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("global-local-override.txt", secondEntry.Key); + Assert.Equal(4010, secondEntry.UserID); + Assert.Equal(5010, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("600", 8), secondEntry.Mode); + Assert.Equal(localOverrideTime, secondEntry.LastModifiedTime); + + Assert.True(await reader.MoveToNextEntryAsync()); + var thirdEntry = (TarEntry)reader.Entry; + Assert.Equal("global-three.txt", thirdEntry.Key); + Assert.Equal(4000, thirdEntry.UserID); + Assert.Equal(5000, thirdEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), thirdEntry.Mode); + Assert.Equal(globalTime, thirdEntry.LastModifiedTime); + + Assert.False(await reader.MoveToNextEntryAsync()); + } + + [Fact] + public async ValueTask Tar_PaxGlobalHeader_Link_Reader_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.Link.tar"); + + using Stream stream = File.OpenRead(archivePath); + await using var reader = await TarReader.OpenAsyncReader(stream); + + Assert.True(await reader.MoveToNextEntryAsync()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("global-link", firstEntry.Key); + Assert.Equal("global-target", firstEntry.LinkTarget); + Assert.Equal(4100, firstEntry.UserID); + Assert.Equal(5100, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), firstEntry.Mode); + + Assert.True(await reader.MoveToNextEntryAsync()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("local-link-override", secondEntry.Key); + Assert.Equal("local-target", secondEntry.LinkTarget); + Assert.Equal(4100, secondEntry.UserID); + Assert.Equal(5100, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), secondEntry.Mode); + + Assert.False(await reader.MoveToNextEntryAsync()); + } + + [Fact] + public async ValueTask Tar_WithSymlink_Reader_SurfacesLinkTargets_Async() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "TarWithSymlink.tar.gz"); + + using Stream stream = File.OpenRead(archivePath); + await using var reader = await TarReader.OpenAsyncReader(stream); + + var foundVulkanToolsLink = false; + var foundVulkanSamplesLink = false; + + while (await reader.MoveToNextEntryAsync()) + { + if (reader.Entry.Key == "MoltenVK-1.0.21/Demos/LunarG-VulkanSamples/Vulkan-Tools") + { + foundVulkanToolsLink = true; + Assert.Equal("../../External/Vulkan-Tools", reader.Entry.LinkTarget); + } + + if (reader.Entry.Key == "MoltenVK-1.0.21/Demos/LunarG-VulkanSamples/VulkanSamples") + { + foundVulkanSamplesLink = true; + Assert.Equal("../../External/VulkanSamples", reader.Entry.LinkTarget); + } + } + + Assert.True(foundVulkanToolsLink); + Assert.True(foundVulkanSamplesLink); + } + [Fact] public void Tar_Containing_Rar_Reader_Async() { diff --git a/tests/SharpCompress.Test/Tar/TarReaderTests.cs b/tests/SharpCompress.Test/Tar/TarReaderTests.cs index 58fe36f68..34a695c89 100644 --- a/tests/SharpCompress.Test/Tar/TarReaderTests.cs +++ b/tests/SharpCompress.Test/Tar/TarReaderTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using SharpCompress.Common; +using SharpCompress.Common.Tar; using SharpCompress.Compressors.BZip2; using SharpCompress.Factories; using SharpCompress.Readers; @@ -170,6 +171,153 @@ public void Tar_LongNamesWithLongNameExtension() ); } + [Fact] + public void Tar_PaxLocalHeader_Reader() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.tar"); + + using Stream stream = File.OpenRead(archivePath); + using var reader = TarReader.OpenReader(stream); + + Assert.True(reader.MoveToNextEntry()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("pax/overridden-name.txt", firstEntry.Key); + Assert.Equal(10, firstEntry.Size); + Assert.Equal(1234, firstEntry.UserID); + Assert.Equal(2345, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + + var expectedTime = DateTimeOffset.FromUnixTimeSeconds(1700000000).LocalDateTime; + Assert.Equal(expectedTime, firstEntry.LastModifiedTime); + + using (var entryStream = reader.OpenEntryStream()) + using (var memoryStream = new MemoryStream()) + { + entryStream.CopyTo(memoryStream); + Assert.Equal(10, memoryStream.Length); + } + + Assert.True(reader.MoveToNextEntry()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("second.txt", secondEntry.Key); + Assert.Equal(11, secondEntry.UserID); + Assert.Equal(22, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("644", 8), secondEntry.Mode); + Assert.Equal(2, secondEntry.Size); + + Assert.False(reader.MoveToNextEntry()); + } + + [Fact] + public void Tar_PaxLocalHeader_Link_Reader() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxLocalHeader.Link.tar"); + + using Stream stream = File.OpenRead(archivePath); + using var reader = TarReader.OpenReader(stream); + + Assert.True(reader.MoveToNextEntry()); + Assert.Equal("pax/link-entry", reader.Entry.Key); + Assert.Equal("pax/target-entry", reader.Entry.LinkTarget); + Assert.False(reader.Entry.IsDirectory); + Assert.False(reader.MoveToNextEntry()); + } + + [Fact] + public void Tar_PaxGlobalHeader_Reader() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.tar"); + + using Stream stream = File.OpenRead(archivePath); + using var reader = TarReader.OpenReader(stream); + + var globalTime = DateTimeOffset.FromUnixTimeSeconds(1700000100).LocalDateTime; + var localOverrideTime = DateTimeOffset.FromUnixTimeSeconds(1700000200).LocalDateTime; + + Assert.True(reader.MoveToNextEntry()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("global-one.txt", firstEntry.Key); + Assert.Equal(4000, firstEntry.UserID); + Assert.Equal(5000, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), firstEntry.Mode); + Assert.Equal(globalTime, firstEntry.LastModifiedTime); + + Assert.True(reader.MoveToNextEntry()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("global-local-override.txt", secondEntry.Key); + Assert.Equal(4010, secondEntry.UserID); + Assert.Equal(5010, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("600", 8), secondEntry.Mode); + Assert.Equal(localOverrideTime, secondEntry.LastModifiedTime); + + Assert.True(reader.MoveToNextEntry()); + var thirdEntry = (TarEntry)reader.Entry; + Assert.Equal("global-three.txt", thirdEntry.Key); + Assert.Equal(4000, thirdEntry.UserID); + Assert.Equal(5000, thirdEntry.GroupId); + Assert.Equal(Convert.ToInt64("640", 8), thirdEntry.Mode); + Assert.Equal(globalTime, thirdEntry.LastModifiedTime); + + Assert.False(reader.MoveToNextEntry()); + } + + [Fact] + public void Tar_PaxGlobalHeader_Link_Reader() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "Tar.PaxGlobalHeader.Link.tar"); + + using Stream stream = File.OpenRead(archivePath); + using var reader = TarReader.OpenReader(stream); + + Assert.True(reader.MoveToNextEntry()); + var firstEntry = (TarEntry)reader.Entry; + Assert.Equal("global-link", firstEntry.Key); + Assert.Equal("global-target", firstEntry.LinkTarget); + Assert.Equal(4100, firstEntry.UserID); + Assert.Equal(5100, firstEntry.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), firstEntry.Mode); + + Assert.True(reader.MoveToNextEntry()); + var secondEntry = (TarEntry)reader.Entry; + Assert.Equal("local-link-override", secondEntry.Key); + Assert.Equal("local-target", secondEntry.LinkTarget); + Assert.Equal(4100, secondEntry.UserID); + Assert.Equal(5100, secondEntry.GroupId); + Assert.Equal(Convert.ToInt64("777", 8), secondEntry.Mode); + + Assert.False(reader.MoveToNextEntry()); + } + + [Fact] + public void Tar_WithSymlink_Reader_SurfacesLinkTargets() + { + var archivePath = Path.Combine(TEST_ARCHIVES_PATH, "TarWithSymlink.tar.gz"); + + using Stream stream = File.OpenRead(archivePath); + using var reader = TarReader.OpenReader(stream); + + var foundVulkanToolsLink = false; + var foundVulkanSamplesLink = false; + + while (reader.MoveToNextEntry()) + { + if (reader.Entry.Key == "MoltenVK-1.0.21/Demos/LunarG-VulkanSamples/Vulkan-Tools") + { + foundVulkanToolsLink = true; + Assert.Equal("../../External/Vulkan-Tools", reader.Entry.LinkTarget); + } + + if (reader.Entry.Key == "MoltenVK-1.0.21/Demos/LunarG-VulkanSamples/VulkanSamples") + { + foundVulkanSamplesLink = true; + Assert.Equal("../../External/VulkanSamples", reader.Entry.LinkTarget); + } + } + + Assert.True(foundVulkanToolsLink); + Assert.True(foundVulkanSamplesLink); + } + [Fact] public void Tar_BZip2_Skip_Entry_Stream() { diff --git a/tests/SharpCompress.Test/Tar/TarWriterAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarWriterAsyncTests.cs index b21e44392..3a516a049 100644 --- a/tests/SharpCompress.Test/Tar/TarWriterAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarWriterAsyncTests.cs @@ -1,7 +1,10 @@ using System.IO; +using System.Linq; using System.Text; using System.Threading.Tasks; +using SharpCompress.Archives.Tar; using SharpCompress.Common; +using SharpCompress.Common.Tar.Headers; using SharpCompress.Test.Mocks; using SharpCompress.Writers.Tar; using Xunit; @@ -58,6 +61,21 @@ await WriteAsync( ) ); + [Theory] + [InlineData(CompressionType.Xz)] + [InlineData(CompressionType.ZStandard)] + [InlineData(CompressionType.Lzw)] + public async ValueTask Tar_UnsupportedWrapperCompression_Write_Async( + CompressionType compressionType + ) => + await Assert.ThrowsAsync(async () => + await WriteAsync( + compressionType, + "Zip.ppmd.noEmptyDirs.zip", + "Zip.ppmd.noEmptyDirs.zip" + ) + ); + [Theory] [InlineData(true)] [InlineData(false)] @@ -81,4 +99,74 @@ public async ValueTask Tar_Finalize_Archive_Async(bool finalizeArchive) : paddedContentWithHeader; Assert.Equal(expectedStreamLength, stream.Length); } + + [Fact] + public async ValueTask Tar_Ustar_HeaderFormat_WritesShortPath_Async() + { + using var stream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR); + await using (var writer = new TarWriter(new AsyncOnlyStream(stream), options)) + using (var content = new MemoryStream(Encoding.UTF8.GetBytes("hello"))) + { + await writer.WriteAsync("dir/file.txt", content, null); + } + + stream.Position = 0; + using var archive = TarArchive.OpenArchive(stream); + Assert.Single(archive.Entries); + Assert.Equal("dir/file.txt", archive.Entries.Single().Key); + } + + [Fact] + public async ValueTask Tar_Ustar_HeaderFormat_ThrowsForLongPath_Async() + { + var longName = new string('a', 160) + ".txt"; + + using var stream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR); + await using var writer = new TarWriter(new AsyncOnlyStream(stream), options); + using var content = new MemoryStream(Encoding.UTF8.GetBytes("hello")); + + await Assert.ThrowsAsync(async () => + await writer.WriteAsync(longName, content, null) + ); + } + + [Fact] + public async ValueTask Tar_GnuLongLink_HeaderFormat_WritesLongPath_Async() + { + var longName = new string('a', 160) + ".txt"; + + using var stream = new MemoryStream(); + var options = new TarWriterOptions( + CompressionType.None, + true, + TarHeaderWriteFormat.GNU_TAR_LONG_LINK + ); + + await using (var writer = new TarWriter(new AsyncOnlyStream(stream), options)) + using (var content = new MemoryStream(Encoding.UTF8.GetBytes("hello"))) + { + await writer.WriteAsync(longName, content, null); + } + + stream.Position = 0; + using var archive = TarArchive.OpenArchive(stream); + Assert.Single(archive.Entries); + Assert.Equal(longName, archive.Entries.Single().Key); + } + + [Fact] + public async ValueTask Tar_Ustar_HeaderFormat_ThrowsForLongDirectory_Async() + { + var longDirectory = new string('a', 170); + + using var stream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR); + await using var writer = new TarWriter(new AsyncOnlyStream(stream), options); + + await Assert.ThrowsAsync(async () => + await writer.WriteDirectoryAsync(longDirectory, null) + ); + } } diff --git a/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs b/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs index b900f9b59..279fb5112 100644 --- a/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs +++ b/tests/SharpCompress.Test/Tar/TarWriterDirectoryTests.cs @@ -3,6 +3,7 @@ using System.Linq; using SharpCompress.Archives.Tar; using SharpCompress.Common; +using SharpCompress.Common.Tar.Headers; using SharpCompress.Writers.Tar; using Xunit; @@ -161,4 +162,46 @@ public void TarWriter_WriteDirectory_MixedWithFiles() Assert.Equal("dir2/", entries[2].Key); Assert.True(entries[2].IsDirectory); } + + [Fact] + public void TarWriter_WriteDirectory_Ustar_ThrowsForLongDirectoryName() + { + using var memoryStream = new MemoryStream(); + using var writer = new TarWriter( + memoryStream, + new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR) + ); + + var longDirectoryName = new string('a', 170); + Assert.Throws(() => + writer.WriteDirectory(longDirectoryName, DateTime.Now) + ); + } + + [Fact] + public void TarWriter_WriteDirectory_GnuLongLink_WritesLongDirectoryName() + { + var longDirectoryName = new string('a', 170); + + using var memoryStream = new MemoryStream(); + using ( + var writer = new TarWriter( + memoryStream, + new TarWriterOptions( + CompressionType.None, + true, + TarHeaderWriteFormat.GNU_TAR_LONG_LINK + ) + ) + ) + { + writer.WriteDirectory(longDirectoryName, DateTime.Now); + } + + memoryStream.Position = 0; + using var archive = TarArchive.OpenArchive(memoryStream); + var entry = archive.Entries.Single(); + Assert.Equal(longDirectoryName + "/", entry.Key); + Assert.True(entry.IsDirectory); + } } diff --git a/tests/SharpCompress.Test/Tar/TarWriterTests.cs b/tests/SharpCompress.Test/Tar/TarWriterTests.cs index 3f81e0c2f..b4cada920 100644 --- a/tests/SharpCompress.Test/Tar/TarWriterTests.cs +++ b/tests/SharpCompress.Test/Tar/TarWriterTests.cs @@ -1,6 +1,9 @@ using System.IO; +using System.Linq; using System.Text; +using SharpCompress.Archives.Tar; using SharpCompress.Common; +using SharpCompress.Common.Tar.Headers; using SharpCompress.Writers.Tar; using Xunit; @@ -52,6 +55,15 @@ public void Tar_Rar_Write() => Write(CompressionType.Rar, "Zip.ppmd.noEmptyDirs.zip", "Zip.ppmd.noEmptyDirs.zip") ); + [Theory] + [InlineData(CompressionType.Xz)] + [InlineData(CompressionType.ZStandard)] + [InlineData(CompressionType.Lzw)] + public void Tar_UnsupportedWrapperCompression_Write(CompressionType compressionType) => + Assert.Throws(() => + Write(compressionType, "Zip.ppmd.noEmptyDirs.zip", "Zip.ppmd.noEmptyDirs.zip") + ); + [Theory] [InlineData(true)] [InlineData(false)] @@ -75,4 +87,58 @@ public void Tar_Finalize_Archive(bool finalizeArchive) : paddedContentWithHeader; Assert.Equal(expectedStreamLength, stream.Length); } + + [Fact] + public void Tar_Ustar_HeaderFormat_WritesShortPath() + { + using var stream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR); + using (var writer = new TarWriter(stream, options)) + using (var content = new MemoryStream(Encoding.UTF8.GetBytes("hello"))) + { + writer.Write("dir/file.txt", content, null); + } + + stream.Position = 0; + using var archive = TarArchive.OpenArchive(stream); + Assert.Single(archive.Entries); + Assert.Equal("dir/file.txt", archive.Entries.Single().Key); + } + + [Fact] + public void Tar_Ustar_HeaderFormat_ThrowsForLongPath() + { + var longName = new string('a', 160) + ".txt"; + + using var stream = new MemoryStream(); + var options = new TarWriterOptions(CompressionType.None, true, TarHeaderWriteFormat.USTAR); + using var writer = new TarWriter(stream, options); + using var content = new MemoryStream(Encoding.UTF8.GetBytes("hello")); + + Assert.Throws(() => writer.Write(longName, content, null)); + } + + [Fact] + public void Tar_GnuLongLink_HeaderFormat_WritesLongPath() + { + var longName = new string('a', 160) + ".txt"; + + using var stream = new MemoryStream(); + var options = new TarWriterOptions( + CompressionType.None, + true, + TarHeaderWriteFormat.GNU_TAR_LONG_LINK + ); + + using (var writer = new TarWriter(stream, options)) + using (var content = new MemoryStream(Encoding.UTF8.GetBytes("hello"))) + { + writer.Write(longName, content, null); + } + + stream.Position = 0; + using var archive = TarArchive.OpenArchive(stream); + Assert.Single(archive.Entries); + Assert.Equal(longName, archive.Entries.Single().Key); + } } diff --git a/tests/TestArchives/Archives/Tar.PaxGlobalHeader.Link.tar b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.Link.tar new file mode 100644 index 000000000..acab32756 Binary files /dev/null and b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.Link.tar differ diff --git a/tests/TestArchives/Archives/Tar.PaxGlobalHeader.tar b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.tar new file mode 100644 index 000000000..63c1e6a94 Binary files /dev/null and b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.tar differ diff --git a/tests/TestArchives/Archives/Tar.PaxLocalHeader.Link.tar b/tests/TestArchives/Archives/Tar.PaxLocalHeader.Link.tar new file mode 100644 index 000000000..8b68c57f0 Binary files /dev/null and b/tests/TestArchives/Archives/Tar.PaxLocalHeader.Link.tar differ diff --git a/tests/TestArchives/Archives/Tar.PaxLocalHeader.tar b/tests/TestArchives/Archives/Tar.PaxLocalHeader.tar new file mode 100644 index 000000000..94ab4b025 Binary files /dev/null and b/tests/TestArchives/Archives/Tar.PaxLocalHeader.tar differ