diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs index d8edb5b5f03602..f37ee088b800fa 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PaxTarEntry.cs @@ -46,12 +46,13 @@ public PaxTarEntry(TarEntryType entryType, string entryName) /// The type of the entry. /// A string with the path and file name of this entry. /// An enumeration of string key-value pairs that represents the metadata to include in the Extended Attributes entry that precedes the current entry. - /// When creating an instance using the constructor, only the following entry types are supported: + /// When creating an instance using the constructor, only the following entry types are supported: /// /// In all platforms: , , , . /// In Unix platforms only: , and . /// - /// The specified are additional attributes to be used for the entry. + /// The specified are additional attributes to be used for the entry. If any of the provided extended attributes correspond to standard entry properties (such as path, mtime, uid, gid, uname, gname, linkpath, devmajor, or devminor), those values are applied to the corresponding properties. The parameter always takes precedence over a path extended attribute if both are specified. + /// Setting a property after construction will update the corresponding extended attribute. /// It may include PAX attributes like: /// /// Access time, under the name atime, as a number. @@ -68,7 +69,11 @@ public PaxTarEntry(TarEntryType entryType, string entryName, IEnumerable @@ -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) { @@ -108,7 +113,8 @@ public PaxTarEntry(TarEntry other) /// /// Returns the extended attributes for this entry. /// - /// The extended attributes are specified when constructing an entry and updated with additional attributes when the entry is written. Use to append custom extended attributes. + /// 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. + /// Setting properties such as , , , , , , , , or will update the corresponding extended attribute to keep properties and extended attributes synchronized. /// The following common PAX attributes may be included: /// /// Modification time, under the name mtime, as a number. @@ -120,7 +126,7 @@ public PaxTarEntry(TarEntry other) /// File length, under the name size, as an . /// /// - public IReadOnlyDictionary ExtendedAttributes => field ??= _header.ExtendedAttributes.AsReadOnly(); + public IReadOnlyDictionary 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; diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs index 72d1396f83beed..0187aadd7b9865 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/PosixTarEntry.cs @@ -48,7 +48,7 @@ internal PosixTarEntry(TarEntry other, TarEntryFormat format) /// /// When the current entry represents a character device or a block device, the major number identifies the driver associated with the device. /// - /// Character and block devices are Unix-specific entry types. + /// Character and block devices are Unix-specific entry types. For PAX entries, setting this property updates the corresponding devmajor extended attribute in . /// The entry does not represent a block device or a character device. /// The value is negative, or larger than 2097151 when using or . public int DeviceMajor @@ -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); } } /// /// 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. /// - /// Character and block devices are Unix-specific entry types. + /// Character and block devices are Unix-specific entry types. For PAX entries, setting this property updates the corresponding devminor extended attribute in . /// The entry does not represent a block device or a character device. /// The value is negative, or larger than 2097151 when using or . public int DeviceMinor @@ -90,10 +91,11 @@ 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); } } @@ -101,7 +103,7 @@ public int DeviceMinor /// Represents the name of the group that owns this entry. /// /// Cannot set a null group name. - /// is only used in Unix platforms. + /// is only used in Unix platforms. For PAX entries, setting this property updates the corresponding gname extended attribute in . public string GroupName { get => _header._gName ?? string.Empty; @@ -109,13 +111,14 @@ public string GroupName { ArgumentNullException.ThrowIfNull(value); _header._gName = value; + _header.SyncStringExtendedAttribute(TarHeader.PaxEaGName, value, FieldLengths.GName); } } /// /// Represents the name of the user that owns this entry. /// - /// is only used in Unix platforms. + /// is only used in Unix platforms. For PAX entries, setting this property updates the corresponding uname extended attribute in . /// Cannot set a null user name. public string UserName { @@ -124,6 +127,7 @@ public string UserName { ArgumentNullException.ThrowIfNull(value); _header._uName = value; + _header.SyncStringExtendedAttribute(TarHeader.PaxEaUName, value, FieldLengths.UName); } } } diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs index 6e8382552e4d4b..50d0543dea5aa8 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarEntry.cs @@ -84,17 +84,21 @@ internal TarEntry(TarEntry other, TarEntryFormat format) /// /// The ID of the group that owns the file represented by this entry. /// - /// This field is only supported in Unix platforms. + /// This field is only supported in Unix platforms. For PAX entries, setting this property updates the corresponding gid extended attribute in . public int Gid { get => _header._gid; - set => _header._gid = value; + set + { + _header._gid = value; + _header.SyncNumericExtendedAttribute(TarHeader.PaxEaGid, value, TarHeader.Octal8ByteFieldMaxValue); + } } /// /// A timestamps that represents the last time the contents of the file represented by this entry were modified. /// - /// In Unix platforms, this timestamp is commonly known as mtime. + /// In Unix platforms, this timestamp is commonly known as mtime. For PAX entries, setting this property updates the corresponding mtime extended attribute in . /// The specified value is larger than when using or . public DateTimeOffset ModificationTime { @@ -106,6 +110,7 @@ public DateTimeOffset ModificationTime ArgumentOutOfRangeException.ThrowIfLessThan(value, DateTimeOffset.UnixEpoch); } _header._mTime = value; + _header.SyncTimestampExtendedAttribute(TarHeader.PaxEaMTime, value); } } @@ -118,6 +123,7 @@ public DateTimeOffset ModificationTime /// /// When the indicates a or a , this property returns the link target path of such link. /// + /// For PAX entries, setting this property updates the corresponding linkpath extended attribute in . /// The entry type is not or . /// The specified value is . /// The specified value is empty. @@ -132,6 +138,7 @@ public string LinkName } ArgumentException.ThrowIfNullOrEmpty(value); _header._linkName = value; + _header.SyncStringExtendedAttribute(TarHeader.PaxEaLinkName, value); } } @@ -157,6 +164,7 @@ public UnixFileMode Mode /// /// Represents the name of the entry, which includes the relative path and the filename. /// + /// For PAX entries, setting this property updates the corresponding path extended attribute in . public string Name { get => _header._name; @@ -164,17 +172,22 @@ public string Name { ArgumentException.ThrowIfNullOrEmpty(value); _header._name = value; + _header.SyncStringExtendedAttribute(TarHeader.PaxEaName, value); } } /// /// The ID of the user that owns the file represented by this entry. /// - /// This field is only supported in Unix platforms. + /// This field is only supported in Unix platforms. For PAX entries, setting this property updates the corresponding uid extended attribute in . public int Uid { get => _header._uid; - set => _header._uid = value; + set + { + _header._uid = value; + _header.SyncNumericExtendedAttribute(TarHeader.PaxEaUid, value, TarHeader.Octal8ByteFieldMaxValue); + } } /// @@ -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). diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs index c39df542aa88d0..feae728117ab8a 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Read.cs @@ -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? dictionaryFromExtendedAttributesHeader) + internal void ReplaceNormalAttributesWithExtended(IEnumerable>? extendedAttributes) { - if (dictionaryFromExtendedAttributesHeader == null || dictionaryFromExtendedAttributesHeader.Count == 0) + if (extendedAttributes == null) { return; } - AddExtendedAttributes(dictionaryFromExtendedAttributesHeader); + AddExtendedAttributes(extendedAttributes); - // 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. // 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)) @@ -121,7 +129,9 @@ internal void ReplaceNormalAttributesWithExtended(Dictionary? 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 && + ExtendedAttributes.TryGetValue(PaxEaLinkName, out string? paxEaLinkName)) { _linkName = paxEaLinkName; } diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs index 8a4ee54bac4f9f..06c9d8ea52a459 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.Write.cs @@ -17,8 +17,8 @@ namespace System.Formats.Tar // Writes header attributes of a tar archive entry. internal sealed partial class TarHeader { - private const long Octal12ByteFieldMaxValue = (1L << (3 * 11)) - 1; // Max value of 11 octal digits. - private const int Octal8ByteFieldMaxValue = (1 << (3 * 7)) - 1; // Max value of 7 octal digits. + internal const long Octal12ByteFieldMaxValue = (1L << (3 * 11)) - 1; // Max value of 11 octal digits. + internal const int Octal8ByteFieldMaxValue = (1 << (3 * 7)) - 1; // Max value of 7 octal digits. private static ReadOnlySpan UstarMagicBytes => "ustar\0"u8; private static ReadOnlySpan UstarVersionBytes => "00"u8; @@ -942,75 +942,14 @@ static int CountDigits(int value) // extended attributes. They get collected and saved in that dictionary, with no restrictions. private void CollectExtendedAttributesFromStandardFieldsIfNeeded() { - ExtendedAttributes[PaxEaName] = _name; - ExtendedAttributes[PaxEaMTime] = TarHelpers.GetTimestampStringFromDateTimeOffset(_mTime); - - TryAddStringField(ExtendedAttributes, PaxEaGName, _gName, FieldLengths.GName); - TryAddStringField(ExtendedAttributes, PaxEaUName, _uName, FieldLengths.UName); - - if (!string.IsNullOrEmpty(_linkName)) - { - Debug.Assert(_typeFlag is TarEntryType.SymbolicLink or TarEntryType.HardLink); - ExtendedAttributes[PaxEaLinkName] = _linkName; - } - - if (_size > Octal12ByteFieldMaxValue) - { - ExtendedAttributes[PaxEaSize] = _size.ToString(); - } - else - { - ExtendedAttributes.Remove(PaxEaSize); - } - - if (_uid > Octal8ByteFieldMaxValue) - { - ExtendedAttributes[PaxEaUid] = _uid.ToString(); - } - else - { - ExtendedAttributes.Remove(PaxEaUid); - } - - if (_gid > Octal8ByteFieldMaxValue) - { - ExtendedAttributes[PaxEaGid] = _gid.ToString(); - } - else - { - ExtendedAttributes.Remove(PaxEaGid); - } - - if (_devMajor > Octal8ByteFieldMaxValue) - { - ExtendedAttributes[PaxEaDevMajor] = _devMajor.ToString(); - } - else - { - ExtendedAttributes.Remove(PaxEaDevMajor); - } - - if (_devMinor > Octal8ByteFieldMaxValue) - { - ExtendedAttributes[PaxEaDevMinor] = _devMinor.ToString(); - } - else - { - ExtendedAttributes.Remove(PaxEaDevMinor); - } + CollectExtendedAttributesFromStandardFieldsIfNeeded(_ea ??= new Dictionary()); + } - // Sets the specified string to the dictionary if it's longer than the specified max byte length; otherwise, remove it. - static void TryAddStringField(Dictionary extendedAttributes, string key, string? value, int maxLength) - { - if (string.IsNullOrEmpty(value) || GetUtf8TextLength(value) <= maxLength) - { - extendedAttributes.Remove(key); - } - else - { - extendedAttributes[key] = value; - } - } + // At write time, we both add and remove entries to ensure the EA dictionary + // is fully normalized. Delegates to the shared helper with removeIfUnneeded: true. + private void CollectExtendedAttributesFromStandardFieldsIfNeeded(Dictionary ea) + { + AddOrUpdateStandardFieldExtendedAttributes(ea, removeIfUnneeded: true); } // The checksum accumulator first adds up the byte values of eight space chars, then the final number diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs index 35da5b566ac37f..593f227c00de63 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarHeader.cs @@ -25,19 +25,19 @@ internal sealed partial class TarHeader private const string GnuVersion = " \0"; // Names of PAX extended attributes commonly found fields - private const string PaxEaName = "path"; - private const string PaxEaLinkName = "linkpath"; - private const string PaxEaMode = "mode"; - private const string PaxEaGName = "gname"; - private const string PaxEaUName = "uname"; - private const string PaxEaGid = "gid"; - private const string PaxEaUid = "uid"; + internal const string PaxEaName = "path"; + internal const string PaxEaLinkName = "linkpath"; + internal const string PaxEaMode = "mode"; + internal const string PaxEaGName = "gname"; + internal const string PaxEaUName = "uname"; + internal const string PaxEaGid = "gid"; + internal const string PaxEaUid = "uid"; internal const string PaxEaATime = "atime"; internal const string PaxEaCTime = "ctime"; - private const string PaxEaMTime = "mtime"; - private const string PaxEaSize = "size"; - private const string PaxEaDevMajor = "devmajor"; - private const string PaxEaDevMinor = "devminor"; + internal const string PaxEaMTime = "mtime"; + internal const string PaxEaSize = "size"; + internal const string PaxEaDevMajor = "devmajor"; + internal const string PaxEaDevMinor = "devminor"; internal Stream? _dataStream; internal long _dataOffset; @@ -152,5 +152,118 @@ internal void AddExtendedAttributes(IEnumerable> ex // if the archive stream is seekable. Otherwise, -1. private static void SetDataOffset(TarHeader header, Stream archiveStream) => header._dataOffset = archiveStream.CanSeek ? archiveStream.Position : -1; + + // Synchronizes the extended attributes dictionary with the value of a property. + // Only updates if the format is PAX and the ExtendedAttributes dictionary has been initialized. + // When maxUtf8ByteLength is 0 (default), the value is always added to EA if non-empty. + // This is intentional for "path" and "linkpath" which always belong in extended attributes. + internal void SyncStringExtendedAttribute(string key, string? value, int maxUtf8ByteLength = 0) + { + if (_format == TarEntryFormat.Pax && _ea is not null) + { + if (!string.IsNullOrEmpty(value) && GetUtf8TextLength(value) > maxUtf8ByteLength) + { + _ea[key] = value; + } + else + { + _ea.Remove(key); + } + } + } + + // Synchronizes the extended attributes dictionary with a timestamp property. + // Only updates if the format is PAX and the ExtendedAttributes dictionary has been initialized. + internal void SyncTimestampExtendedAttribute(string key, DateTimeOffset value) + { + if (_format == TarEntryFormat.Pax && _ea is not null) + { + _ea[key] = TarHelpers.GetTimestampStringFromDateTimeOffset(value); + } + } + + // Synchronizes the extended attributes dictionary with a numeric property. + // Only updates if the format is PAX and the ExtendedAttributes dictionary has been initialized. + // Uses the same logic as CollectExtendedAttributesFromStandardFieldsIfNeeded to determine + // whether to add or remove the attribute. + internal void SyncNumericExtendedAttribute(string key, int value, int maxNonextendedValue) + { + if (_format == TarEntryFormat.Pax && _ea is not null) + { + if (value > maxNonextendedValue) + { + _ea[key] = value.ToString(); + } + else + { + _ea.Remove(key); + } + } + } + + internal Dictionary GetPopulatedExtendedAttributes() + { + PopulateExtendedAttributesFromStandardFields(ExtendedAttributes); + return ExtendedAttributes; + } + + // Ensures standard fields are present in the extended attributes dictionary + // without removing any existing entries. Used for populating the EA dictionary + // for read access. Delegates to the shared helper with removeIfUnneeded: false. + private void PopulateExtendedAttributesFromStandardFields(Dictionary ea) + { + AddOrUpdateStandardFieldExtendedAttributes(ea, removeIfUnneeded: false); + } + + // Shared helper that adds standard header field values to extended attributes. + // When removeIfUnneeded is true (write-time), entries that fit in standard fields + // are removed from the dictionary. When false (read-time/populate), only entries + // that exceed standard field capacity are added — existing keys are never removed. + private void AddOrUpdateStandardFieldExtendedAttributes(Dictionary ea, bool removeIfUnneeded) + { + ea[PaxEaName] = _name; + ea[PaxEaMTime] = TarHelpers.GetTimestampStringFromDateTimeOffset(_mTime); + + AddOrRemoveStringField(ea, PaxEaGName, _gName, FieldLengths.GName, removeIfUnneeded); + AddOrRemoveStringField(ea, PaxEaUName, _uName, FieldLengths.UName, removeIfUnneeded); + + if (!string.IsNullOrEmpty(_linkName)) + { + // The LinkName is stored unconditionally (not doing so might + // break users depending on existing behavior). + Debug.Assert(_typeFlag is TarEntryType.SymbolicLink or TarEntryType.HardLink); + ea[PaxEaLinkName] = _linkName; + } + + AddOrRemoveNumericField(ea, PaxEaSize, _size, Octal12ByteFieldMaxValue, removeIfUnneeded); + AddOrRemoveNumericField(ea, PaxEaUid, _uid, Octal8ByteFieldMaxValue, removeIfUnneeded); + AddOrRemoveNumericField(ea, PaxEaGid, _gid, Octal8ByteFieldMaxValue, removeIfUnneeded); + AddOrRemoveNumericField(ea, PaxEaDevMajor, _devMajor, Octal8ByteFieldMaxValue, removeIfUnneeded); + AddOrRemoveNumericField(ea, PaxEaDevMinor, _devMinor, Octal8ByteFieldMaxValue, removeIfUnneeded); + + static void AddOrRemoveStringField(Dictionary ea, string key, string? value, int maxUtf8ByteLength, bool removeIfUnneeded) + { + if (!string.IsNullOrEmpty(value) && GetUtf8TextLength(value) > maxUtf8ByteLength) + { + ea[key] = value; + } + else if (removeIfUnneeded) + { + ea.Remove(key); + } + } + + static void AddOrRemoveNumericField(Dictionary ea, string key, long value, long maxNonextendedValue, bool removeIfUnneeded) + { + if (value > maxNonextendedValue) + { + ea[key] = value.ToString(); + } + else if (removeIfUnneeded) + { + ea.Remove(key); + } + } + } } } diff --git a/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj b/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj index 8f85711e7eaf3f..11085c750251fc 100644 --- a/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj +++ b/src/libraries/System.Formats.Tar/tests/System.Formats.Tar.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/src/libraries/System.Formats.Tar/tests/TarEntry/PaxTarEntry.ExtendedAttributes.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarEntry/PaxTarEntry.ExtendedAttributes.Tests.cs new file mode 100644 index 00000000000000..f0c0c99900f905 --- /dev/null +++ b/src/libraries/System.Formats.Tar/tests/TarEntry/PaxTarEntry.ExtendedAttributes.Tests.cs @@ -0,0 +1,296 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.IO; +using Xunit; + +namespace System.Formats.Tar.Tests +{ + public class PaxTarEntry_ExtendedAttributes_Tests : TarTestsBase + { + // helper to create getters and setters for test data + static (Func getter, Action setter) Accessors(Func getter, Action setter) => + (entry => getter(entry), (entry, value) => setter(entry, (T)value)); + + public static IEnumerable PropertyUpdateData() + { + yield return new object[] { "gname", "OldGroup", new string('a', 33), TarEntryType.RegularFile, Accessors(e => e.GroupName, (e, v) => e.GroupName = v) }; + yield return new object[] { "uname", "OldUser", new string('a', 33), TarEntryType.RegularFile, Accessors(e => e.UserName, (e, v) => e.UserName = v) }; + yield return new object[] { "uid", 10, 3000000, TarEntryType.RegularFile, Accessors(e => e.Uid, (e, v) => e.Uid = v) }; + yield return new object[] { "gid", 10, 3000000, TarEntryType.RegularFile, Accessors(e => e.Gid, (e, v) => e.Gid = v) }; + yield return new object[] { "devmajor", 10, 3000000, TarEntryType.BlockDevice, Accessors(e => e.DeviceMajor, (e, v) => e.DeviceMajor = v) }; + yield return new object[] { "devminor", 10, 3000000, TarEntryType.BlockDevice, Accessors(e => e.DeviceMinor, (e, v) => e.DeviceMinor = v) }; + } + + [Theory] + [MemberData(nameof(PropertyUpdateData))] + public void Property_Setter_ShouldUpdateExtendedAttributes(string extAttrKey, object smallValue, object largeValue, TarEntryType entryType, (Func getter, Action setter) accessors) + { + Dictionary extendedAttributes = new Dictionary() + { + [extAttrKey] = smallValue.ToString() + }; + + PaxTarEntry entry = new PaxTarEntry(entryType, "test.txt", extendedAttributes); + + // Small value fits in the regular header field, but EA is still preserved from construction + Assert.Equal(smallValue, accessors.getter(entry)); + Assert.True(entry.ExtendedAttributes.ContainsKey(extAttrKey)); + Assert.Equal(smallValue.ToString(), entry.ExtendedAttributes[extAttrKey]); + + // Set property value to the larger value that requires using ExtendedAttributes + accessors.setter(entry, largeValue); + Assert.Equal(largeValue, accessors.getter(entry)); + Assert.True(entry.ExtendedAttributes.ContainsKey(extAttrKey)); + Assert.Equal(largeValue.ToString(), entry.ExtendedAttributes[extAttrKey]); + + // Set property value back to the smaller value that fits in the regular header field, which should remove it from ExtendedAttributes + accessors.setter(entry, smallValue); + Assert.Equal(smallValue, accessors.getter(entry)); + Assert.False(entry.ExtendedAttributes.ContainsKey(extAttrKey)); + } + + public static IEnumerable PropertySetData() + { + yield return new object[] { "linkpath", "./newpath", "./newpath", TarEntryType.SymbolicLink, Accessors(e => e.LinkName, (e, v) => e.LinkName = v) }; + yield return new object[] { "mtime", DateTimeOffset.FromUnixTimeSeconds(9876543210), "9876543210", TarEntryType.RegularFile, Accessors(e => e.ModificationTime, (e, v) => e.ModificationTime = v) }; + } + + [Theory] + [MemberData(nameof(PropertySetData))] + public void PersistentProperty_ShouldUpdateWhenPropertyIsSet(string extAttrKey, object value, string valueAsString, TarEntryType entryType, (Func getter, Action setter) accessors) + { + PaxTarEntry entry = new PaxTarEntry(entryType, "./test.txt"); + + accessors.setter(entry, value); + Assert.Equal(value, accessors.getter(entry)); + + Assert.True(entry.ExtendedAttributes.ContainsKey(extAttrKey)); + Assert.Equal(valueAsString, entry.ExtendedAttributes[extAttrKey]); + + entry = new PaxTarEntry(entryType, "./test.txt", new Dictionary() + { + [extAttrKey] = valueAsString + }); + + Assert.True(entry.ExtendedAttributes.ContainsKey(extAttrKey)); + Assert.Equal(valueAsString, entry.ExtendedAttributes[extAttrKey]); + } + + [Fact] + public void Constructor_WithConflictingPathExtendedAttribute_ShouldUseEntryName() + { + Dictionary extendedAttributes = new Dictionary + { + { "path", "different.txt" } + }; + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, "test.txt", extendedAttributes); + + Assert.Equal("test.txt", entry.Name); + Assert.True(entry.ExtendedAttributes.ContainsKey("path")); + Assert.Equal("test.txt", entry.ExtendedAttributes["path"]); + } + + [Fact] + public void Constructor_WithMatchingExtendedAttributes_ShouldSucceed() + { + Dictionary extendedAttributes = new Dictionary + { + { "path", "test.txt" } + }; + + PaxTarEntry entry = new PaxTarEntry(TarEntryType.RegularFile, "test.txt", extendedAttributes); + + Assert.True(entry.ExtendedAttributes.ContainsKey("path")); + Assert.Equal("test.txt", entry.ExtendedAttributes["path"]); + } + + [Fact] + public void SyncAfterRead_ChangeProperty_ExtendedAttributeReflectsNewValue() + { + MemoryStream ms = new(); + using (TarWriter writer = new(ms, TarEntryFormat.Pax, leaveOpen: true)) + { + PaxTarEntry writeEntry = new PaxTarEntry(TarEntryType.RegularFile, "file.txt"); + writeEntry.Uid = 1000; + writer.WriteEntry(writeEntry); + } + + ms.Position = 0; + using TarReader reader = new(ms); + PaxTarEntry readEntry = Assert.IsType(reader.GetNextEntry()); + + Assert.Equal(1000, readEntry.Uid); + + // Change property after reading + readEntry.Uid = 5000000; + Assert.Equal(5000000, readEntry.Uid); + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaUid)); + Assert.Equal("5000000", readEntry.ExtendedAttributes[PaxEaUid]); + + // Change name after reading + readEntry.Name = "renamed.txt"; + Assert.Equal("renamed.txt", readEntry.Name); + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaName)); + Assert.Equal("renamed.txt", readEntry.ExtendedAttributes[PaxEaName]); + } + + // Helper to build a raw PAX archive MemoryStream from EA data and header fields. + // Provides a seekable MemoryStream positioned at 0, ready for TarReader. + private static MemoryStream BuildRawPaxArchiveStream( + string headerName, + Dictionary extraEAs = null, + int uid = 0, int gid = 0, + long mtime = 1700000000, + bool includePathInEA = true) + { + var ms = new MemoryStream(); + byte[] eaData; + if (includePathInEA) + { + eaData = BuildRawPaxExtendedAttributeData(headerName, extraEAs); + } + else + { + var sb = new System.Text.StringBuilder(); + if (extraEAs is not null) + { + foreach (var kvp in extraEAs) + { + AppendRawPaxExtendedAttributeRecord(sb, kvp.Key, kvp.Value); + } + } + eaData = System.Text.Encoding.UTF8.GetBytes(sb.ToString()); + } + + WriteRawTarHeader(ms, "PaxHeaders.0/entry", 0, 0, 0, eaData.Length, 0, 'x', ""); + ms.Write(eaData); + PadToTarBlockBoundary(ms); + + WriteRawTarHeader(ms, headerName, Convert.ToInt32("644", 8), uid, gid, 0, mtime, '0', ""); + PadToTarBlockBoundary(ms); + + ms.Write(new byte[1024]); + ms.Position = 0; + return ms; + } + + [Fact] + public void EAPreservationOnRead_StandardFieldEAs_StillVisibleInExtendedAttributes() + { + var extraEAs = new Dictionary + { + [PaxEaUid] = "1000", + [PaxEaGid] = "2000", + [PaxEaUName] = "testuser", + [PaxEaGName] = "testgroup", + }; + using MemoryStream ms = BuildRawPaxArchiveStream("file.txt", extraEAs, uid: 1000, gid: 2000); + using TarReader reader = new(ms); + PaxTarEntry readEntry = Assert.IsType(reader.GetNextEntry()); + + Assert.Equal(1000, readEntry.Uid); + Assert.Equal(2000, readEntry.Gid); + Assert.Equal("testuser", readEntry.UserName); + Assert.Equal("testgroup", readEntry.GroupName); + + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaUid)); + Assert.Equal("1000", readEntry.ExtendedAttributes[PaxEaUid]); + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaGid)); + Assert.Equal("2000", readEntry.ExtendedAttributes[PaxEaGid]); + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaUName)); + Assert.Equal("testuser", readEntry.ExtendedAttributes[PaxEaUName]); + Assert.True(readEntry.ExtendedAttributes.ContainsKey(PaxEaGName)); + Assert.Equal("testgroup", readEntry.ExtendedAttributes[PaxEaGName]); + } + + [Fact] + public void CustomEA_Roundtrip_SurvivesWriteAndRead() + { + const string customKey = "MSWINDOWS.rawsd"; + const string customValue = "AQAAgBQAAAAkAAA"; + + MemoryStream ms = new(); + using (TarWriter writer = new(ms, TarEntryFormat.Pax, leaveOpen: true)) + { + PaxTarEntry writeEntry = new PaxTarEntry(TarEntryType.RegularFile, "file.txt", + new Dictionary + { + { customKey, customValue } + }); + writer.WriteEntry(writeEntry); + } + + ms.Position = 0; + using TarReader reader = new(ms); + PaxTarEntry readEntry = Assert.IsType(reader.GetNextEntry()); + + Assert.True(readEntry.ExtendedAttributes.ContainsKey(customKey)); + Assert.Equal(customValue, readEntry.ExtendedAttributes[customKey]); + } + + [Fact] + public void BadArchive_MtimeDisagreement_EAWins() + { + long headerMtime = 1700000000; + string eaMtime = "9876543210.123"; + + using MemoryStream ms = BuildRawPaxArchiveStream("file.txt", + extraEAs: new Dictionary { ["mtime"] = eaMtime }, + mtime: headerMtime); + using TarReader reader = new(ms); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + + // EA mtime should take precedence over header mtime + Assert.NotEqual(DateTimeOffset.FromUnixTimeSeconds(headerMtime), entry.ModificationTime); + Assert.True(entry.ExtendedAttributes.ContainsKey(PaxEaMTime)); + Assert.Equal(eaMtime, entry.ExtendedAttributes[PaxEaMTime]); + } + + [Fact] + public void BadArchive_UidGidDisagreement_EAWins() + { + var extraEAs = new Dictionary + { + ["uid"] = "1000", + ["gid"] = "2000" + }; + // Header has uid=500, gid=600 (different from EA values) + using MemoryStream ms = BuildRawPaxArchiveStream("file.txt", extraEAs, uid: 500, gid: 600); + using TarReader reader = new(ms); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + + // EA values should take precedence over header values + Assert.Equal(1000, entry.Uid); + Assert.Equal(2000, entry.Gid); + Assert.True(entry.ExtendedAttributes.ContainsKey(PaxEaUid)); + Assert.Equal("1000", entry.ExtendedAttributes[PaxEaUid]); + Assert.True(entry.ExtendedAttributes.ContainsKey(PaxEaGid)); + Assert.Equal("2000", entry.ExtendedAttributes[PaxEaGid]); + } + + [Fact] + public void BadArchive_MissingEAPath_HeaderNameIsUsed() + { + using MemoryStream ms = BuildRawPaxArchiveStream("fallback-name.txt", + extraEAs: new Dictionary { ["mtime"] = "1700000000" }, + includePathInEA: false); + using TarReader reader = new(ms); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + + // Header name should be used when EA has no "path" + Assert.Equal("fallback-name.txt", entry.Name); + } + + [Fact] + public void BadArchive_MalformedNumericEA_Throws() + { + using MemoryStream ms = BuildRawPaxArchiveStream("file.txt", + extraEAs: new Dictionary { ["uid"] = "notanumber" }); + using TarReader reader = new(ms); + Assert.Throws(() => reader.GetNextEntry()); + } + } +} diff --git a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs index d8f6bfb1d2b2c4..48079f6b026616 100644 --- a/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarFile/TarFile.ExtractToDirectory.Stream.Tests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.IO.Enumeration; @@ -433,5 +434,130 @@ private void ExtractRootDirMatch_Verify_Throws(TarEntryFormat format, TarEntryTy Assert.Throws(() => TarFile.ExtractToDirectory(archive, destinationFolderPath, overwriteFiles: false)); Assert.False(File.Exists(entryFilePath), $"File should not exist: {entryFilePath}"); } + + public static IEnumerable PaxExtraction_PathOverrideData() + { + // headerName, eaName, expectedApiName + yield return new object[] { "data/report.txt", "config/settings.txt", "config/settings.txt" }; + yield return new object[] { "../../escape.txt", "safe.txt", "safe.txt" }; + } + + [Theory] + [MemberData(nameof(PaxExtraction_PathOverrideData))] + public void PaxExtraction_EntryNameMatchesExtractedPath(string headerName, string eaName, string expectedApiName) + { + byte[] content = "test data"u8.ToArray(); + byte[] archive = BuildRawPaxArchiveWithEAPathOverride(headerName, eaName, content); + + using TempDirectory root = new TempDirectory(); + + using (var scanStream = new MemoryStream(archive)) + using (var reader = new TarReader(scanStream)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Equal(expectedApiName, entry.Name); + } + + using var extractStream = new MemoryStream(archive); + TarFile.ExtractToDirectory(extractStream, root.Path, overwriteFiles: false); + + string[] files = Directory.GetFiles(root.Path, "*", SearchOption.AllDirectories); + Assert.Single(files); + string extractedRelPath = Path.GetRelativePath(root.Path, files[0]).Replace('\\', '/'); + Assert.Equal(expectedApiName, extractedRelPath); + } + + [Fact] + public void PaxExtraction_PathTraversalInEA_IsBlocked() + { + byte[] content = "test data"u8.ToArray(); + byte[] archive = BuildRawPaxArchiveWithEAPathOverride("safe.txt", "../../escape.txt", content); + + using (var scanStream = new MemoryStream(archive)) + using (var reader = new TarReader(scanStream)) + { + TarEntry entry = reader.GetNextEntry(); + Assert.NotNull(entry); + Assert.Contains("..", entry.Name); + } + + using TempDirectory root = new TempDirectory(); + using var extractStream = new MemoryStream(archive); + Assert.Throws(() => + TarFile.ExtractToDirectory(extractStream, root.Path, overwriteFiles: false)); + } + + [Theory] + [InlineData(10, 50)] // EA larger than header + [InlineData(100, 25)] // EA smaller than header + public void PaxExtraction_EntryLengthMatchesExtractedFileSize(int dataSize, long eaSize) + { + byte[] actualData = new byte[dataSize]; + Array.Fill(actualData, (byte)'X'); + + byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, dataSize, eaSize); + + long apiLength; + using (var scanStream = new MemoryStream(archive)) + using (var reader = new TarReader(scanStream)) + { + TarEntry entry = reader.GetNextEntry(copyData: true); + Assert.NotNull(entry); + apiLength = entry.Length; + } + + using TempDirectory root = new TempDirectory(); + using var extractStream = new MemoryStream(archive); + TarFile.ExtractToDirectory(extractStream, root.Path, overwriteFiles: false); + + long extractedSize = new FileInfo(Path.Combine(root.Path, "file.bin")).Length; + Assert.Equal(apiLength, extractedSize); + } + + [Fact] + public void PaxExtraction_SizeAmplification_EntryLengthMatchesExtraction() + { + int entryCount = 10; + byte[] tinyData = "x"u8.ToArray(); + long headerSize = 1; + long eaSize = 500; + + using var ms = new MemoryStream(); + for (int i = 0; i < entryCount; i++) + { + string name = $"file_{i:D3}.bin"; + var extraEAs = new Dictionary { ["size"] = eaSize.ToString() }; + byte[] eaData = BuildRawPaxExtendedAttributeData(name, extraEAs); + + WriteRawTarHeader(ms, $"PaxHeaders.0/{name}", 0, 0, 0, eaData.Length, 0, 'x', ""); + ms.Write(eaData); + PadToTarBlockBoundary(ms); + + WriteRawTarHeader(ms, name, Convert.ToInt32("644", 8), 0, 0, headerSize, 1700000000, '0', ""); + ms.Write(tinyData); + PadToTarBlockBoundary(ms); + } + ms.Write(new byte[1024]); + byte[] archive = ms.ToArray(); + + long apiTotalSize = 0; + using (var scanStream = new MemoryStream(archive)) + using (var reader = new TarReader(scanStream)) + { + TarEntry e; + while ((e = reader.GetNextEntry(copyData: true)) is not null) + { + apiTotalSize += e.Length; + } + } + + using TempDirectory root = new TempDirectory(); + using var extractStream = new MemoryStream(archive); + TarFile.ExtractToDirectory(extractStream, root.Path, overwriteFiles: false); + + long totalExtracted = Directory.GetFiles(root.Path).Sum(f => new FileInfo(f).Length); + Assert.Equal(apiTotalSize, totalExtracted); + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs index c2e6a7ed49d37f..ec192fa311d3cc 100644 --- a/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarReader/TarReader.GetNextEntry.Tests.cs @@ -1,6 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. +using System.Collections.Generic; using System.IO; using System.Text; using Xunit; @@ -415,6 +416,84 @@ public void GetNextEntry_UnseekableArchive_DisposedDataStream_NotRead_DoesNotThr Assert.Null(reader.GetNextEntry()); } + public static IEnumerable EAPathOverrideData() + { + // (headerName, eaPath, expectedName) + yield return new object[] { "data/report.txt", "config/settings.txt", "config/settings.txt" }; + yield return new object[] { "../../escape.txt", "safe.txt", "safe.txt" }; + yield return new object[] { "safe.txt", "../../escape.txt", "../../escape.txt" }; + } + + [Theory] + [MemberData(nameof(EAPathOverrideData))] + public void PaxReader_EAPathOverridesHeaderName(string headerName, string eaPath, string expectedName) + { + byte[] content = "test data"u8.ToArray(); + byte[] archive = BuildRawPaxArchiveWithEAPathOverride(headerName, eaPath, content); + + using var stream = new MemoryStream(archive); + using var reader = new TarReader(stream); + TarEntry entry = reader.GetNextEntry(); + + Assert.NotNull(entry); + Assert.Equal(expectedName, entry.Name); + } + + [Fact] + public void PaxReader_EALinkpathOverridesHeaderLinkname() + { + byte[] archive = BuildRawPaxArchiveSymlink("mylink", "mylink", "./safe.txt", "./other.txt"); + + using var stream = new MemoryStream(archive); + using var reader = new TarReader(stream); + TarEntry entry = reader.GetNextEntry(); + + Assert.NotNull(entry); + Assert.Equal("./other.txt", entry.LinkName); + } + + public static IEnumerable EASizeOverrideData() + { + // (actualDataSize, headerSize, eaSize) — EA size always takes precedence + yield return new object[] { 10, 10L, 50L }; // eaSize > headerSize (larger) + yield return new object[] { 100, 100L, 25L }; // eaSize < headerSize (smaller) + } + + [Theory] + [MemberData(nameof(EASizeOverrideData))] + public void PaxReader_EASizeOverridesHeaderSize(int actualDataSize, long headerSize, long eaSize) + { + byte[] actualData = new byte[actualDataSize]; + Array.Fill(actualData, (byte)'X'); + + byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, headerSize, eaSize); + + using var stream = new MemoryStream(archive); + using var reader = new TarReader(stream); + TarEntry entry = reader.GetNextEntry(copyData: true); + + Assert.NotNull(entry); + Assert.Equal(eaSize, entry.Length); + } + + [Fact] + public void PaxReader_EntryLengthAndDataStreamLengthAreConsistent() + { + byte[] actualData = "ABCDEFGHIJ"u8.ToArray(); + long headerSize = 10; + long eaSize = 50; + + byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, headerSize, eaSize); + + using var stream = new MemoryStream(archive); + using var reader = new TarReader(stream); + TarEntry entry = reader.GetNextEntry(copyData: true); + + Assert.NotNull(entry); + Assert.NotNull(entry.DataStream); + Assert.Equal(entry.Length, entry.DataStream.Length); + } + [Fact] public void Read_Archive_With_Unsupported_EntryType() { diff --git a/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs index cfcb5923a331c8..254c447ca77322 100644 --- a/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs +++ b/src/libraries/System.Formats.Tar/tests/TarTestsBase.cs @@ -928,5 +928,152 @@ internal void WriteTarArchiveWithOneEntry(Stream s, TarEntryFormat entryFormat, writer.WriteEntry(entry); } + + // Raw PAX archive building helpers for constructing archives + // where header fields intentionally disagree with extended attributes. + + protected static byte[] BuildRawPaxArchive( + string headerName, string eaPath, + Dictionary extraEAs = null, + byte[] fileContent = null, + int mode = 0b110_100_100, // 0644 + int uid = 0, int gid = 0, + long headerSizeField = -1, + long mtime = 1700000000, + char typeflag = '0', + string linkname = "") + { + using var ms = new MemoryStream(); + byte[] eaData = BuildRawPaxExtendedAttributeData(eaPath, extraEAs); + + WriteRawTarHeader(ms, "PaxHeaders.0/entry", 0, 0, 0, eaData.Length, 0, 'x', ""); + ms.Write(eaData); + PadToTarBlockBoundary(ms); + + long size = headerSizeField >= 0 ? headerSizeField : (fileContent?.Length ?? 0); + WriteRawTarHeader(ms, headerName, mode, uid, gid, size, mtime, typeflag, linkname); + if (fileContent is not null) + { + ms.Write(fileContent); + } + PadToTarBlockBoundary(ms); + + ms.Write(new byte[1024]); + return ms.ToArray(); + } + + protected static byte[] BuildRawPaxArchiveWithEAPathOverride(string headerName, string eaPath, byte[] fileContent) + => BuildRawPaxArchive(headerName, eaPath, fileContent: fileContent); + + protected static byte[] BuildRawPaxArchiveWithSizeOverride( + string headerName, string eaPath, byte[] fileContent, + long headerSizeField, long eaSizeOverride) + => BuildRawPaxArchive(headerName, eaPath, + extraEAs: new Dictionary { ["size"] = eaSizeOverride.ToString() }, + fileContent: fileContent, headerSizeField: headerSizeField); + + protected static byte[] BuildRawPaxArchiveSymlink( + string headerName, string eaPath, + string headerLinkName, string eaLinkPath) + => BuildRawPaxArchive(headerName, eaPath, + extraEAs: new Dictionary { ["linkpath"] = eaLinkPath }, + mode: Convert.ToInt32("777", 8), typeflag: '2', linkname: headerLinkName); + + protected static byte[] BuildRawPaxExtendedAttributeData(string eaPath, Dictionary extraEAs) + { + var sb = new StringBuilder(); + AppendRawPaxExtendedAttributeRecord(sb, "path", eaPath); + if (extraEAs is not null) + { + foreach (KeyValuePair kvp in extraEAs) + { + AppendRawPaxExtendedAttributeRecord(sb, kvp.Key, kvp.Value); + } + } + + return Encoding.UTF8.GetBytes(sb.ToString()); + } + + protected static void WriteRawTarHeader(MemoryStream ms, string name, int mode, int uid, int gid, + long size, long mtime, char typeflag, string linkname) + { + byte[] header = new byte[512]; + Encoding.UTF8.GetBytes(name.AsSpan(0, Math.Min(name.Length, 100)), header.AsSpan(0)); + WriteRawOctalField(header, 100, 8, mode); + WriteRawOctalField(header, 108, 8, uid); + WriteRawOctalField(header, 116, 8, gid); + WriteRawOctalField(header, 124, 12, size); + WriteRawOctalField(header, 136, 12, mtime); + header[156] = (byte)typeflag; + if (!string.IsNullOrEmpty(linkname)) + { + Encoding.UTF8.GetBytes(linkname.AsSpan(0, Math.Min(linkname.Length, 100)), header.AsSpan(157)); + } + + "ustar\0"u8.CopyTo(header.AsSpan(257)); + "00"u8.CopyTo(header.AsSpan(263)); + + for (int i = 148; i < 156; i++) + { + header[i] = (byte)' '; + } + + long checksum = 0; + for (int i = 0; i < 512; i++) + { + checksum += header[i]; + } + + WriteRawOctalField(header, 148, 7, checksum); + header[155] = (byte)' '; + ms.Write(header); + } + + protected static void PadToTarBlockBoundary(MemoryStream ms) + { + int remainder = (int)(ms.Length % 512); + if (remainder != 0) + { + ms.Write(new byte[512 - remainder]); + } + } + + protected static void AppendRawPaxExtendedAttributeRecord(StringBuilder sb, string key, string value) + { + string content = $" {key}={value}\n"; + int contentByteCount = Encoding.UTF8.GetByteCount(content); + int totalLen = contentByteCount + 1; + while (true) + { + string lenString = totalLen.ToString(CultureInfo.InvariantCulture); + int newTotalLen = lenString.Length + contentByteCount; + if (newTotalLen == totalLen) + { + break; + } + + totalLen = newTotalLen; + } + + sb.Append($"{totalLen}{content}"); + } + + private static void WriteRawOctalField(byte[] buffer, int offset, int fieldLen, long value) + { + string octal = Convert.ToString(value, 8); + int maxDigits = fieldLen - 1; + octal = octal.PadLeft(maxDigits, '0'); + if (octal.Length > maxDigits) + { + octal = octal[^maxDigits..]; + } + + for (int i = 0; i < octal.Length; i++) + { + buffer[offset + i] = (byte)octal[i]; + } + + buffer[offset + maxDigits] = 0; + } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs index 650c31b1f86734..51c64eec7a0cdf 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Roundtrip.Tests.cs @@ -156,7 +156,6 @@ public void UserNameGroupNameRoundtrips(TarEntryFormat entryFormat, bool unseeka public void PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyFitOnLegacyFields(TarEntryType entryType) { Dictionary extendedAttributes = new(); - extendedAttributes[PaxEaName] = "ea_name"; extendedAttributes[PaxEaGName] = "ea_gname"; extendedAttributes[PaxEaUName] = "ea_uname"; extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); @@ -209,7 +208,6 @@ public void PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyFitOnLe public void PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenLargerThanLegacyFields(TarEntryType entryType) { Dictionary extendedAttributes = new(); - extendedAttributes[PaxEaName] = "ea_name"; extendedAttributes[PaxEaGName] = "ea_gname"; extendedAttributes[PaxEaUName] = "ea_uname"; extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs index e1ba5ef7819cac..ba308c7e7a7786 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntryAsync.Entry.Roundtrip.Tests.cs @@ -105,7 +105,6 @@ public async Task UserNameGroupNameRoundtripsAsync(TarEntryFormat entryFormat, b public async Task PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyFitOnLegacyFieldsAsync(TarEntryType entryType) { Dictionary extendedAttributes = new(); - extendedAttributes[PaxEaName] = "ea_name"; extendedAttributes[PaxEaGName] = "ea_gname"; extendedAttributes[PaxEaUName] = "ea_uname"; extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime); @@ -158,7 +157,6 @@ public async Task PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenTheyF public async Task PaxExtendedAttributes_DoNotOverwritePublicProperties_WhenLargerThanLegacyFieldsAsync(TarEntryType entryType) { Dictionary extendedAttributes = new(); - extendedAttributes[PaxEaName] = "ea_name"; extendedAttributes[PaxEaGName] = "ea_gname"; extendedAttributes[PaxEaUName] = "ea_uname"; extendedAttributes[PaxEaMTime] = GetTimestampStringFromDateTimeOffset(TestModificationTime);