Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
fe31852
Initial plan
Copilot Feb 4, 2026
83608fc
Add synchronization between ExtendedAttributes and public properties
Copilot Feb 4, 2026
788b440
Move hardcoded max octal value to use constant
Copilot Feb 4, 2026
c1308ff
Merge test files and deduplicate using Theory with MemberData
Copilot Feb 4, 2026
b263909
Add validation for conflicting path in extended attributes
Copilot Feb 4, 2026
167d364
Remove validation that was causing test failures
Copilot Feb 5, 2026
4fa51fb
Merge branch 'main' into copilot/sync-extended-attributes
stephentoub Feb 8, 2026
46df367
Deduplicate Uid/Gid tests into parameterized Theory methods
Copilot Feb 8, 2026
52df0ef
Fix
rzikm Feb 10, 2026
c4ace66
Minor fix
rzikm Feb 10, 2026
bec3bfb
Clean up test file: remove unused using, commented-out code, and extr…
Copilot Feb 10, 2026
1315ea3
Address code review: use UTF-8 byte length, guard linkpath for link t…
Copilot Feb 10, 2026
8445406
Replace path conflict exception with entryName precedence and update …
Copilot Mar 2, 2026
a43ea6f
Add more tests
rzikm Mar 6, 2026
7253cf8
Merge branch 'main' into copilot/sync-extended-attributes
rzikm Mar 11, 2026
86f7d3d
Address review comments: preserve EA on read, sync path in constructo…
rzikm Mar 11, 2026
8bae5cf
Address code review: consolidate BuildRawPaxArchive methods, remove d…
Copilot Mar 16, 2026
41a4378
Consolidate shared EA logic, parameterize tests, add BuildRawPaxArchi…
Copilot Mar 18, 2026
f6a11b9
Parameterize extraction tests: consolidate path override and size ove…
Copilot Mar 18, 2026
c1d0252
Simplify extraction size test to use InlineData instead of MemberData…
Copilot Mar 18, 2026
e2f6a65
added comment
rzikm Mar 18, 2026
46ee42b
Add comment
rzikm Mar 19, 2026
5bf3a7b
Apply code review suggestion
rzikm Mar 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,13 @@ public PaxTarEntry(TarEntryType entryType, string entryName)
/// <param name="entryType">The type of the entry.</param>
/// <param name="entryName">A string with the path and file name of this entry.</param>
/// <param name="extendedAttributes">An enumeration of string key-value pairs that represents the metadata to include in the Extended Attributes entry that precedes the current entry.</param>
/// <remarks>When creating an instance using the <see cref="PaxTarEntry(TarEntryType, string)"/> constructor, only the following entry types are supported:
/// <remarks><para>When creating an instance using the <see cref="PaxTarEntry(TarEntryType, string, IEnumerable{KeyValuePair{string, string}})"/> constructor, only the following entry types are supported:</para>
/// <list type="bullet">
/// <item>In all platforms: <see cref="TarEntryType.Directory"/>, <see cref="TarEntryType.HardLink"/>, <see cref="TarEntryType.SymbolicLink"/>, <see cref="TarEntryType.RegularFile"/>.</item>
/// <item>In Unix platforms only: <see cref="TarEntryType.BlockDevice"/>, <see cref="TarEntryType.CharacterDevice"/> and <see cref="TarEntryType.Fifo"/>.</item>
/// </list>
/// The specified <paramref name="extendedAttributes"/> are additional attributes to be used for the entry.
/// <para>The specified <paramref name="extendedAttributes"/> are additional attributes to be used for the entry. If any of the provided extended attributes correspond to standard entry properties (such as <c>path</c>, <c>mtime</c>, <c>uid</c>, <c>gid</c>, <c>uname</c>, <c>gname</c>, <c>linkpath</c>, <c>devmajor</c>, or <c>devminor</c>), those values are applied to the corresponding properties. The <paramref name="entryName"/> parameter always takes precedence over a <c>path</c> extended attribute if both are specified.</para>
/// <para>Setting a property after construction will update the corresponding extended attribute.</para>
/// <para>It may include PAX attributes like:</para>
/// <list type="bullet">
/// <item>Access time, under the name <c>atime</c>, as a <see cref="double"/> number.</item>
Expand All @@ -68,7 +69,11 @@ public PaxTarEntry(TarEntryType entryType, string entryName, IEnumerable<KeyValu
ArgumentNullException.ThrowIfNull(extendedAttributes);

_header._prefix = string.Empty;
_header.AddExtendedAttributes(extendedAttributes);
_header.ReplaceNormalAttributesWithExtended(extendedAttributes);

// The entryName parameter takes precedence over a "path" extended attribute.
Comment thread
rzikm marked this conversation as resolved.
_header._name = entryName;
_header.ExtendedAttributes[TarHeader.PaxEaName] = entryName;
}

