diff --git a/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs index 128c7a9f7c6101..5c4367daf3e2d4 100644 --- a/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs +++ b/src/libraries/System.Formats.Tar/ref/System.Formats.Tar.cs @@ -140,8 +140,14 @@ public void WriteEntry(string fileName, string? entryName) { } public sealed partial class TarWriterOptions { public TarWriterOptions() { } + public bool Deterministic { get { throw null; } set { } } public System.Formats.Tar.TarEntryFormat Format { get { throw null; } set { } } public System.Formats.Tar.TarHardLinkMode HardLinkMode { get { throw null; } set { } } + public int? OverrideGid { get { throw null; } set { } } + public string? OverrideGName { get { throw null; } set { } } + public System.DateTimeOffset? OverrideModificationTime { get { throw null; } set { } } + public int? OverrideUid { get { throw null; } set { } } + public string? OverrideUName { get { throw null; } set { } } } public sealed partial class UstarTarEntry : System.Formats.Tar.PosixTarEntry { 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 5a6645d551a2fa..af1e6ffea569db 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 @@ -127,11 +127,11 @@ private void WriteUstarFieldsToBuffer(Span buffer) } // Writes the current header as a PAX Global Extended Attributes entry into the archive stream. - internal ValueTask WriteAsPaxGlobalExtendedAttributesCoreAsync(Stream archiveStream, Memory buffer, int globalExtendedAttributesEntryNumber, CancellationToken cancellationToken) + internal ValueTask WriteAsPaxGlobalExtendedAttributesCoreAsync(Stream archiveStream, Memory buffer, int globalExtendedAttributesEntryNumber, bool deterministic, CancellationToken cancellationToken) where TAdapter : IReadWriteAdapter { VerifyGlobalExtendedAttributesDataIsValid(globalExtendedAttributesEntryNumber); - return WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: true, globalExtendedAttributesEntryNumber, cancellationToken); + return WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: true, globalExtendedAttributesEntryNumber, deterministic, cancellationToken); } // Verifies the data is valid for writing a Global Extended Attributes entry. @@ -171,7 +171,7 @@ internal ValueTask WriteAsUstarCoreAsync(Stream archiveStream, Memory< // Writes the current header as a PAX entry into the archive stream. // Makes sure to add the preceding extended attributes entry before the actual entry. - internal async ValueTask WriteAsPaxCoreAsync(Stream archiveStream, Memory buffer, CancellationToken cancellationToken) + internal async ValueTask WriteAsPaxCoreAsync(Stream archiveStream, Memory buffer, bool deterministic, CancellationToken cancellationToken) where TAdapter : IReadWriteAdapter { Debug.Assert(archiveStream.CanSeek || _dataStream == null || _dataStream.CanSeek); @@ -193,7 +193,7 @@ internal async ValueTask WriteAsPaxCoreAsync(Stream archiveStream, Mem CollectExtendedAttributesFromStandardFieldsIfNeeded(); // Write the extended attributes entry into the archive first - await extendedAttributesHeader.WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: false, globalExtendedAttributesEntryNumber: -1, cancellationToken).ConfigureAwait(false); + await extendedAttributesHeader.WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: false, globalExtendedAttributesEntryNumber: -1, deterministic, cancellationToken).ConfigureAwait(false); buffer.Span.Clear(); // And then write the stored entry into the archive @@ -205,7 +205,7 @@ internal async ValueTask WriteAsPaxCoreAsync(Stream archiveStream, Mem // Fill the current header's dict CollectExtendedAttributesFromStandardFieldsIfNeeded(); // And pass the attributes to the preceding extended attributes header for writing - await extendedAttributesHeader.WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: false, globalExtendedAttributesEntryNumber: -1, cancellationToken).ConfigureAwait(false); + await extendedAttributesHeader.WriteAsPaxExtendedAttributesCoreAsync(archiveStream, buffer, ExtendedAttributes, isGea: false, globalExtendedAttributesEntryNumber: -1, deterministic, cancellationToken).ConfigureAwait(false); buffer.Span.Clear(); // Reset it to reuse it // Second, we write this header as a normal one @@ -310,23 +310,23 @@ private void WriteGnuFieldsToBuffer(Span buffer) } // Writes the current header as a PAX Extended Attributes entry into the archive stream. - private ValueTask WriteAsPaxExtendedAttributesCoreAsync(Stream archiveStream, Memory buffer, Dictionary extendedAttributes, bool isGea, int globalExtendedAttributesEntryNumber, CancellationToken cancellationToken) + private ValueTask WriteAsPaxExtendedAttributesCoreAsync(Stream archiveStream, Memory buffer, Dictionary extendedAttributes, bool isGea, int globalExtendedAttributesEntryNumber, bool deterministic, CancellationToken cancellationToken) where TAdapter : IReadWriteAdapter { - WriteAsPaxExtendedAttributesShared(isGea, globalExtendedAttributesEntryNumber, extendedAttributes); + WriteAsPaxExtendedAttributesShared(isGea, globalExtendedAttributesEntryNumber, extendedAttributes, deterministic); Debug.Assert(_dataStream == null || (extendedAttributes.Count > 0 && _dataStream.CanSeek)); // We generate the extended attributes data stream, should always be seekable return WriteWithSeekableDataStreamCoreAsync(TarEntryFormat.Pax, archiveStream, buffer, cancellationToken); } // Initializes the name, mode and type flag of a PAX extended attributes entry. - private void WriteAsPaxExtendedAttributesShared(bool isGea, int globalExtendedAttributesEntryNumber, Dictionary extendedAttributes) + private void WriteAsPaxExtendedAttributesShared(bool isGea, int globalExtendedAttributesEntryNumber, Dictionary extendedAttributes, bool deterministic) { Debug.Assert(isGea && globalExtendedAttributesEntryNumber >= 0 || !isGea && globalExtendedAttributesEntryNumber < 0); _dataStream = GenerateExtendedAttributesDataStream(extendedAttributes); _name = isGea ? - GenerateGlobalExtendedAttributeName(globalExtendedAttributesEntryNumber) : - GenerateExtendedAttributeName(); + GenerateGlobalExtendedAttributeName(globalExtendedAttributesEntryNumber, deterministic) : + GenerateExtendedAttributeName(deterministic); _mode = TarHelpers.GetDefaultMode(_typeFlag); _typeFlag = isGea ? TarEntryType.GlobalExtendedAttributes : TarEntryType.ExtendedAttributes; @@ -946,7 +946,7 @@ private static int WriteAsUtf8String(ReadOnlySpan text, Span buffer) // - %d: The directory name of the file, equivalent to the result of the dirname utility on the translated pathname. // - %p: The current process ID. // - %f: The filename of the file, equivalent to the result of the basename utility on the translated pathname. - private string GenerateExtendedAttributeName() + private string GenerateExtendedAttributeName(bool deterministic) { ReadOnlySpan dirName = Path.GetDirectoryName(_name.AsSpan()); dirName = dirName.IsEmpty ? "." : dirName; @@ -954,9 +954,10 @@ private string GenerateExtendedAttributeName() ReadOnlySpan fileName = Path.GetFileName(_name.AsSpan()); fileName = fileName.IsEmpty ? "." : fileName; + int processId = deterministic ? 0 : Environment.ProcessId; return _typeFlag is TarEntryType.Directory or TarEntryType.DirectoryList ? - $"{dirName}/PaxHeaders.{Environment.ProcessId}/{fileName}{Path.DirectorySeparatorChar}" : - $"{dirName}/PaxHeaders.{Environment.ProcessId}/{fileName}"; + $"{dirName}/PaxHeaders.{processId}/{fileName}{Path.DirectorySeparatorChar}" : + $"{dirName}/PaxHeaders.{processId}/{fileName}"; } // Gets the special name for the 'name' field in a global extended attribute entry. @@ -966,10 +967,15 @@ private string GenerateExtendedAttributeName() // - %n: The sequence number of the global extended header record of the archive, starting at 1. // If the path of $TMPDIR makes the final string too long to fit in the 'name' field, // then the TMPDIR='/tmp' is used. - private static string GenerateGlobalExtendedAttributeName(int globalExtendedAttributesEntryNumber) + private static string GenerateGlobalExtendedAttributeName(int globalExtendedAttributesEntryNumber, bool deterministic) { Debug.Assert(globalExtendedAttributesEntryNumber >= 1); + if (deterministic) + { + return $"/tmp/GlobalHead.0.{globalExtendedAttributesEntryNumber}"; + } + ReadOnlySpan tmp = Path.TrimEndingDirectorySeparator(Path.GetTempPath()); string result = $"{tmp}/GlobalHead.{Environment.ProcessId}.{globalExtendedAttributesEntryNumber}"; diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs index 649403e07f0444..c4c719ad765567 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Unix.cs @@ -84,7 +84,7 @@ private TarEntry ConstructEntryForWriting(string fullPath, string entryName, Fil entry._header._devMinor = (int)minor; } - entry._header._mTime = TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(status.MTime); + entry._header._mTime = GetModificationTime(TarHelpers.GetDateTimeOffsetFromSecondsSinceEpoch(status.MTime)); // We do not set atime and ctime by default because many external tools are unable to read GNU entries // that have these fields set to non-zero values. This is because the GNU format writes atime and ctime in the same // location where other formats expect the prefix field to be written. @@ -95,24 +95,43 @@ private TarEntry ConstructEntryForWriting(string fullPath, string entryName, Fil entry._header._mode = status.Mode & (int)TarHelpers.ValidUnixFileModes; // Uid and UName - entry._header._uid = (int)status.Uid; - if (!_userIdentifiers.TryGetValue(status.Uid, out string? uName)) + entry._header._uid = _overrideUid ?? (_deterministic ? 0 : (int)status.Uid); + if (_overrideUName is string overrideUName) { - uName = Interop.Sys.GetUserNameFromPasswd(status.Uid); - _userIdentifiers.Add(status.Uid, uName); + entry._header._uName = overrideUName; + } + else if (_deterministic) + { + entry._header._uName = string.Empty; + } + else + { + if (!_userIdentifiers.TryGetValue(status.Uid, out string? uName)) + { + uName = Interop.Sys.GetUserNameFromPasswd(status.Uid); + _userIdentifiers.Add(status.Uid, uName); + } + entry._header._uName = uName; } - entry._header._uName = uName; // Gid and GName - entry._header._gid = (int)status.Gid; - if (!_groupIdentifiers.TryGetValue(status.Gid, out string? gName)) + entry._header._gid = _overrideGid ?? (_deterministic ? 0 : (int)status.Gid); + if (_overrideGName is string overrideGName) + { + entry._header._gName = overrideGName; + } + else if (_deterministic) + { + entry._header._gName = string.Empty; + } + else { - if (Interop.Sys.TryGetGroupName(status.Gid, out gName)) + if (!_groupIdentifiers.TryGetValue(status.Gid, out string? gName) && Interop.Sys.TryGetGroupName(status.Gid, out gName)) { _groupIdentifiers.Add(status.Gid, gName); } + entry._header._gName = gName; } - entry._header._gName = gName; if (entry.EntryType == TarEntryType.SymbolicLink) { diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs index b3aa1ba01a235f..3c600e126b4f34 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.Windows.cs @@ -116,7 +116,7 @@ private TarEntry ConstructEntryForWriting(string fullPath, string entryName, Fil _ => throw new InvalidDataException(SR.Format(SR.TarInvalidFormat, Format)), }; - entry._header._mTime = fileInfo.ftLastWriteTime.ToDateTimeUtc(); + entry._header._mTime = GetModificationTime(fileInfo.ftLastWriteTime.ToDateTimeUtc()); // We do not set atime and ctime by default because many external tools are unable to read GNU entries // that have these fields set to non-zero values. This is because the GNU format writes atime and ctime in the same // location where other formats expect the prefix field to be written. @@ -124,6 +124,10 @@ private TarEntry ConstructEntryForWriting(string fullPath, string entryName, Fil // then setting the values. entry.Mode = DefaultWindowsMode; + entry._header._uid = _overrideUid ?? 0; + entry._header._gid = _overrideGid ?? 0; + entry._header._uName = _overrideUName ?? string.Empty; + entry._header._gName = _overrideGName ?? string.Empty; if (entry.EntryType == TarEntryType.SymbolicLink) { diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs index 72b9f3b9d49d73..ef877129a2f17a 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriter.cs @@ -19,6 +19,12 @@ public sealed partial class TarWriter : IDisposable, IAsyncDisposable private readonly bool _leaveOpen; private readonly Stream _archiveStream; private readonly TarHardLinkMode _hardLinkMode; + private readonly bool _deterministic; + private readonly DateTimeOffset? _overrideModificationTime; + private readonly int? _overrideUid; + private readonly int? _overrideGid; + private readonly string? _overrideUName; + private readonly string? _overrideGName; private int _nextGlobalExtendedAttributesEntryNumber; /// @@ -98,6 +104,12 @@ public TarWriter(Stream archiveStream, TarWriterOptions options, bool leaveOpen _archiveStream = archiveStream; Format = options.Format; _hardLinkMode = options.HardLinkMode; + _deterministic = options.Deterministic; + _overrideModificationTime = options.OverrideModificationTime; + _overrideUid = options.OverrideUid; + _overrideGid = options.OverrideGid; + _overrideUName = options.OverrideUName; + _overrideGName = options.OverrideGName; _leaveOpen = leaveOpen; _isDisposed = false; _wroteEntries = false; @@ -315,8 +327,8 @@ private async ValueTask WriteEntryCoreAsync(TarEntry entry, Cancellati { TarEntryFormat.V7 => entry._header.WriteAsV7CoreAsync(_archiveStream, buffer, cancellationToken), TarEntryFormat.Ustar => entry._header.WriteAsUstarCoreAsync(_archiveStream, buffer, cancellationToken), - TarEntryFormat.Pax when entry._header._typeFlag is TarEntryType.GlobalExtendedAttributes => entry._header.WriteAsPaxGlobalExtendedAttributesCoreAsync(_archiveStream, buffer, _nextGlobalExtendedAttributesEntryNumber++, cancellationToken), - TarEntryFormat.Pax => entry._header.WriteAsPaxCoreAsync(_archiveStream, buffer, cancellationToken), + TarEntryFormat.Pax when entry._header._typeFlag is TarEntryType.GlobalExtendedAttributes => entry._header.WriteAsPaxGlobalExtendedAttributesCoreAsync(_archiveStream, buffer, _nextGlobalExtendedAttributesEntryNumber++, _deterministic, cancellationToken), + TarEntryFormat.Pax => entry._header.WriteAsPaxCoreAsync(_archiveStream, buffer, _deterministic, cancellationToken), TarEntryFormat.Gnu => entry._header.WriteAsGnuCoreAsync(_archiveStream, buffer, cancellationToken), _ => throw new InvalidDataException(SR.Format(SR.TarInvalidFormat, Format)), }; @@ -330,6 +342,9 @@ private async ValueTask WriteEntryCoreAsync(TarEntry entry, Cancellati } } + private DateTimeOffset GetModificationTime(DateTimeOffset sourceModificationTime) => + _overrideModificationTime ?? (_deterministic ? DateTimeOffset.UnixEpoch : sourceModificationTime); + // The spec indicates that the end of the archive is indicated // by two records consisting entirely of zero bytes. // This method is called from Dispose/DisposeAsync, so we don't want to propagate a cancelled CancellationToken. diff --git a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriterOptions.cs b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriterOptions.cs index d97393102d85df..5ce9ef92092401 100644 --- a/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriterOptions.cs +++ b/src/libraries/System.Formats.Tar/src/System/Formats/Tar/TarWriterOptions.cs @@ -43,5 +43,61 @@ public TarHardLinkMode HardLinkMode field = value; } } + + /// + /// Gets or sets a value that indicates whether the writer should avoid process- and host-dependent metadata. + /// + /// The default value is . + /// + /// When enabled, PAX extended and global extended header names do not contain the process ID or temporary directory path. + /// Entries created by use zero user and group IDs, empty user and group names, and as the modification time unless the corresponding override property is set. + /// Metadata explicitly set on entries passed to is preserved. + /// + public bool Deterministic { get; set; } + + /// + /// Gets or sets the modification time used for entries created from filesystem paths. + /// + /// + /// to preserve the source filesystem timestamp when is , + /// or to use when is . + /// + public DateTimeOffset? OverrideModificationTime { get; set; } + + /// + /// Gets or sets the user ID used for entries created from filesystem paths. + /// + /// + /// to preserve the source filesystem value when is , + /// or to use zero when is . + /// + public int? OverrideUid { get; set; } + + /// + /// Gets or sets the group ID used for entries created from filesystem paths. + /// + /// + /// to preserve the source filesystem value when is , + /// or to use zero when is . + /// + public int? OverrideGid { get; set; } + + /// + /// Gets or sets the user name used for entries created from filesystem paths. + /// + /// + /// to preserve the source filesystem value when is , + /// or to use an empty string when is . + /// + public string? OverrideUName { get; set; } + + /// + /// Gets or sets the group name used for entries created from filesystem paths. + /// + /// + /// to preserve the source filesystem value when is , + /// or to use an empty string when is . + /// + public string? OverrideGName { get; set; } } } diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs index 840cc8946ea3b7..770485e9bcb525 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.Entry.Pax.Tests.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Text; using Xunit; namespace System.Formats.Tar.Tests @@ -35,6 +36,86 @@ public void WriteRegularFile() } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ExtendedHeaderName_UsesConfiguredProcessIdBehavior(bool deterministic) + { + using MemoryStream archiveStream = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = deterministic + }; + using (TarWriter writer = new TarWriter(archiveStream, options, leaveOpen: true)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "file.txt") + { + ModificationTime = DateTimeOffset.UnixEpoch + }); + } + + string headerName = ReadFirstHeaderName(archiveStream); + int expectedProcessId = deterministic ? 0 : Environment.ProcessId; + Assert.Equal($"./PaxHeaders.{expectedProcessId}/.", headerName); + } + + [Fact] + public void GlobalExtendedHeaderName_IsProcessAndTempDirectoryIndependentInDeterministicMode() + { + using MemoryStream archiveStream = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = true + }; + using (TarWriter writer = new TarWriter(archiveStream, options, leaveOpen: true)) + { + writer.WriteEntry(new PaxGlobalExtendedAttributesTarEntry( + new Dictionary { ["key"] = "value" })); + } + + Assert.Equal("/tmp/GlobalHead.0.1", ReadFirstHeaderName(archiveStream)); + } + + [Fact] + public void DeterministicMode_PreservesExplicitEntryMetadata() + { + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(1_636_374_896); + using MemoryStream archiveStream = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = true + }; + using (TarWriter writer = new TarWriter(archiveStream, options, leaveOpen: true)) + { + writer.WriteEntry(new PaxTarEntry(TarEntryType.RegularFile, "file.txt") + { + ModificationTime = timestamp, + Uid = 123, + Gid = 456, + UserName = "user", + GroupName = "group" + }); + } + + archiveStream.Position = 0; + using TarReader reader = new TarReader(archiveStream); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + Assert.Equal(timestamp, entry.ModificationTime); + Assert.Equal(123, entry.Uid); + Assert.Equal(456, entry.Gid); + Assert.Equal("user", entry.UserName); + Assert.Equal("group", entry.GroupName); + } + + private static string ReadFirstHeaderName(MemoryStream archiveStream) + { + byte[] archive = archiveStream.ToArray(); + return Encoding.UTF8.GetString(archive.AsSpan(0, 100)).TrimEnd('\0'); + } + [Fact] public void WriteHardLink() { diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs index 5e168d7808a364..9cd576cfeb5b5c 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.Unix.cs @@ -305,6 +305,51 @@ public void CreateEntryFromFileOwnedByNonExistentGroupAndUser(TarEntryFormat f) }, f.ToString(), new RemoteInvokeOptions { RunAsSudo = true }).Dispose(); } + [ConditionalFact(typeof(TarWriter_WriteEntry_File_Tests), nameof(IsRemoteExecutorSupportedAndPrivilegedProcess))] + public void DeterministicMode_DoesNotCaptureSourceOwnership() + { + RemoteExecutor.Invoke(() => + { + using TempDirectory root = new TempDirectory(); + string filePath = Path.Join(root.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + + string groupName = Path.GetRandomFileName()[0..6]; + string userName = Path.GetRandomFileName()[0..6]; + _ = CreateGroup(groupName); + _ = CreateUser(userName); + try + { + SetGroupAsOwnerOfFile(groupName, filePath); + SetUserAsOwnerOfFile(userName, filePath); + + using MemoryStream archive = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = true + }; + using (TarWriter writer = new TarWriter(archive, options, leaveOpen: true)) + { + writer.WriteEntry(filePath, "file.txt"); + } + + archive.Position = 0; + using TarReader reader = new TarReader(archive); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + Assert.Equal(0, entry.Uid); + Assert.Equal(0, entry.Gid); + Assert.Equal(string.Empty, entry.UserName); + Assert.Equal(string.Empty, entry.GroupName); + } + finally + { + DeleteUser(userName); + DeleteGroup(groupName); + } + }, new RemoteInvokeOptions { RunAsSudo = true }).Dispose(); + } + [Theory] [InlineData(TarEntryFormat.V7)] [InlineData(TarEntryFormat.Ustar)] diff --git a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs index db1234f9fcaf71..b84dc851daf71e 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriter/TarWriter.WriteEntry.File.Tests.cs @@ -112,6 +112,100 @@ public void Add_File(TarEntryFormat format) } } + [Fact] + public void DeterministicMode_NormalizesPathMetadata() + { + using TempDirectory root = new TempDirectory(); + string filePath = Path.Join(root.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + File.SetLastWriteTimeUtc(filePath, new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + + using MemoryStream archive = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = true + }; + using (TarWriter writer = new TarWriter(archive, options, leaveOpen: true)) + { + writer.WriteEntry(filePath, "file.txt"); + } + + archive.Position = 0; + using TarReader reader = new TarReader(archive); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + Assert.Equal(DateTimeOffset.UnixEpoch, entry.ModificationTime); + Assert.Equal(0, entry.Uid); + Assert.Equal(0, entry.Gid); + Assert.Equal(string.Empty, entry.UserName); + Assert.Equal(string.Empty, entry.GroupName); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PathMetadataOverridesTakePrecedence(bool deterministic) + { + using TempDirectory root = new TempDirectory(); + string filePath = Path.Join(root.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(1_636_374_896); + + using MemoryStream archive = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = deterministic, + OverrideModificationTime = timestamp, + OverrideUid = 123, + OverrideGid = 456, + OverrideUName = "user", + OverrideGName = "group" + }; + using (TarWriter writer = new TarWriter(archive, options, leaveOpen: true)) + { + writer.WriteEntry(filePath, "file.txt"); + } + + archive.Position = 0; + using TarReader reader = new TarReader(archive); + PaxTarEntry entry = Assert.IsType(reader.GetNextEntry()); + Assert.Equal(timestamp, entry.ModificationTime); + Assert.Equal(123, entry.Uid); + Assert.Equal(456, entry.Gid); + Assert.Equal("user", entry.UserName); + Assert.Equal("group", entry.GroupName); + } + + [Fact] + public void DeterministicMode_ProducesSameArchiveWhenSourceTimestampChanges() + { + using TempDirectory root = new TempDirectory(); + string filePath = Path.Join(root.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + + byte[] first = WriteArchive(new DateTime(2020, 1, 2, 3, 4, 5, DateTimeKind.Utc)); + byte[] second = WriteArchive(new DateTime(2025, 6, 7, 8, 9, 10, DateTimeKind.Utc)); + Assert.Equal(first, second); + + byte[] WriteArchive(DateTime lastWriteTimeUtc) + { + File.SetLastWriteTimeUtc(filePath, lastWriteTimeUtc); + using MemoryStream archive = new MemoryStream(); + TarWriterOptions options = new TarWriterOptions + { + Format = TarEntryFormat.Pax, + Deterministic = true, + OverrideModificationTime = DateTimeOffset.FromUnixTimeSeconds(1_636_374_896) + }; + using (TarWriter writer = new TarWriter(archive, options, leaveOpen: true)) + { + writer.WriteEntry(filePath, "file.txt"); + } + return archive.ToArray(); + } + } + [Theory] [InlineData(TarEntryFormat.V7, false)] [InlineData(TarEntryFormat.V7, true)] diff --git a/src/libraries/System.Formats.Tar/tests/TarWriterOptions.Tests.cs b/src/libraries/System.Formats.Tar/tests/TarWriterOptions.Tests.cs index 8e48ae6a39480a..da864075f0e4c4 100644 --- a/src/libraries/System.Formats.Tar/tests/TarWriterOptions.Tests.cs +++ b/src/libraries/System.Formats.Tar/tests/TarWriterOptions.Tests.cs @@ -13,6 +13,12 @@ public void DefaultValues() TarWriterOptions options = new TarWriterOptions(); Assert.Equal(TarEntryFormat.Pax, options.Format); Assert.Equal(TarHardLinkMode.PreserveLink, options.HardLinkMode); + Assert.False(options.Deterministic); + Assert.Null(options.OverrideModificationTime); + Assert.Null(options.OverrideUid); + Assert.Null(options.OverrideGid); + Assert.Null(options.OverrideUName); + Assert.Null(options.OverrideGName); } [Theory] @@ -46,5 +52,27 @@ public void HardLinkMode_RejectsInvalidValues(TarHardLinkMode mode) TarWriterOptions options = new TarWriterOptions(); Assert.Throws("value", () => options.HardLinkMode = mode); } + + [Fact] + public void DeterministicMetadataOptions_AcceptValues() + { + DateTimeOffset timestamp = DateTimeOffset.FromUnixTimeSeconds(1_636_374_896); + TarWriterOptions options = new TarWriterOptions + { + Deterministic = true, + OverrideModificationTime = timestamp, + OverrideUid = 123, + OverrideGid = 456, + OverrideUName = "user", + OverrideGName = "group" + }; + + Assert.True(options.Deterministic); + Assert.Equal(timestamp, options.OverrideModificationTime); + Assert.Equal(123, options.OverrideUid); + Assert.Equal(456, options.OverrideGid); + Assert.Equal("user", options.OverrideUName); + Assert.Equal("group", options.OverrideGName); + } } }