From 93151a2e087cadfbe964e58a631508581363232b Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 9 Jun 2026 11:01:00 -0500 Subject: [PATCH 1/4] Remove external tar process workaround for hardlink signing The workaround (dotnet/arcade#16484) used external tar.exe on Windows to handle hardlinks because System.Formats.Tar lacked proper support (dotnet/runtime#74404). Now that the runtime issue is fixed and arcade targets .NET 10+, hardlinks are handled the same way as symlinks: skipped during reading (only the target regular file is signed) and preserved as-is during repack. Changes: - Remove ReadTarGZipEntriesWithExternalTar and RepackTarGZipWithExternalTar private methods from ZipData.cs - Remove Windows-conditional branches in ReadEntries and RepackTarGZip - Add TarEntryType.HardLink to the skip filter in ReadEntries (same treatment as symlinks) - Change SignTarGZipFileWithHardlinks test from [WindowsOnlyFact] to [Fact] - Update test expectations: only the regular file entry is signed; hardlinks are preserved and validated via new helper - Add ValidateProducedTarGZipHardlinks helper to verify hardlinks survive the repack round-trip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../SignToolTests.cs | 50 +++++--- src/Microsoft.DotNet.SignTool/src/ZipData.cs | 113 +----------------- 2 files changed, 36 insertions(+), 127 deletions(-) diff --git a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs index bb25bde1135..c170f15fb51 100644 --- a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs +++ b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs @@ -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 { @@ -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((path, linkName), + $"hardlink '{path}' -> '{linkName}' should be preserved in the repacked tarball"); + } + } + [Fact] public void EmptySigningList() { @@ -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 @@ -1813,19 +1837,16 @@ public void SignTarGZipFileWithHardlinks() // Overriding information var fileSignInfo = new Dictionary(); - // 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[] @@ -1835,16 +1856,15 @@ public void SignTarGZipFileWithHardlinks() ArcadeCertTest ArcadeStrongTest - - ArcadeCertTest - ArcadeStrongTest - - - ArcadeCertTest - ArcadeStrongTest - " }); + + // 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] diff --git a/src/Microsoft.DotNet.SignTool/src/ZipData.cs b/src/Microsoft.DotNet.SignTool/src/ZipData.cs index ae9676caf85..d55413eb9a5 100644 --- a/src/Microsoft.DotNet.SignTool/src/ZipData.cs +++ b/src/Microsoft.DotNet.SignTool/src/ZipData.cs @@ -55,15 +55,9 @@ public static IEnumerable 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) { @@ -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)) { @@ -437,103 +423,6 @@ private void RepackTarGZip(TaskLoggingHelper log, string tempDir) } } - /// - /// 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. - /// - private static IEnumerable 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); - } - } - } - - /// - /// Repack tar.gz using external tar.exe to preserve hardlinks. - /// Windows tarballs use hardlinks for deduplication, which System.Formats.Tar doesn't yet support. - /// - 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 ReadTarGZipEntries(string path) { using FileStream streamToDecompress = File.OpenRead(path); From 07e497ad153040905316be26ef5293c61a0beecf Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 9 Jun 2026 15:22:33 -0500 Subject: [PATCH 2/4] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs index c170f15fb51..c97fbc9406c 100644 --- a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs +++ b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs @@ -604,7 +604,7 @@ private void ValidateProducedTarGZipHardlinks( foreach ((string path, string linkName) in expectedHardlinks) { - hardlinkEntries.Should().Contain((path, linkName), + hardlinkEntries.Should().Contain((Name: path, LinkName: linkName), $"hardlink '{path}' -> '{linkName}' should be preserved in the repacked tarball"); } } From 5267739dcdfca163a87b0f26b16ffd0c1322d455 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 9 Jun 2026 11:01:00 -0500 Subject: [PATCH 3/4] Remove external tar process workaround for hardlink signing The workaround (dotnet/arcade#16484) used external tar.exe on Windows to handle hardlinks because System.Formats.Tar lacked proper support (dotnet/runtime#74404). Now that the runtime issue is fixed and arcade targets .NET 10+, hardlinks are handled the same way as symlinks: skipped during reading (only the target regular file is signed) and preserved as-is during repack. Changes: - Remove ReadTarGZipEntriesWithExternalTar and RepackTarGZipWithExternalTar private methods from ZipData.cs - Remove Windows-conditional branches in ReadEntries and RepackTarGZip - Add TarEntryType.HardLink to the skip filter in ReadEntries (same treatment as symlinks) - Change SignTarGZipFileWithHardlinks test from [WindowsOnlyFact] to [Fact] - Update test expectations: only the regular file entry is signed; hardlinks are preserved and validated via new helper - Add ValidateProducedTarGZipHardlinks helper to verify hardlinks survive the repack round-trip Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs index c97fbc9406c..92dc59d92ef 100644 --- a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs +++ b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs @@ -1781,8 +1781,8 @@ public void SignTarGZipFile() /// /// 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 there are no signing scenarios that use + /// symbolic links on Windows. /// [UnixOnlyFactAttribute] public void SignTarGZipFileWithSymlinks() From ec2436603d0c11396129df856085e9b69da776d0 Mon Sep 17 00:00:00 2001 From: Michael Simons Date: Tue, 9 Jun 2026 15:38:30 -0500 Subject: [PATCH 4/4] Update symlink test doc comment Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs index 92dc59d92ef..d6ae31e00b5 100644 --- a/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs +++ b/src/Microsoft.DotNet.SignTool.Tests/SignToolTests.cs @@ -1781,8 +1781,8 @@ public void SignTarGZipFile() /// /// Validates that tar.gz archives containing symbolic links are handled correctly. - /// This test is Unix-only because there are no signing scenarios that use - /// symbolic links 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. /// [UnixOnlyFactAttribute] public void SignTarGZipFileWithSymlinks()