/// <summary>
Expand All @@ -90,7 +95,7 @@ public PaxTarEntry(TarEntry other)

if (other is PaxTarEntry paxOther)
{
_header.AddExtendedAttributes(paxOther.ExtendedAttributes);
_header.ReplaceNormalAttributesWithExtended(paxOther.ExtendedAttributes);
}
else if (other is GnuTarEntry gnuOther)
{
Expand All @@ -108,7 +113,8 @@ public PaxTarEntry(TarEntry other)
/// <summary>
/// Returns the extended attributes for this entry.
/// </summary>
/// <remarks>The extended attributes are specified when constructing an entry and updated with additional attributes when the entry is written. Use <see cref="PaxTarEntry(TarEntryType, string, IEnumerable{KeyValuePair{string, string}})"/> to append custom extended attributes.
/// <remarks>The extended attributes are specified when constructing an entry. All provided extended attributes are preserved, including those whose values fit within the standard header fields.
/// <para>Setting properties such as <see cref="TarEntry.Name"/>, <see cref="TarEntry.ModificationTime"/>, <see cref="TarEntry.Uid"/>, <see cref="TarEntry.Gid"/>, <see cref="PosixTarEntry.UserName"/>, <see cref="PosixTarEntry.GroupName"/>, <see cref="TarEntry.LinkName"/>, <see cref="PosixTarEntry.DeviceMajor"/>, or <see cref="PosixTarEntry.DeviceMinor"/> will update the corresponding extended attribute to keep properties and extended attributes synchronized.</para>
/// <para>The following common PAX attributes may be included:</para>
/// <list type="bullet">
/// <item>Modification time, under the name <c>mtime</c>, as a <see cref="double"/> number.</item>
Expand All @@ -120,7 +126,7 @@ public PaxTarEntry(TarEntry other)
/// <item>File length, under the name <c>size</c>, as an <see cref="int"/>.</item>
/// </list>
/// </remarks>
public IReadOnlyDictionary<string, string> ExtendedAttributes => field ??= _header.ExtendedAttributes.AsReadOnly();
public IReadOnlyDictionary<string, string> ExtendedAttributes => field ??= _header.GetPopulatedExtendedAttributes().AsReadOnly();

// Determines if the current instance's entry type supports setting a data stream.
internal override bool IsDataStreamSetterSupported() => EntryType == TarEntryType.RegularFile;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ internal PosixTarEntry(TarEntry other, TarEntryFormat format)
/// <summary>
/// When the current entry represents a character device or a block device, the major number identifies the driver associated with the device.
/// </summary>
/// <remarks>Character and block devices are Unix-specific entry types.</remarks>
/// <remarks>Character and block devices are Unix-specific entry types. For PAX entries, setting this property updates the corresponding <c>devmajor</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
/// <exception cref="InvalidOperationException">The entry does not represent a block device or a character device.</exception>
/// <exception cref="ArgumentOutOfRangeException">The value is negative, or larger than 2097151 when using <see cref="TarEntryFormat.V7"/> or <see cref="TarEntryFormat.Ustar"/>.</exception>
public int DeviceMajor
Expand All @@ -64,17 +64,18 @@ public int DeviceMajor
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (FormatIsOctalOnly)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 0x1FFFFF); // 7777777 in octal
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, TarHeader.Octal8ByteFieldMaxValue);
}

_header._devMajor = value;
_header.SyncNumericExtendedAttribute(TarHeader.PaxEaDevMajor, value, TarHeader.Octal8ByteFieldMaxValue);
}
}

