Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
54 changes: 37 additions & 17 deletions src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
using Xunit.Abstractions;
using Microsoft.DotNet.Build.Tasks.Installers;
using Microsoft.DotNet.StrongName;
using System.Formats.Tar;

namespace Microsoft.DotNet.SignTool.Tests
{
Expand Down Expand Up @@ -584,6 +585,30 @@ private void ValidateProducedTarGZipContent(
}
}

private void ValidateProducedTarGZipHardlinks(
string tarGZipPath,
(string path, string linkName)[] expectedHardlinks)
{
using FileStream stream = File.OpenRead(tarGZipPath);
using GZipStream decompressor = new(stream, CompressionMode.Decompress);
using TarReader reader = new(decompressor);

var hardlinkEntries = new List<(string Name, string LinkName)>();
while (reader.GetNextEntry() is TarEntry entry)
{
if (entry.EntryType == TarEntryType.HardLink)
{
hardlinkEntries.Add((entry.Name, entry.LinkName));
}
}

foreach ((string path, string linkName) in expectedHardlinks)
{
hardlinkEntries.Should().Contain((Name: path, LinkName: linkName),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: If possible I'd make this stronger and check that no additional hard links are present. I think that awesomeassertions has a nice primitive for this kind of assert.

$"hardlink '{path}' -> '{linkName}' should be preserved in the repacked tarball");
}
}

[Fact]
public void EmptySigningList()
{
Expand Down Expand Up @@ -1756,8 +1781,8 @@ public void SignTarGZipFile()

/// <summary>
/// Validates that tar.gz archives containing symbolic links are handled correctly.
/// On Windows, ReadTarGZipEntriesWithExternalTar throws when symlinks are detected,
/// so this test is skipped on Windows.
/// This test is Unix-only because creating symlinks on Windows requires elevation
/// and there are no signing scenarios that use symbolic links on Windows.
/// </summary>
[UnixOnlyFactAttribute]
public void SignTarGZipFileWithSymlinks()
Expand Down Expand Up @@ -1794,8 +1819,7 @@ public void SignTarGZipFileWithSymlinks()
});
}

// TODO: Remove WindowsOnlyFact once https://github.com/dotnet/arcade/issues/16484 is resolved.
[WindowsOnlyFact]
[Fact]
public void SignTarGZipFileWithHardlinks()
{
// List of files to be considered for signing
Comment thread
MichaelSimons marked this conversation as resolved.
Expand All @@ -1813,19 +1837,16 @@ public void SignTarGZipFileWithHardlinks()
// Overriding information
var fileSignInfo = new Dictionary<ExplicitSignInfoKey, FileSignInfoEntry>();

// All three files (original + 2 hardlinks) should be detected for signing
// Only the regular file (hardlink1.dll) is detected for signing.
// Hardlink entries are skipped (like symlinks) and preserved during repack.
ValidateFileSignInfos(itemsToSign, strongNameSignInfo, fileSignInfo, s_fileExtensionSignInfo, new[]
{
"File 'hardlink1.dll' TargetFramework='.NETStandard,Version=v2.0' Certificate='ArcadeCertTest' StrongName='ArcadeStrongTest'",
"File 'hardlink2.dll' TargetFramework='.NETStandard,Version=v2.0' Certificate='ArcadeCertTest' StrongName='ArcadeStrongTest'",
"File 'original.dll' TargetFramework='.NETStandard,Version=v2.0' Certificate='ArcadeCertTest' StrongName='ArcadeStrongTest'",
"File 'testHardlinks.tgz'",
},
expectedWarnings: new[]
{
$@"SIGN004: Signing 3rd party library '{Path.Combine(_tmpDir, "ContainerSigning", "0", "hardlink1.dll")}' with Microsoft certificate 'ArcadeCertTest'. The library is considered 3rd party library due to its copyright: ''.",
$@"SIGN004: Signing 3rd party library '{Path.Combine(_tmpDir, "ContainerSigning", "1", "hardlink2.dll")}' with Microsoft certificate 'ArcadeCertTest'. The library is considered 3rd party library due to its copyright: ''.",
$@"SIGN004: Signing 3rd party library '{Path.Combine(_tmpDir, "ContainerSigning", "2", "original.dll")}' with Microsoft certificate 'ArcadeCertTest'. The library is considered 3rd party library due to its copyright: ''.",
});

ValidateGeneratedProject(itemsToSign, strongNameSignInfo, fileSignInfo, s_fileExtensionSignInfo, new[]
Expand All @@ -1835,16 +1856,15 @@ public void SignTarGZipFileWithHardlinks()
<Authenticode>ArcadeCertTest</Authenticode>
<StrongName>ArcadeStrongTest</StrongName>
</FilesToSign>
<FilesToSign Include=""{Uri.EscapeDataString(Path.Combine(_tmpDir, "ContainerSigning", "1", "hardlink2.dll"))}"">
<Authenticode>ArcadeCertTest</Authenticode>
<StrongName>ArcadeStrongTest</StrongName>
</FilesToSign>
<FilesToSign Include=""{Uri.EscapeDataString(Path.Combine(_tmpDir, "ContainerSigning", "2", "original.dll"))}"">
<Authenticode>ArcadeCertTest</Authenticode>
<StrongName>ArcadeStrongTest</StrongName>
</FilesToSign>
"
});

// Verify that hardlinks are preserved in the repacked tarball
ValidateProducedTarGZipHardlinks(Path.Combine(_tmpDir, "testHardlinks.tgz"), new[]
{
("hardlink2.dll", "hardlink1.dll"),
("original.dll", "hardlink1.dll"),
});
}

