-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Make published container images reproducible #55689
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
baronfel
merged 6 commits into
dotnet:main
from
jetersen:feat/reproducible-container-timestamps
Aug 18, 2026
Merged
Changes from 3 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
c146bed
Honor SOURCE_DATE_EPOCH to make published images reproducible
jetersen 24bcc4a
Remove the process id from layer pax extended headers
jetersen 26b29bf
Address review feedback on SOURCE_DATE_EPOCH handling
jetersen de670b8
Simplify reproducible container timestamp handling
jetersen 6cf7e45
Refactor tar entry writing to use TarWriter directly for improved cla…
jetersen f43b964
Fix ambiguous Layer.FromDirectory overload
jetersen File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
208 changes: 208 additions & 0 deletions
208
src/Containers/Microsoft.NET.Build.Containers/PaxHeaderNameNormalizingStream.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,208 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| namespace Microsoft.NET.Build.Containers; | ||
|
|
||
| /// <summary> | ||
| /// A write-through stream that rewrites the name of pax extended headers so that a tar archive does | ||
| /// not depend on the process that produced it. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// <para> | ||
| /// <see cref="System.Formats.Tar.TarWriter"/> names the extended header entry that precedes an entry | ||
| /// <c>./PaxHeaders.<process id>/.</c>. POSIX suggests including the process id so that two | ||
| /// concurrent extractions cannot collide over the same temporary name, but it means the bytes of the | ||
| /// archive differ between two runs that write identical content. That alone is enough to change a | ||
| /// container layer's digest, so the same source published twice yields two different images. | ||
| /// </para> | ||
| /// <para> | ||
| /// The name is not meaningful to an extractor: the path an extended header applies to is carried in | ||
| /// its <c>path</c> record, not in its entry name, and the entry always applies to the entry that | ||
| /// immediately follows it. Replacing the name with a constant is therefore safe and keeps the archive | ||
| /// a valid pax archive. | ||
| /// </para> | ||
| /// </remarks> | ||
| internal sealed class PaxHeaderNameNormalizingStream : Stream | ||
| { | ||
| private const int BlockSize = 512; | ||
| private const int NameOffset = 0; | ||
| private const int NameLength = 100; | ||
| private const int SizeOffset = 124; | ||
| private const int SizeLength = 12; | ||
| private const int ChecksumOffset = 148; | ||
| private const int ChecksumLength = 8; | ||
| private const int TypeFlagOffset = 156; | ||
| private const int MagicOffset = 257; | ||
| private const byte ExtendedHeaderTypeFlag = (byte)'x'; | ||
| private const byte GlobalExtendedHeaderTypeFlag = (byte)'g'; | ||
|
|
||
| /// <summary>The name written in place of the process-dependent one.</summary> | ||
| private static ReadOnlySpan<byte> NormalizedName => "./PaxHeaders/."u8; | ||
|
|
||
| private static ReadOnlySpan<byte> UstarMagic => "ustar"u8; | ||
|
|
||
| private readonly Stream _inner; | ||
| private readonly bool _leaveOpen; | ||
| private readonly byte[] _block = new byte[BlockSize]; | ||
|
|
||
| private int _blockBytes; | ||
| private long _dataBlocksRemaining; | ||
| private bool _disposed; | ||
|
|
||
| public PaxHeaderNameNormalizingStream(Stream inner, bool leaveOpen = false) | ||
| { | ||
| _inner = inner; | ||
| _leaveOpen = leaveOpen; | ||
| } | ||
|
|
||
| public override bool CanRead => false; | ||
| public override bool CanSeek => false; | ||
| public override bool CanWrite => true; | ||
| public override long Length => throw new NotSupportedException(); | ||
| public override long Position | ||
| { | ||
| get => throw new NotSupportedException(); | ||
| set => throw new NotSupportedException(); | ||
| } | ||
|
|
||
| public override void Write(byte[] buffer, int offset, int count) => Write(buffer.AsSpan(offset, count)); | ||
|
|
||
| public override void Write(ReadOnlySpan<byte> buffer) | ||
| { | ||
| // The stream is reassembled into 512 byte blocks because a caller may write across block | ||
| // boundaries, and a header can only be recognized once its whole block is available. | ||
| while (!buffer.IsEmpty) | ||
| { | ||
| int take = Math.Min(BlockSize - _blockBytes, buffer.Length); | ||
| buffer[..take].CopyTo(_block.AsSpan(_blockBytes)); | ||
| _blockBytes += take; | ||
| buffer = buffer[take..]; | ||
|
|
||
| if (_blockBytes == BlockSize) | ||
| { | ||
| ProcessBlock(); | ||
| _inner.Write(_block, 0, BlockSize); | ||
| _blockBytes = 0; | ||
| } | ||
| } | ||
| } | ||
|
baronfel marked this conversation as resolved.
Outdated
|
||
|
|
||
| public override void WriteByte(byte value) => Write(new ReadOnlySpan<byte>(in value)); | ||
|
|
||
| private void ProcessBlock() | ||
| { | ||
| // Only a header block can be rewritten, so the data blocks that follow one are skipped. This | ||
| // matters because file content can contain anything, including bytes that look like a header. | ||
| if (_dataBlocksRemaining > 0) | ||
| { | ||
| _dataBlocksRemaining--; | ||
| return; | ||
| } | ||
|
|
||
| Span<byte> block = _block; | ||
|
|
||
| // An all-zero block is end-of-archive padding rather than a header. | ||
| if (!block.ContainsAnyExcept((byte)0)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (!block.Slice(MagicOffset, UstarMagic.Length).SequenceEqual(UstarMagic)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| _dataBlocksRemaining = ParseDataBlockCount(block); | ||
|
|
||
| byte typeFlag = block[TypeFlagOffset]; | ||
| if (typeFlag is not (ExtendedHeaderTypeFlag or GlobalExtendedHeaderTypeFlag)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| Span<byte> name = block.Slice(NameOffset, NameLength); | ||
| name.Clear(); | ||
| NormalizedName.CopyTo(name); | ||
|
|
||
| WriteChecksum(block); | ||
| } | ||
|
|
||
| /// <summary>Reads the octal size field and converts it to a count of trailing data blocks.</summary> | ||
| private static long ParseDataBlockCount(ReadOnlySpan<byte> block) | ||
| { | ||
| long size = 0; | ||
| foreach (byte b in block.Slice(SizeOffset, SizeLength)) | ||
| { | ||
| if (b is (byte)' ' or 0) | ||
| { | ||
| // The field is terminated by a space or NUL; anything after it is padding. | ||
| break; | ||
| } | ||
|
|
||
| if (b is < (byte)'0' or > (byte)'7') | ||
| { | ||
| // Not a value this stream understands (for example a base-256 encoded size). Treating | ||
| // it as zero would risk rewriting file content, so the rest of the archive is left alone. | ||
| return long.MaxValue; | ||
| } | ||
|
|
||
| size = (size * 8) + (b - '0'); | ||
| } | ||
|
|
||
| return (size + BlockSize - 1) / BlockSize; | ||
| } | ||
|
|
||
| /// <summary>Recomputes the header checksum, which the name change invalidates.</summary> | ||
| private static void WriteChecksum(Span<byte> block) | ||
| { | ||
| Span<byte> checksumField = block.Slice(ChecksumOffset, ChecksumLength); | ||
|
|
||
| // The checksum is defined as the sum of the header bytes with the checksum field read as spaces. | ||
| checksumField.Fill((byte)' '); | ||
|
|
||
| int checksum = 0; | ||
| foreach (byte b in block) | ||
| { | ||
| checksum += b; | ||
| } | ||
|
|
||
| // Six octal digits, a NUL and a space, as written by the runtime. | ||
| for (int i = 5; i >= 0; i--) | ||
| { | ||
| checksumField[i] = (byte)('0' + (checksum & 7)); | ||
| checksum >>= 3; | ||
| } | ||
|
|
||
| checksumField[6] = 0; | ||
| checksumField[7] = (byte)' '; | ||
| } | ||
|
|
||
| public override void Flush() => _inner.Flush(); | ||
|
|
||
| public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException(); | ||
| public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); | ||
| public override void SetLength(long value) => throw new NotSupportedException(); | ||
|
|
||
| protected override void Dispose(bool disposing) | ||
| { | ||
| if (disposing && !_disposed) | ||
| { | ||
| _disposed = true; | ||
|
|
||
| // A well-formed archive is a whole number of blocks, but a trailing partial block is | ||
| // forwarded rather than dropped so this stream never loses data it was given. | ||
| if (_blockBytes > 0) | ||
| { | ||
| _inner.Write(_block, 0, _blockBytes); | ||
| _blockBytes = 0; | ||
| } | ||
|
|
||
| if (!_leaveOpen) | ||
| { | ||
| _inner.Dispose(); | ||
| } | ||
| } | ||
|
|
||
| base.Dispose(disposing); | ||
| } | ||
| } | ||
49 changes: 49 additions & 0 deletions
49
src/Containers/Microsoft.NET.Build.Containers/SourceDateEpoch.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Globalization; | ||
|
|
||
| namespace Microsoft.NET.Build.Containers; | ||
|
|
||
| /// <summary> | ||
| /// Provides the timestamp stamped into generated container artifacts. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Image creation is otherwise a function of the current time: the image config's creation date, the | ||
| /// generated history entries and every layer tar entry are stamped with <see cref="DateTime.UtcNow"/>. | ||
| /// Publishing one commit twice therefore produces two different digests, which defeats content-addressed | ||
| /// deduplication in registries and makes downstream tooling treat a rebuild as a new artifact. | ||
| /// Honoring <see href="https://reproducible-builds.org/docs/source-date-epoch/">SOURCE_DATE_EPOCH</see>, | ||
| /// the cross-ecosystem convention for this, lets a build opt into a reproducible image. | ||
| /// </remarks> | ||
| internal static class SourceDateEpoch | ||
|
baronfel marked this conversation as resolved.
Outdated
|
||
| { | ||
| private const string EnvironmentVariableName = "SOURCE_DATE_EPOCH"; | ||
|
|
||
| /// <summary> | ||
| /// The timestamp to stamp into generated container artifacts: the value of SOURCE_DATE_EPOCH when | ||
| /// it is set to a valid non-negative integer, otherwise the current UTC time. | ||
| /// </summary> | ||
| internal static DateTime GetTimestamp(Func<string, string?>? environmentReader = null) | ||
| { | ||
| string? value = (environmentReader ?? Environment.GetEnvironmentVariable)(EnvironmentVariableName); | ||
|
|
||
| // An unset variable is the common case, and a malformed one is treated the same way: the | ||
| // reproducible-builds specification asks consumers to ignore values they cannot interpret | ||
| // rather than fail the build. | ||
| if (string.IsNullOrWhiteSpace(value) || | ||
| !long.TryParse(value.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out long secondsSinceEpoch)) | ||
| { | ||
| return DateTime.UtcNow; | ||
| } | ||
|
|
||
| try | ||
| { | ||
| return DateTimeOffset.FromUnixTimeSeconds(secondsSinceEpoch).UtcDateTime; | ||
| } | ||
| catch (ArgumentOutOfRangeException) | ||
| { | ||
| return DateTime.UtcNow; | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.