/// <summary>
/// When the current entry represents a character device or a block device, the minor number is used by the driver to distinguish individual devices it controls.
/// </summary>
/// <remarks>Character and block devices are Unix-specific entry types.</remarks>
/// <remarks>Character and block devices are Unix-specific entry types. For PAX entries, setting this property updates the corresponding <c>devminor</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
/// <exception cref="InvalidOperationException">The entry does not represent a block device or a character device.</exception>
/// <exception cref="ArgumentOutOfRangeException">The value is negative, or larger than 2097151 when using <see cref="TarEntryFormat.V7"/> or <see cref="TarEntryFormat.Ustar"/>.</exception>
public int DeviceMinor
Expand All @@ -90,32 +91,34 @@ public int DeviceMinor
ArgumentOutOfRangeException.ThrowIfNegative(value);
if (FormatIsOctalOnly)
{
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, 0x1FFFFF); // 7777777 in octal
ArgumentOutOfRangeException.ThrowIfGreaterThan(value, TarHeader.Octal8ByteFieldMaxValue);
}

_header._devMinor = value;
_header.SyncNumericExtendedAttribute(TarHeader.PaxEaDevMinor, value, TarHeader.Octal8ByteFieldMaxValue);
}
}

/// <summary>
/// Represents the name of the group that owns this entry.
/// </summary>
/// <exception cref="ArgumentNullException">Cannot set a null group name.</exception>
/// <remarks><see cref="GroupName"/> is only used in Unix platforms.</remarks>
/// <remarks><see cref="GroupName"/> is only used in Unix platforms. For PAX entries, setting this property updates the corresponding <c>gname</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
public string GroupName
{
get => _header._gName ?? string.Empty;
set
{
ArgumentNullException.ThrowIfNull(value);
_header._gName = value;
_header.SyncStringExtendedAttribute(TarHeader.PaxEaGName, value, FieldLengths.GName);
}
}

/// <summary>
/// Represents the name of the user that owns this entry.
/// </summary>
/// <remarks><see cref="UserName"/> is only used in Unix platforms.</remarks>
/// <remarks><see cref="UserName"/> is only used in Unix platforms. For PAX entries, setting this property updates the corresponding <c>uname</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
/// <exception cref="ArgumentNullException">Cannot set a null user name.</exception>
public string UserName
{
Expand All @@ -124,6 +127,7 @@ public string UserName
{
ArgumentNullException.ThrowIfNull(value);
_header._uName = value;
_header.SyncStringExtendedAttribute(TarHeader.PaxEaUName, value, FieldLengths.UName);
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,17 +84,21 @@ internal TarEntry(TarEntry other, TarEntryFormat format)
/// <summary>
/// The ID of the group that owns the file represented by this entry.
/// </summary>
/// <remarks>This field is only supported in Unix platforms.</remarks>
/// <remarks>This field is only supported in Unix platforms. For PAX entries, setting this property updates the corresponding <c>gid</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
public int Gid
{
get => _header._gid;
set => _header._gid = value;
set
{
_header._gid = value;
_header.SyncNumericExtendedAttribute(TarHeader.PaxEaGid, value, TarHeader.Octal8ByteFieldMaxValue);
}
}

/// <summary>
/// A timestamps that represents the last time the contents of the file represented by this entry were modified.
/// </summary>
/// <remarks>In Unix platforms, this timestamp is commonly known as <c>mtime</c>.</remarks>
/// <remarks>In Unix platforms, this timestamp is commonly known as <c>mtime</c>. For PAX entries, setting this property updates the corresponding <c>mtime</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
/// <exception cref="ArgumentOutOfRangeException">The specified value is larger than <see cref="DateTimeOffset.UnixEpoch"/> when using <see cref="TarEntryFormat.V7"/> or <see cref="TarEntryFormat.Ustar"/>.</exception>
public DateTimeOffset ModificationTime
{
Expand All @@ -106,6 +110,7 @@ public DateTimeOffset ModificationTime
ArgumentOutOfRangeException.ThrowIfLessThan(value, DateTimeOffset.UnixEpoch);
}
_header._mTime = value;
_header.SyncTimestampExtendedAttribute(TarHeader.PaxEaMTime, value);
}
}

Expand All @@ -118,6 +123,7 @@ public DateTimeOffset ModificationTime
/// <summary>
/// When the <see cref="EntryType"/> indicates a <see cref="TarEntryType.SymbolicLink"/> or a <see cref="TarEntryType.HardLink"/>, this property returns the link target path of such link.
/// </summary>
/// <remarks>For PAX entries, setting this property updates the corresponding <c>linkpath</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
/// <exception cref="InvalidOperationException">The entry type is not <see cref="TarEntryType.HardLink"/> or <see cref="TarEntryType.SymbolicLink"/>.</exception>
/// <exception cref="ArgumentNullException">The specified value is <see langword="null"/>.</exception>
/// <exception cref="ArgumentException">The specified value is empty.</exception>
Expand All @@ -132,6 +138,7 @@ public string LinkName
}
ArgumentException.ThrowIfNullOrEmpty(value);
_header._linkName = value;
_header.SyncStringExtendedAttribute(TarHeader.PaxEaLinkName, value);
}
}

