Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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,21 @@
<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. The digit count is bounded because
DateTimeOffset.FromUnixTimeSeconds throws for values outside its supported range; 11 digits is
comfortably inside it (the maximum it accepts is 253402300799). -->
<_ContainerSourceDateEpoch Condition="'$(_ContainerSourceDateEpoch)' != '' and !$([System.Text.RegularExpressions.Regex]::IsMatch('$(_ContainerSourceDateEpoch)', '^[0-9]{1,11}$'))"></_ContainerSourceDateEpoch>
<_ContainerImageCreated Condition="'$(_ContainerSourceDateEpoch)' == ''">$([System.DateTime]::UtcNow.ToString('o'))</_ContainerImageCreated>
<_ContainerImageCreated Condition="'$(_ContainerSourceDateEpoch)' != ''">$([System.DateTimeOffset]::FromUnixTimeSeconds($(_ContainerSourceDateEpoch)).UtcDateTime.ToString('o'))</_ContainerImageCreated>
</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