diff --git a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs index 684194e2ab73..3a398721e516 100644 --- a/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs +++ b/src/Installer/Microsoft.Dotnet.Installation/Internal/DotnetArchiveExtractor.cs @@ -303,57 +303,51 @@ private static string ResolveEntryDestPath(string entryName, string targetDir, M /// private static void ExtractTarArchive(string archivePath, string targetDir, IProgressTask? installTask, MuxerHandler? muxerHandler = null, Action? onEntryExtracted = null, Func? shouldSkipEntry = null) { - string decompressedPath = DecompressTarGzIfNeeded(archivePath, out bool needsDecompression); + bool isGzip = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - try - { - long totalEntries = CountTarEntries(decompressedPath); - installTask?.MaxValue = totalEntries > 0 ? totalEntries : 1; + // Hold a single read handle on the archive for the entire extraction. FileShare.Read + // keeps the file locked against deletion on Windows, and on Unix an open handle keeps the + // underlying data readable even if the path is unlinked. Either way the scratch directory + // cannot be reaped out from under us (e.g. by CI temp cleanup) in the window between the + // entry-count pass and the extraction pass. Both passes seek this shared stream back to the + // start and wrap it in a fresh, non-owning GZipStream/TarReader so the handle stays open. + using var archiveStream = new FileStream(archivePath, FileMode.Open, FileAccess.Read, FileShare.Read); - ExtractTarContents(decompressedPath, targetDir, installTask, muxerHandler, onEntryExtracted, shouldSkipEntry); - } - finally - { - // Clean up temporary decompressed file - if (needsDecompression && File.Exists(decompressedPath)) - { - File.Delete(decompressedPath); - } - } + long totalEntries = CountTarEntries(archiveStream, isGzip); + installTask?.MaxValue = totalEntries > 0 ? totalEntries : 1; + + archiveStream.Seek(0, SeekOrigin.Begin); + ExtractTarContents(archiveStream, isGzip, targetDir, installTask, muxerHandler, onEntryExtracted, shouldSkipEntry); } /// - /// Decompresses a .tar.gz file if needed, returning the path to the tar file. + /// Wraps an already-open tar archive stream for reading, layering in gzip decompression when + /// needed. The returned reader leaves open so a single file + /// handle can be reused across multiple passes; callers dispose the returned stream only when + /// it is a gzip wrapper (never the shared archive stream). /// - private static string DecompressTarGzIfNeeded(string archivePath, out bool needsDecompression) - { - needsDecompression = archivePath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); - if (!needsDecompression) - { - return archivePath; - } - - string decompressedPath = archivePath.Replace(".gz", "", StringComparison.Ordinal); - - using FileStream originalFileStream = File.OpenRead(archivePath); - using FileStream decompressedFileStream = File.Create(decompressedPath); - using GZipStream decompressionStream = new GZipStream(originalFileStream, CompressionMode.Decompress); - decompressionStream.CopyTo(decompressedFileStream); - - return decompressedPath; - } + private static Stream OpenTarReadStream(Stream archiveStream, bool isGzip) + => isGzip ? new GZipStream(archiveStream, CompressionMode.Decompress, leaveOpen: true) : archiveStream; /// - /// Counts the number of entries in a tar file for progress reporting. + /// Counts the number of entries in a tar archive for progress reporting. /// - private static long CountTarEntries(string tarPath) + private static long CountTarEntries(Stream archiveStream, bool isGzip) { long totalFiles = 0; - using var tarStream = File.OpenRead(tarPath); - var tarReader = new TarReader(tarStream); - while (tarReader.GetNextEntry() is not null) + Stream tarStream = OpenTarReadStream(archiveStream, isGzip); + try { - totalFiles++; + using var tarReader = new TarReader(tarStream, leaveOpen: true); + while (tarReader.GetNextEntry() is not null) + { + totalFiles++; + } + } + finally + { + // Only dispose the gzip wrapper; the shared archive stream stays open for the next pass. + if (isGzip) { tarStream.Dispose(); } } return totalFiles; } @@ -364,27 +358,42 @@ private static long CountTarEntries(string tarPath) /// internal static void ExtractTarContents(string tarPath, string targetDir, IProgressTask? installTask, MuxerHandler? muxerHandler = null, Action? onEntryExtracted = null, Func? shouldSkipEntry = null) { - using var tarStream = File.OpenRead(tarPath); - var tarReader = new TarReader(tarStream); - TarEntry? entry; - - // Defer hard link creation until after all regular files are extracted, - // since the target file may not exist yet when the hard link entry is encountered. - var deferredHardLinks = new List<(string DestPath, string TargetPath)>(); + bool isGzip = tarPath.EndsWith(".gz", StringComparison.OrdinalIgnoreCase); + using var archiveStream = new FileStream(tarPath, FileMode.Open, FileAccess.Read, FileShare.Read); + ExtractTarContents(archiveStream, isGzip, targetDir, installTask, muxerHandler, onEntryExtracted, shouldSkipEntry); + } - while ((entry = tarReader.GetNextEntry()) is not null) + private static void ExtractTarContents(Stream archiveStream, bool isGzip, string targetDir, IProgressTask? installTask, MuxerHandler? muxerHandler, Action? onEntryExtracted, Func? shouldSkipEntry) + { + Stream tarStream = OpenTarReadStream(archiveStream, isGzip); + try { - bool skip = shouldSkipEntry?.Invoke(entry.Name) ?? false; - if (!skip) + using var tarReader = new TarReader(tarStream, leaveOpen: true); + TarEntry? entry; + + // Defer hard link creation until after all regular files are extracted, + // since the target file may not exist yet when the hard link entry is encountered. + var deferredHardLinks = new List<(string DestPath, string TargetPath)>(); + + while ((entry = tarReader.GetNextEntry()) is not null) { - ProcessTarEntry(entry, targetDir, muxerHandler, deferredHardLinks); + bool skip = shouldSkipEntry?.Invoke(entry.Name) ?? false; + if (!skip) + { + ProcessTarEntry(entry, targetDir, muxerHandler, deferredHardLinks); + } + + onEntryExtracted?.Invoke(entry.Name); + installTask?.Value += 1; } - onEntryExtracted?.Invoke(entry.Name); - installTask?.Value += 1; + CreateDeferredHardLinks(deferredHardLinks); + } + finally + { + // Only dispose the gzip wrapper; the shared archive stream is owned by the caller. + if (isGzip) { tarStream.Dispose(); } } - - CreateDeferredHardLinks(deferredHardLinks); } private static void ProcessTarEntry(TarEntry entry, string targetDir, MuxerHandler? muxerHandler, List<(string DestPath, string TargetPath)> deferredHardLinks) diff --git a/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs index 44b7a2884195..2f0ab5e5adc6 100644 --- a/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs +++ b/test/dotnetup.Tests/DotnetArchiveExtractorTests.cs @@ -461,6 +461,45 @@ public void Commit_ExtractsTarGzArchive_Correctly() File.ReadAllText(runtimeFile).Should().Be("runtime-content"); } + [Fact] + public void Commit_ExtractsTarGzArchive_WhenDecompressedTarPathAlreadyExists() + { + using var testEnv = DotnetupTestUtilities.CreateTestEnvironment(); + + var installRoot = new DotnetInstallRoot(testEnv.InstallPath, InstallerUtilities.GetDefaultInstallArchitecture()); + var version = new ReleaseVersion(9, 0, 0); + + var request = new DotnetInstallRequest( + installRoot, + new UpdateChannel("9.0"), + InstallComponent.Runtime, + new InstallRequestOptions()); + + byte[] tarGzContent = CreateTarGzWithFiles( + ("shared/Microsoft.NETCore.App/9.0.0/System.Runtime.dll", "runtime-content")); + + var mockDownloader = new MockArchiveDownloader + { + CreateFakeArchive = true, + FakeArchiveContent = tarGzContent, + ArchiveFileExtension = ".tar.gz" + }; + + var releaseManifest = new ReleaseManifest(); + var progressTarget = new NullProgressTarget(); + + using var extractor = new DotnetArchiveExtractor(request, version, releaseManifest, progressTarget, mockDownloader); + + extractor.Prepare(); + Directory.CreateDirectory(mockDownloader.DownloadCalls[0].DestinationPath.Replace(".gz", "", StringComparison.Ordinal)); + + extractor.Commit(); + + var runtimeFile = Path.Combine(testEnv.InstallPath, "shared", "Microsoft.NETCore.App", "9.0.0", "System.Runtime.dll"); + File.Exists(runtimeFile).Should().BeTrue("tar.gz extraction should not depend on a sibling decompressed .tar staging file"); + File.ReadAllText(runtimeFile).Should().Be("runtime-content"); + } + [PlatformSpecificFact(TestPlatforms.Windows)] public void Commit_ExtractsZipArchive_Correctly() {