[Fact]
Expand Down
113 changes: 1 addition & 112 deletions src/Microsoft.DotNet.SignTool/src/ZipData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,9 @@ public static IEnumerable<ZipDataEntry> ReadEntries(string archivePath, string t
{
if (FileSignInfo.IsTarGZip(archivePath))
{
// TODO: Remove workaround for https://github.com/dotnet/arcade/issues/16484
// Hardlinks are used on Windows but System.Formats.Tar doesn't fully support them yet.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
return ReadTarGZipEntriesWithExternalTar(archivePath, tempDir, log, ignoreContent);
}

return ReadTarGZipEntries(archivePath)
.Where(static entry => entry.EntryType != TarEntryType.SymbolicLink &&
entry.EntryType != TarEntryType.HardLink &&
entry.EntryType != TarEntryType.Directory)
.Select(static entry => new ZipDataEntry(entry.Name, entry.DataStream, entry.Length)
Comment thread
MichaelSimons marked this conversation as resolved.
{
Expand Down Expand Up @@ -394,14 +388,6 @@ private void RepackPkgOrAppBundles(TaskLoggingHelper log, string tempDir, string

private void RepackTarGZip(TaskLoggingHelper log, string tempDir)
{
// TODO: Remove workaround for https://github.com/dotnet/arcade/issues/16484
// Hardlinks are used on Windows but System.Formats.Tar doesn't fully support them yet.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
RepackTarGZipWithExternalTar(log, tempDir);
return;
}

using MemoryStream streamToCompress = new();
using (TarWriter writer = new(streamToCompress, leaveOpen: true))
{
Expand Down Expand Up @@ -437,103 +423,6 @@ private void RepackTarGZip(TaskLoggingHelper log, string tempDir)
}
}

/// <summary>
/// Read tar.gz entries using external tar.exe to properly handle hardlinks.
/// Windows tarballs use hardlinks for deduplication, which System.Formats.Tar doesn't yet support.
/// When tar.exe extracts hardlinks, they become regular files with the same content.
/// </summary>
private static IEnumerable<ZipDataEntry> ReadTarGZipEntriesWithExternalTar(string archivePath, string tempDir, TaskLoggingHelper log, bool ignoreContent)
{
string extractDir = Path.Combine(tempDir, Guid.NewGuid().ToString());
Directory.CreateDirectory(extractDir);

try
{
// Extract the tarball - tar.exe will resolve hardlinks to regular files
if (!RunExternalProcess(log, "tar", $"-xzf \"{archivePath}\" -C \"{extractDir}\"", out _))
{
throw new Exception($"Failed to extract tar archive: {archivePath}");
}

foreach (var path in Directory.EnumerateFiles(extractDir, "*", SearchOption.AllDirectories))
{
// Symbolic links require elevated permissions to create on Windows. They should not be used therefore they are not supported.
if (new FileInfo(path).LinkTarget != null)
{
throw new InvalidOperationException(
$"Symbolic link detected in tar archive '{archivePath}': '{path}'. " +
$"Tarballs containing symbolic links are not supported for signing on Windows.");
}

string relativePath = path.Substring(extractDir.Length + 1).Replace(Path.DirectorySeparatorChar, '/');
using var stream = ignoreContent ? null : (Stream)File.Open(path, FileMode.Open);
yield return new ZipDataEntry(relativePath, stream);
}
}
finally
{
if (Directory.Exists(extractDir))
{
Directory.Delete(extractDir, recursive: true);
}
}
}

/// <summary>
/// Repack tar.gz using external tar.exe to preserve hardlinks.
/// Windows tarballs use hardlinks for deduplication, which System.Formats.Tar doesn't yet support.
/// </summary>
private void RepackTarGZipWithExternalTar(TaskLoggingHelper log, string tempDir)
{
string extractDir = Path.Combine(tempDir, Guid.NewGuid().ToString());
Directory.CreateDirectory(extractDir);

try
{
// Extract the tarball - tar.exe will recreate hardlinks
if (!RunExternalProcess(log, "tar", $"-xzf \"{FileSignInfo.FullPath}\" -C \"{extractDir}\"", out _))
{
log.LogError($"Failed to extract tar archive: {FileSignInfo.FullPath}");
return;
}

// Replace signed files in the extracted directory
foreach (var path in Directory.EnumerateFiles(extractDir, "*", SearchOption.AllDirectories))
{
// Symbolic links are not supported by the external tar.exe signing path.
if (new FileInfo(path).LinkTarget != null)
{
throw new InvalidOperationException(
$"Symbolic link detected in tar archive '{FileSignInfo.FullPath}': '{path}'. " +
$"Tarballs containing symbolic links are not supported for signing on Windows.");
}

string relativePath = path.Substring(extractDir.Length + 1).Replace(Path.DirectorySeparatorChar, '/');
ZipPart? signedPart = FindNestedPart(relativePath);

if (signedPart.HasValue)
{
log.LogMessage(MessageImportance.Low, $"Copying signed file from {signedPart.Value.FileSignInfo.FullPath} to {FileSignInfo.FullPath} -> {relativePath}");
File.Copy(signedPart.Value.FileSignInfo.FullPath, path, overwrite: true);
}
}

// Repack the tarball - tar.exe will detect and preserve hardlinks
if (!RunExternalProcess(log, "tar", $"-czf \"{FileSignInfo.FullPath}\" -C \"{extractDir}\" .", out _))
{
log.LogError($"Failed to create tar archive: {FileSignInfo.FullPath}");
return;
}
}
finally
{
if (Directory.Exists(extractDir))
{
Directory.Delete(extractDir, recursive: true);
}
}
}

private static IEnumerable<TarEntry> ReadTarGZipEntries(string path)
{
using FileStream streamToDecompress = File.OpenRead(path);
Expand Down
Loading