Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Containers/Microsoft.NET.Build.Containers/ImageConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,11 @@ internal string BuildConfig()
int numberOfLayers = _rootFsLayers.Count;
int numberOfNonEmptyLayerHistoryEntries = _history.Count(h => h.empty_layer is null or false);
int missingHistoryEntries = numberOfLayers - numberOfNonEmptyLayerHistoryEntries;
HistoryEntry customHistoryEntry = new(created: DateTime.UtcNow, author: ".NET SDK",
// Sampled once so the creation date and the generated history entries agree, and so that an
// unchanged input produces an unchanged config blob.
DateTime createdAt = SourceDateEpoch.GetTimestamp();
Comment thread
baronfel marked this conversation as resolved.
Outdated

HistoryEntry customHistoryEntry = new(created: createdAt, author: ".NET SDK",
created_by: $".NET SDK Container Tooling, version {Constants.Version}");
for (int i = 0; i < missingHistoryEntries; i++)
{
Expand All @@ -152,7 +156,7 @@ internal string BuildConfig()
{
["config"] = newConfig,
//update creation date
["created"] = RFC3339Format(DateTime.UtcNow),
["created"] = RFC3339Format(createdAt),
["rootfs"] = new JsonObject()
{
["type"] = "layers",
Expand Down
33 changes: 25 additions & 8 deletions src/Containers/Microsoft.NET.Build.Containers/Layer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -93,17 +93,28 @@ public static Layer FromDirectory(string directory, string containerPath, bool i
{
using (HashDigestGZipStream gz = new(fs, leaveOpen: true))
{
using (TarWriter writer = new(gz, TarEntryFormat.Pax, leaveOpen: true))
// The extended header names the runtime writes contain the current process id, which
// would otherwise make the layer differ between two builds of identical content.
using (PaxHeaderNameNormalizingStream normalized = new(gz, leaveOpen: true))
Comment thread
baronfel marked this conversation as resolved.
Outdated
using (TarWriter writer = new(normalized, TarEntryFormat.Pax, leaveOpen: true))
{
// Every entry is stamped with the same timestamp so that publishing identical
// content twice produces an identical layer. Otherwise each entry would carry the
// moment it happened to be written and the layer digest would change on every build.
DateTimeOffset modificationTime = SourceDateEpoch.GetTimestamp();
Comment thread
baronfel marked this conversation as resolved.
Outdated

// Windows layers need a Files folder
if (isWindowsLayer)
{
var entry = new PaxTarEntry(TarEntryType.Directory, "Files", entryAttributes);
var entry = new PaxTarEntry(TarEntryType.Directory, "Files", entryAttributes)
{
ModificationTime = modificationTime
};
writer.WriteEntry(entry);
}

// Write an entry for the application directory.
WriteTarEntryForFile(writer, new DirectoryInfo(directory), containerPath, entryAttributes, isWindowsLayer ? null : userId);
WriteTarEntryForFile(writer, new DirectoryInfo(directory), containerPath, entryAttributes, isWindowsLayer ? null : userId, modificationTime);

// Write entries for the application directory contents.
var fileList = new FileSystemEnumerable<(FileSystemInfo file, string containerPath)>(
Expand All @@ -124,19 +135,24 @@ public static Layer FromDirectory(string directory, string containerPath, bool i
AttributesToSkip = FileAttributes.System, // Include hidden files
RecurseSubdirectories = true
});
foreach (var item in fileList)
// The enumeration order of a directory is filesystem-defined, so it is sorted to keep
// the order of entries in the tar stream stable across machines and builds.
foreach (var item in fileList.OrderBy(static item => item.containerPath, StringComparer.Ordinal))
{
WriteTarEntryForFile(writer, item.file, item.containerPath, entryAttributes, isWindowsLayer ? null : userId);
WriteTarEntryForFile(writer, item.file, item.containerPath, entryAttributes, isWindowsLayer ? null : userId, modificationTime);
}

// Windows layers need a Hives folder, we do not need to create any Registry Hive deltas inside
if (isWindowsLayer)
{
var entry = new PaxTarEntry(TarEntryType.Directory, "Hives", entryAttributes);
var entry = new PaxTarEntry(TarEntryType.Directory, "Hives", entryAttributes)
{
ModificationTime = modificationTime
};
writer.WriteEntry(entry);
}

} // Dispose of the TarWriter before getting the hash so the final data get written to the tar stream
} // Dispose of the TarWriter and the normalizing stream before getting the hash so the final data get written to the tar stream

int bytesWritten = gz.GetCurrentUncompressedHash(uncompressedHash);
Debug.Assert(bytesWritten == uncompressedHash.Length);
Expand All @@ -150,7 +166,7 @@ public static Layer FromDirectory(string directory, string containerPath, bool i
Debug.Assert(bW == hash.Length);

// Writes a tar entry corresponding to the file system item.
static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string containerPath, IEnumerable<KeyValuePair<string, string>> entryAttributes, int? userId)
static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string containerPath, IEnumerable<KeyValuePair<string, string>> entryAttributes, int? userId, DateTimeOffset modificationTime)
{
UnixFileMode mode = DetermineFileMode(file);
PaxTarEntry entry;
Expand All @@ -169,6 +185,7 @@ static void WriteTarEntryForFile(TarWriter writer, FileSystemInfo file, string c
}

entry.Mode = mode;
entry.ModificationTime = modificationTime;
if (userId is int uid)
{
entry.Uid = uid;
Expand Down
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.&lt;process id&gt;/.</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;
}
}
}
Comment thread
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 src/Containers/Microsoft.NET.Build.Containers/SourceDateEpoch.cs
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
Comment thread
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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -145,11 +145,19 @@
<ContainerVersion Condition="'$(ContainerVersion)' == '' and '$(PackageVersion)' != ''">$(PackageVersion)</ContainerVersion>
<ContainerLicenseExpression Condition="'$(ContainerLicenseExpression)' == '' and '$(PackageLicenseExpression)' != ''">$(PackageLicenseExpression)</ContainerLicenseExpression>
<ContainerTitle Condition="'$(ContainerTitle)' == '' and '$(Title)' != ''">$(Title)</ContainerTitle>
<!-- SOURCE_DATE_EPOCH (https://reproducible-builds.org/docs/source-date-epoch/) pins the creation
label so that rebuilding the same source produces the same image. -->
<_ContainerSourceDateEpoch>$([System.Environment]::GetEnvironmentVariable('SOURCE_DATE_EPOCH'))</_ContainerSourceDateEpoch>
Comment thread
baronfel marked this conversation as resolved.
Outdated
<!-- A value that is not a non-negative integer is ignored rather than failing the build, matching
the specification and the behavior of the tasks. -->
<_ContainerSourceDateEpoch Condition="'$(_ContainerSourceDateEpoch)' != '' and !$([System.Text.RegularExpressions.Regex]::IsMatch($(_ContainerSourceDateEpoch), '^[0-9]+$'))"></_ContainerSourceDateEpoch>
<_ContainerImageCreated Condition="'$(_ContainerSourceDateEpoch)' == ''">$([System.DateTime]::UtcNow.ToString('o'))</_ContainerImageCreated>
<_ContainerImageCreated Condition="'$(_ContainerSourceDateEpoch)' != ''">$([System.DateTimeOffset]::FromUnixTimeSeconds($(_ContainerSourceDateEpoch)).UtcDateTime.ToString('o'))</_ContainerImageCreated>
Comment thread
jetersen marked this conversation as resolved.
Outdated
</PropertyGroup>

<!-- Labels generated from descriptions from the spec at https://github.com/opencontainers/image-spec/blob/main/annotations.md#pre-defined-annotation-keys -->
<ItemGroup Label="Conventional Label assignment" Condition="'$(ContainerGenerateLabels)' == 'true'">
<ContainerLabel Condition="'$(ContainerGenerateLabelsImageCreated)' == 'true'" Include="org.opencontainers.image.created;org.opencontainers.artifact.created" Value="$([System.DateTime]::UtcNow.ToString('o'))" />
<ContainerLabel Condition="'$(ContainerGenerateLabelsImageCreated)' == 'true'" Include="org.opencontainers.image.created;org.opencontainers.artifact.created" Value="$(_ContainerImageCreated)" />
<ContainerLabel Condition="'$(ContainerGenerateLabelsImageDescription)' == 'true' and '$(ContainerDescription)' != ''" Include="org.opencontainers.artifact.description;org.opencontainers.image.description" Value="$(ContainerDescription)" />
<ContainerLabel Condition="'$(ContainerGenerateLabelsImageAuthors)' == 'true' and '$(ContainerAuthors)' != ''" Include="org.opencontainers.image.authors" Value="$(ContainerAuthors)" />
<ContainerLabel Condition="'$(ContainerGenerateLabelsImageUrl)' == 'true' and '$(ContainerInformationUrl)' != ''" Include="org.opencontainers.image.url" Value="$(ContainerInformationUrl)" />
Expand Down
Loading
Loading