diff --git a/docs/TAR_GAP_ANALYSIS.md b/docs/TAR_GAP_ANALYSIS.md index 0bc6c684..e10ffcba 100644 --- a/docs/TAR_GAP_ANALYSIS.md +++ b/docs/TAR_GAP_ANALYSIS.md @@ -21,10 +21,14 @@ Primary references: - `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. +- Sparse handling remains explicitly unsupported. +- Non-modeled PAX keys remain explicitly unsupported. ## Claimed vs Actual Support @@ -50,9 +54,9 @@ Recommended action: ## Read-Path Gaps -### Local PAX headers are implemented; global PAX is still pending +### Local and global PAX headers are implemented for selected keys -Local POSIX PAX extended headers (`x`) are now supported on the read path. +Local (`x`) and global (`g`) POSIX PAX extended headers are now supported on the read path. Supported keys in the current implementation: @@ -66,17 +70,20 @@ Supported keys in the current implementation: Remaining gap: -- global PAX extended headers (`g`) are still not semantically implemented +- non-modeled PAX keys are still ignored +- PAX sparse extensions are still unsupported Recommended action: -- keep local PAX key support documented as implemented -- implement global PAX (`g`) separately when cross-entry metadata state is added +- 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` @@ -89,26 +96,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: +Not currently modeled/supported: -- `TarHeader.Read` does not special-case `GlobalExtendedHeader` -- `TarEntry` does not surface a global-header model -- no tests cover this case - -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 @@ -215,12 +221,12 @@ Tar tests now cover: - long-name failure in USTAR mode - long-name success in GNU mode through sync and async writer paths -### No tests for sparse or global PAX headers +### No tests for sparse tar semantics -Local PAX coverage now exists, but there is still no evidence of coverage for: +Local and global PAX coverage now exists, but there is still no evidence of coverage for: -- global extended headers - sparse tar entries +- sparse PAX extensions Impact: @@ -255,7 +261,7 @@ Missing implementation-specific details include: - GNU long-name and long-link support - USTAR prefix handling - oldgnu numeric quirk handling -- partial PAX support boundaries (local supported, global pending) +- 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 @@ -281,15 +287,10 @@ Recommended action: ### Priority 1 -- Implement and document global PAX (`g`) support -- Decide and document the support position for sparse files -- Decide and document support boundaries for non-modeled PAX keys (`uname`, `gname`, vendor keys) - -### Priority 2 - - 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 diff --git a/docs/TAR_SPEC.md b/docs/TAR_SPEC.md index 83131729..19618cb6 100644 --- a/docs/TAR_SPEC.md +++ b/docs/TAR_SPEC.md @@ -270,6 +270,7 @@ Tar header parsing is implemented in `TarHeader.Read` and `TarHeader.ReadAsync`. | 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 | @@ -318,6 +319,22 @@ Currently supported keys: 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`. @@ -376,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 @@ -394,10 +411,8 @@ This section documents current implementation limits, not desired future behavio ### Read limitations or partial support -- No PAX global extended header (`g`) support -- Local PAX support is limited to selected keys (`path`, `linkpath`, `size`, `mtime`, `uid`, `gid`, `mode`) +- 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 @@ -433,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` diff --git a/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs b/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs index 812a75eb..d39f7342 100644 --- a/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs +++ b/src/SharpCompress/Common/Tar/Headers/TarHeader.Async.cs @@ -200,9 +200,13 @@ internal sealed partial class TarHeader .ConfigureAwait(false); } - internal async ValueTask ReadAsync(AsyncBinaryReader reader) + internal async ValueTask ReadAsync( + AsyncBinaryReader reader, + PaxMetadata? globalPaxMetadata = null + ) { - var pendingMetadata = new PendingTarMetadata(); + globalPaxMetadata ??= new PaxMetadata(); + var pendingMetadata = globalPaxMetadata.Clone(); byte[] buffer; EntryType entryType; @@ -239,6 +243,13 @@ internal sealed partial class TarHeader continue; } + if (entryType == EntryType.GlobalExtendedHeader) + { + await ReadPaxMetadataAsync(reader, buffer, globalPaxMetadata).ConfigureAwait(false); + pendingMetadata = globalPaxMetadata.Clone(); + continue; + } + break; } @@ -278,7 +289,7 @@ internal sealed partial class TarHeader } } - ApplyPendingMetadata(pendingMetadata); + pendingMetadata.ApplyTo(this); if (entryType == EntryType.Directory) { @@ -329,7 +340,7 @@ internal sealed partial class TarHeader private async ValueTask ReadPaxMetadataAsync( AsyncBinaryReader reader, byte[] buffer, - PendingTarMetadata pendingMetadata + PaxMetadata pendingMetadata ) { var payload = await ReadMetadataPayloadAsync( diff --git a/src/SharpCompress/Common/Tar/Headers/TarHeader.cs b/src/SharpCompress/Common/Tar/Headers/TarHeader.cs index 897e5871..a07f61ba 100644 --- a/src/SharpCompress/Common/Tar/Headers/TarHeader.cs +++ b/src/SharpCompress/Common/Tar/Headers/TarHeader.cs @@ -43,7 +43,7 @@ internal sealed partial class TarHeader private const int MAX_LONG_NAME_SIZE = 32768; private const int MAX_PAX_HEADER_SIZE = 65536; - private sealed class PendingTarMetadata + internal sealed class PaxMetadata { internal string? Name { get; set; } internal string? LinkName { get; set; } @@ -52,6 +52,56 @@ internal sealed partial class TarHeader 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) @@ -246,9 +296,10 @@ internal sealed partial class TarHeader output.Write(stackalloc byte[numPaddingBytes]); } - internal bool Read(BinaryReader reader) + internal bool Read(BinaryReader reader, PaxMetadata? globalPaxMetadata = null) { - var pendingMetadata = new PendingTarMetadata(); + globalPaxMetadata ??= new PaxMetadata(); + var pendingMetadata = globalPaxMetadata.Clone(); byte[] buffer; EntryType entryType; @@ -283,6 +334,13 @@ internal sealed partial class TarHeader continue; } + if (entryType == EntryType.GlobalExtendedHeader) + { + ReadPaxMetadata(reader, buffer, globalPaxMetadata); + pendingMetadata = globalPaxMetadata.Clone(); + continue; + } + break; } @@ -322,7 +380,7 @@ internal sealed partial class TarHeader } } - ApplyPendingMetadata(pendingMetadata); + pendingMetadata.ApplyTo(this); if (entryType == EntryType.Directory) { @@ -343,11 +401,7 @@ internal sealed partial class TarHeader return ArchiveEncoding.Decode(nameBytes, 0, nameBytes.Length).TrimNulls(); } - private void ReadPaxMetadata( - BinaryReader reader, - byte[] buffer, - PendingTarMetadata pendingMetadata - ) + private void ReadPaxMetadata(BinaryReader reader, byte[] buffer, PaxMetadata pendingMetadata) { var payload = ReadMetadataPayload(reader, buffer, MAX_PAX_HEADER_SIZE, "PAX header"); ParsePaxRecords(payload, pendingMetadata); @@ -403,7 +457,7 @@ internal sealed partial class TarHeader return remainder == 0 ? 0 : BLOCK_SIZE - remainder; } - private static void ParsePaxRecords(byte[] payload, PendingTarMetadata pendingMetadata) + private static void ParsePaxRecords(byte[] payload, PaxMetadata pendingMetadata) { var index = 0; while (index < payload.Length) @@ -473,11 +527,7 @@ internal sealed partial class TarHeader return value; } - private static void ApplyPaxKeyValue( - PendingTarMetadata pendingMetadata, - string key, - string value - ) + private static void ApplyPaxKeyValue(PaxMetadata pendingMetadata, string key, string value) { switch (key) { @@ -579,44 +629,6 @@ internal sealed partial class TarHeader } } - private void ApplyPendingMetadata(PendingTarMetadata pendingMetadata) - { - if (pendingMetadata.Name is not null) - { - Name = pendingMetadata.Name; - } - - if (pendingMetadata.LinkName is not null) - { - LinkName = pendingMetadata.LinkName; - } - - if (pendingMetadata.Size.HasValue) - { - Size = pendingMetadata.Size.Value; - } - - if (pendingMetadata.LastModifiedTime.HasValue) - { - LastModifiedTime = pendingMetadata.LastModifiedTime.Value; - } - - if (pendingMetadata.Mode.HasValue) - { - Mode = pendingMetadata.Mode.Value; - } - - if (pendingMetadata.UserId.HasValue) - { - UserId = pendingMetadata.UserId.Value; - } - - if (pendingMetadata.GroupId.HasValue) - { - GroupId = pendingMetadata.GroupId.Value; - } - } - private static EntryType ReadEntryType(byte[] buffer) => (EntryType)buffer[156]; private long ReadSize(byte[] buffer) diff --git a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs index b0a316f1..d1f5ccc6 100644 --- a/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs +++ b/src/SharpCompress/Common/Tar/TarHeaderFactory.Async.cs @@ -19,13 +19,15 @@ internal static partial class TarHeaderFactory 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 bcd3a5e9..df71351e 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 @@ internal static partial class TarHeaderFactory 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/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs index fee5cfa1..fdf2b336 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveAsyncTests.cs @@ -314,4 +314,62 @@ public class TarArchiveAsyncTests : ArchiveTests 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 b41a0e40..390a40c5 100644 --- a/tests/SharpCompress.Test/Tar/TarArchiveTests.cs +++ b/tests/SharpCompress.Test/Tar/TarArchiveTests.cs @@ -209,6 +209,58 @@ public class TarArchiveTests : ArchiveTests 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 509ab1cd..5d096c96 100644 --- a/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs +++ b/tests/SharpCompress.Test/Tar/TarReaderAsyncTests.cs @@ -203,6 +203,71 @@ public class TarReaderAsyncTests : ReaderTests 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() { diff --git a/tests/SharpCompress.Test/Tar/TarReaderTests.cs b/tests/SharpCompress.Test/Tar/TarReaderTests.cs index 872d915e..34a695c8 100644 --- a/tests/SharpCompress.Test/Tar/TarReaderTests.cs +++ b/tests/SharpCompress.Test/Tar/TarReaderTests.cs @@ -223,6 +223,71 @@ public class TarReaderTests : ReaderTests 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() { diff --git a/tests/TestArchives/Archives/Tar.PaxGlobalHeader.Link.tar b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.Link.tar new file mode 100644 index 00000000..acab3275 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 00000000..63c1e6a9 Binary files /dev/null and b/tests/TestArchives/Archives/Tar.PaxGlobalHeader.tar differ