Expand All @@ -157,24 +164,30 @@ public UnixFileMode Mode
/// <summary>
/// Represents the name of the entry, which includes the relative path and the filename.
/// </summary>
/// <remarks>For PAX entries, setting this property updates the corresponding <c>path</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
public string Name
{
get => _header._name;
set
{
ArgumentException.ThrowIfNullOrEmpty(value);
_header._name = value;
_header.SyncStringExtendedAttribute(TarHeader.PaxEaName, value);
}
}

/// <summary>
/// The ID of the user that owns the file represented by this entry.
/// </summary>
/// <remarks>This field is only supported in Unix platforms.</remarks>
/// <remarks>This field is only supported in Unix platforms. For PAX entries, setting this property updates the corresponding <c>uid</c> extended attribute in <see cref="PaxTarEntry.ExtendedAttributes"/>.</remarks>
public int Uid
{
get => _header._uid;
set => _header._uid = value;
set
{
_header._uid = value;
_header.SyncNumericExtendedAttribute(TarHeader.PaxEaUid, value, TarHeader.Octal8ByteFieldMaxValue);
}
}

/// <summary>
Expand Down Expand Up @@ -591,10 +604,10 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync)

if (!OperatingSystem.IsWindows())
{
const UnixFileMode OwnershipPermissions =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;
const UnixFileMode OwnershipPermissions =
UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute |
UnixFileMode.GroupRead | UnixFileMode.GroupWrite | UnixFileMode.GroupExecute |
UnixFileMode.OtherRead | UnixFileMode.OtherWrite | UnixFileMode.OtherExecute;

// Restore permissions.
// For security, limit to ownership permissions, and respect umask (through UnixCreateMode).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,24 @@ internal sealed partial class TarHeader
// If any of the dictionary entries use the name of a standard attribute, that attribute's value gets replaced with the one from the dictionary.
// Unlike the historic header, numeric values in extended attributes are stored using decimal, not octal.
// Throws if any conversion from string to the expected data type fails.
internal void ReplaceNormalAttributesWithExtended(Dictionary<string, string>? dictionaryFromExtendedAttributesHeader)
internal void ReplaceNormalAttributesWithExtended(IEnumerable<KeyValuePair<string, string>>? extendedAttributes)
{
if (dictionaryFromExtendedAttributesHeader == null || dictionaryFromExtendedAttributesHeader.Count == 0)
if (extendedAttributes == null)
{
return;
}

AddExtendedAttributes(dictionaryFromExtendedAttributesHeader);
AddExtendedAttributes(extendedAttributes);
Comment thread
rzikm marked this conversation as resolved.

// Find all the extended attributes with known names and save them in the expected standard attribute.
if (_ea == null || _ea.Count == 0)
{
// no extended attributes were added, so we can skip the rest of the processing
return;
}

// Find all the extended attributes with known names and save them
// in the expected standard attribute. Extended attributes are preserved
// as-is to avoid data loss during roundtripping.

Comment thread
rzikm marked this conversation as resolved.
// The 'name' header field only fits 100 bytes, so we always store the full name text to the dictionary.
if (ExtendedAttributes.TryGetValue(PaxEaName, out string? paxEaName))
Expand All @@ -121,7 +129,9 @@ internal void ReplaceNormalAttributesWithExtended(Dictionary<string, string>? di
}

// The 'linkName' header field only fits 100 bytes, so we always store the full linkName text to the dictionary.
if (ExtendedAttributes.TryGetValue(PaxEaLinkName, out string? paxEaLinkName))
// Only apply to link entries to avoid setting _linkName on non-link entry types.
if (_typeFlag is TarEntryType.HardLink or TarEntryType.SymbolicLink &&
Comment thread
rzikm marked this conversation as resolved.
ExtendedAttributes.TryGetValue(PaxEaLinkName, out string? paxEaLinkName))
{
_linkName = paxEaLinkName;
}
Expand Down
Loading
Loading