From fb8c6174b7bc3b2eb4d1dc16a9422afcd482ac98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 11 May 2026 20:28:15 +0200 Subject: [PATCH 1/4] Improve SEO for Microsoft.Testing.Platform (MTP) documentation (#53355) * Improve SEO for Microsoft.Testing.Platform documentation - Add displayName entries to all MTP TOC nodes in toc.yml for better learn.microsoft.com search and sitemap coverage - Add ms.custom: microsoft-testing-platform,MTP to all 26 MTP-related pages for internal search indexing - Improve title and description of test-platforms-overview.md to capture 'MTP vs VSTest' comparison queries - Enrich intro page description with additional search terms (.NET test runner, VSTest alternative, CI pipelines, IDEs) - Improve migration guide description with specific terms (argument mapping, project configuration, CI pipeline updates) * Move ms.custom to docfx.json file glob, simplify to single value Agent-Logs-Url: https://github.com/dotnet/docs/sessions/322f9b37-006c-4d70-8412-51bd1740a7ff Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- docfx.json | 6 +++- .../microsoft-testing-platform-intro.md | 4 +-- ...ating-vstest-microsoft-testing-platform.md | 2 +- docs/core/testing/test-platforms-overview.md | 4 +-- docs/navigate/devops-testing/toc.yml | 30 ++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docfx.json b/docfx.json index fe1779d7b90cf..a2e90bbe00cba 100644 --- a/docfx.json +++ b/docfx.json @@ -834,7 +834,11 @@ "docs/core/porting/github-copilot-app-modernization/**/**.{md,yml}": "ce-skilling-ai-copilot" }, "ms.custom": { - "docs/ai/**/**.{md,yml}": "devx-track-dotnet" + "docs/ai/**/**.{md,yml}": "devx-track-dotnet", + "docs/core/testing/*microsoft-testing-platform*.md": "microsoft-testing-platform", + "docs/core/testing/test-platforms-overview.md": "microsoft-testing-platform", + "docs/core/testing/unit-testing-with-dotnet-test.md": "microsoft-testing-platform", + "docs/core/tools/dotnet-test-mtp.md": "microsoft-testing-platform" }, "ms.update-cycle": { "docs/ai/**/**.{md,yml}": "180-days", diff --git a/docs/core/testing/microsoft-testing-platform-intro.md b/docs/core/testing/microsoft-testing-platform-intro.md index f89786ba334ce..a24362cdd5560 100644 --- a/docs/core/testing/microsoft-testing-platform-intro.md +++ b/docs/core/testing/microsoft-testing-platform-intro.md @@ -1,6 +1,6 @@ --- -title: Microsoft.Testing.Platform overview -description: Learn about Microsoft.Testing.Platform, a lightweight way to run tests without depending on the .NET SDK. +title: Microsoft.Testing.Platform overview - .NET test runner +description: Learn about Microsoft.Testing.Platform (MTP), a lightweight and portable .NET test runner and VSTest alternative for running unit tests in CI pipelines, CLI, and IDEs. author: Evangelink ms.author: amauryleve ms.date: 03/17/2024 diff --git a/docs/core/testing/migrating-vstest-microsoft-testing-platform.md b/docs/core/testing/migrating-vstest-microsoft-testing-platform.md index d8314b0f6a01c..6ec31331068e1 100644 --- a/docs/core/testing/migrating-vstest-microsoft-testing-platform.md +++ b/docs/core/testing/migrating-vstest-microsoft-testing-platform.md @@ -1,6 +1,6 @@ --- title: Migration guide from VSTest to Microsoft.Testing.Platform (MTP) -description: Learn how to migrate from VSTest to MTP +description: Step-by-step guide to migrate from VSTest to Microsoft.Testing.Platform (MTP), including argument mapping, project configuration, and CI pipeline updates. author: Youssef1313 ms.author: ygerges ms.date: 09/15/2025 diff --git a/docs/core/testing/test-platforms-overview.md b/docs/core/testing/test-platforms-overview.md index 9813e58e98418..1b0d004dd72af 100644 --- a/docs/core/testing/test-platforms-overview.md +++ b/docs/core/testing/test-platforms-overview.md @@ -1,6 +1,6 @@ --- -title: Test platforms overview for .NET -description: Learn how VSTest and Microsoft.Testing.Platform (MTP) differ, and choose the right test platform for your .NET test projects. +title: Microsoft.Testing.Platform vs VSTest - .NET test platform comparison +description: Compare Microsoft.Testing.Platform (MTP) and VSTest to choose the right .NET test platform for your projects, CI pipelines, and IDE integration. author: Evangelink ms.author: amauryleve ms.date: 02/24/2026 diff --git a/docs/navigate/devops-testing/toc.yml b/docs/navigate/devops-testing/toc.yml index 94b797e0d2448..1f4b9de4bb2f0 100644 --- a/docs/navigate/devops-testing/toc.yml +++ b/docs/navigate/devops-testing/toc.yml @@ -32,7 +32,7 @@ items: href: ../../devops/dotnet-secure-github-action.md displayName: codeql,security,vulnerability,source scan - name: Testing - displayName: xUnit,NUnit,MSTest,unit test,test,integration test,load test,smoke test, web test + displayName: xUnit,NUnit,MSTest,unit test,test,integration test,load test,smoke test,web test,Microsoft.Testing.Platform,MTP,testing platform,test runner items: - name: Overview href: ../../core/testing/index.md @@ -257,60 +257,88 @@ items: items: - name: Overview href: ../../core/testing/test-platforms-overview.md + displayName: MTP vs VSTest,compare test platforms,choose test platform - name: Microsoft.Testing.Platform + displayName: MTP,testing platform,.NET test runner,VSTest alternative items: - name: Overview href: ../../core/testing/microsoft-testing-platform-intro.md + displayName: MTP,testing platform,test runner,VSTest alternative - name: Run and debug tests href: ../../core/testing/microsoft-testing-platform-run-and-debug.md + displayName: MTP,run tests,debug tests,dotnet test,CI pipeline - name: CLI options reference href: ../../core/testing/microsoft-testing-platform-cli-options.md + displayName: MTP,command line,CLI,options,arguments,switches - name: Configuration href: ../../core/testing/microsoft-testing-platform-config.md + displayName: MTP,testconfig.json,configuration,settings - name: Troubleshooting href: ../../core/testing/microsoft-testing-platform-troubleshooting.md + displayName: MTP,exit codes,errors,troubleshoot - name: Features + displayName: MTP features,extensions,NuGet items: - name: Overview href: ../../core/testing/microsoft-testing-platform-features.md + displayName: MTP features,extensions - name: Terminal output href: ../../core/testing/microsoft-testing-platform-terminal-output.md + displayName: MTP,terminal,test reporter,ANSI,progress - name: Test reports href: ../../core/testing/microsoft-testing-platform-test-reports.md + displayName: MTP,TRX,test report,Azure DevOps - name: Code coverage href: ../../core/testing/microsoft-testing-platform-code-coverage.md + displayName: MTP,code coverage,coverage - name: Crash and hang dumps href: ../../core/testing/microsoft-testing-platform-crash-hang-dumps.md + displayName: MTP,crash dump,hang dump,diagnostics - name: OpenTelemetry href: ../../core/testing/microsoft-testing-platform-open-telemetry.md + displayName: MTP,OpenTelemetry,traces,metrics - name: Retry href: ../../core/testing/microsoft-testing-platform-retry.md + displayName: MTP,retry,flaky tests - name: Hot Reload href: ../../core/testing/microsoft-testing-platform-hot-reload.md + displayName: MTP,hot reload,live reload - name: Microsoft Fakes href: ../../core/testing/microsoft-testing-platform-fakes.md + displayName: MTP,fakes,shims,stubs - name: Telemetry href: ../../core/testing/microsoft-testing-platform-telemetry.md + displayName: MTP,telemetry,opt out - name: Migration + displayName: MTP,migrate,VSTest migration items: - name: Migrate from VSTest href: ../../core/testing/migrating-vstest-microsoft-testing-platform.md + displayName: MTP,VSTest,migration,migrate,switch - name: Migrate from MTP v1 to v2 href: ../../core/testing/microsoft-testing-platform-migration-from-v1-to-v2.md + displayName: MTP v2,upgrade,breaking changes - name: Create custom extensions + displayName: MTP,extensibility,custom extensions items: - name: Overview href: ../../core/testing/microsoft-testing-platform-architecture.md + displayName: MTP,architecture,custom test framework,extensibility - name: Build a test framework href: ../../core/testing/microsoft-testing-platform-architecture-test-framework.md + displayName: MTP,custom test framework,ITestFramework - name: VSTest Bridge href: ../../core/testing/microsoft-testing-platform-extensions-vstest-bridge.md + displayName: MTP,VSTest bridge,backward compatibility - name: Build extensions href: ../../core/testing/microsoft-testing-platform-architecture-extensions.md + displayName: MTP,extensions,in-process,out-of-process - name: Capabilities href: ../../core/testing/microsoft-testing-platform-architecture-capabilities.md + displayName: MTP,capabilities - name: Services href: ../../core/testing/microsoft-testing-platform-architecture-services.md + displayName: MTP,services,IServiceProvider - name: VSTest href: https://github.com/microsoft/vstest - name: Testing with 'dotnet test' From bc3f90044e920531a61ab1cbf3b35a60a2b51e5e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 15:17:33 -0700 Subject: [PATCH 2/4] Fix shebang recommendation in file-based apps docs (#53566) * Initial plan * Fix incorrect shebang recommendation in file-based apps docs Agent-Logs-Url: https://github.com/dotnet/docs/sessions/686c76cc-7134-414a-84d4-cc62a14aa6c1 Co-authored-by: adegeo <67293991+adegeo@users.noreply.github.com> * Rewrites and found another spot * Revise env -S description to be accurate yet effect-focused Agent-Logs-Url: https://github.com/dotnet/docs/sessions/10366416-b55e-4b2e-b4ae-0cc021c5c14e Co-authored-by: adegeo <67293991+adegeo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: adegeo <67293991+adegeo@users.noreply.github.com> Co-authored-by: Andy De George (from Dev Box) --- docs/core/sdk/file-based-apps.md | 7 ++++++- .../fundamentals/tutorials/file-based-programs.md | 12 +++++------- .../snippets/file-based-programs/AsciiArt.cs | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/docs/core/sdk/file-based-apps.md b/docs/core/sdk/file-based-apps.md index 0ff2a73c00b4f..ba34376076b6c 100644 --- a/docs/core/sdk/file-based-apps.md +++ b/docs/core/sdk/file-based-apps.md @@ -318,7 +318,7 @@ Enable direct execution of file-based apps on Unix-like systems by using a sheba Add a shebang at the top of your file: ```csharp -#!/usr/bin/env dotnet +#!/usr/bin/env -S dotnet -- #:package Spectre.Console using Spectre.Console; @@ -341,6 +341,11 @@ Run directly: > [!NOTE] > Use `LF` line endings instead of `CRLF` when you add a shebang. Don't include a BOM in the file. +To prevent `dotnet` from consuming arguments that match its own parameters (such as `--help`), the shebang uses `--` as a separator. The `--` separator tells `dotnet` to forward all subsequent command-line arguments directly to your app. The `-S` flag lets `env` split the remaining text into separate arguments so you can include `--` in the shebang. + +> [!NOTE] +> If `-S` isn't supported on your system, use `#!/usr/bin/env dotnet` instead. With this shebang, `dotnet` might consume arguments that match its own CLI parameters. + ## Implicit build files File-based apps respect MSBuild and NuGet configuration files in the same directory or parent directories. These files affect how the SDK builds your application. Be mindful of these files when organizing your file-based apps. diff --git a/docs/csharp/fundamentals/tutorials/file-based-programs.md b/docs/csharp/fundamentals/tutorials/file-based-programs.md index 438a256004d59..33c8ab387b244 100644 --- a/docs/csharp/fundamentals/tutorials/file-based-programs.md +++ b/docs/csharp/fundamentals/tutorials/file-based-programs.md @@ -10,6 +10,7 @@ ai-usage: ai-assisted # Tutorial: Build file-based C# programs *File-based apps* are programs contained within a single `*.cs` file that you build and run without a corresponding project (`*.csproj`) file. File-based apps are ideal for learning C# because they have less complexity: The entire program is stored in a single file. File-based apps are also useful for building command line utilities. On Unix platforms, you can run file-based apps by using `#!` (shebang) [directives](../../language-reference/preprocessor-directives.md). + In this tutorial, you: > [!div class="checklist"] @@ -80,16 +81,13 @@ On Unix, you can execute file-based apps directly using just the source file nam 1. Add a shebang (`#!`) directive as the first line of the `AsciiArt.cs` file: ```csharp - #!/usr/local/share/dotnet/dotnet + #!/usr/bin/env -S dotnet -- ``` -The location of `dotnet` can be different on different Unix installations. Use the command `which dotnet` to locate the `dotnet` host in your environment. - -Alternatively, you can use `#!/usr/bin/env dotnet` to resolve the dotnet path from the PATH environment variable automatically: + This shebang uses `env` to find `dotnet` in the PATH environment. The `-S` parameter enables `env` to pass `dotnet` and `--` as separate arguments. The `--` ensures that any arguments a user provides are passed directly to your app, preventing `dotnet` from consuming them by mistake. -```csharp -#!/usr/bin/env dotnet -``` + > [!TIP] + > If the previous shebang doesn't work, try `#!/usr/bin/env dotnet` or the exact location of `dotnet`, for example `#!/usr/local/share/dotnet/dotnet --`. After making these two changes, you can run the program directly: diff --git a/docs/csharp/fundamentals/tutorials/snippets/file-based-programs/AsciiArt.cs b/docs/csharp/fundamentals/tutorials/snippets/file-based-programs/AsciiArt.cs index f2870efbdb7bf..9799bfa3b64a4 100755 --- a/docs/csharp/fundamentals/tutorials/snippets/file-based-programs/AsciiArt.cs +++ b/docs/csharp/fundamentals/tutorials/snippets/file-based-programs/AsciiArt.cs @@ -1,4 +1,4 @@ -#!/usr/bin/env dotnet +#!/usr/bin/env -S dotnet -- // #:package Colorful.Console@1.2.15 From adde5489543f675f9157edb26d1f0d37cf552611 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Mon, 11 May 2026 15:31:40 -0700 Subject: [PATCH 3/4] Fix file-existence validation bypassed when --file is provided explicitly (#52974) --- .../commandline/get-started-tutorial.md | 7 +++---- .../csharp/Stage3/Program.cs | 19 +++++-------------- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/docs/standard/commandline/get-started-tutorial.md b/docs/standard/commandline/get-started-tutorial.md index d0bfd241287f0..7e943ade204d0 100644 --- a/docs/standard/commandline/get-started-tutorial.md +++ b/docs/standard/commandline/get-started-tutorial.md @@ -340,11 +340,10 @@ scl quotes delete --search-terms David "You can do" Antoine "Perfection is achie :::code language="csharp" source="snippets/get-started-tutorial/csharp/Stage3/Program.cs" id="fileoption" ::: - This code uses to provide custom parsing, validation, and error handling. + This code uses two separate mechanisms: - Without this code, missing files are reported with an exception and stack trace. With this code just the specified error message is displayed. - - This code also specifies a default value, which is why it sets to custom parsing method. + * supplies `sampleQuotes.txt` as the default when you don't provide `--file`. `DefaultValueFactory` doesn't run when you do provide `--file`. + * runs when you explicitly provide `--file`. It converts the token to a `FileInfo` and validates that the file exists. Without validation, a missing file would cause an unhandled `FileNotFoundException` with a stack trace. With validation, just the specified error message is displayed. 1. After the code that creates `lightModeOption`, add options and arguments for the `add` and `delete` commands: diff --git a/docs/standard/commandline/snippets/get-started-tutorial/csharp/Stage3/Program.cs b/docs/standard/commandline/snippets/get-started-tutorial/csharp/Stage3/Program.cs index f0f96143fd75b..62a291c6179c8 100644 --- a/docs/standard/commandline/snippets/get-started-tutorial/csharp/Stage3/Program.cs +++ b/docs/standard/commandline/snippets/get-started-tutorial/csharp/Stage3/Program.cs @@ -11,24 +11,15 @@ static int Main(string[] args) Option fileOption = new("--file") { Description = "An option whose argument is parsed as a FileInfo", - Required = true, - DefaultValueFactory = result => + DefaultValueFactory = _ => new FileInfo("sampleQuotes.txt"), + CustomParser = result => { - if (result.Tokens.Count == 0) - { - return new FileInfo("sampleQuotes.txt"); - - } - string filePath = result.Tokens.Single().Value; - if (!File.Exists(filePath)) + var file = new FileInfo(result.Tokens.Single().Value); + if (!file.Exists) { result.AddError("File does not exist"); - return null; - } - else - { - return new FileInfo(filePath); } + return file; } }; // From 401af4f3d5a8acb4939a41715044de1c1245a1de Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Tue, 12 May 2026 09:40:57 +0200 Subject: [PATCH 4/4] Add Compression best practices guide (#52968) * set up compression guide * address comments * address comments * nitpicks * update check and comments * Apply suggestions from copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * remove unnecessary sentence * address comments * Basic restructure Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Replace * in xref; restructure Choose right API * Try to rework other xref display strings to avoid confusing reader * Update zip-tar-best-practices.md --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Andy De George (from Dev Box) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Andy (Steve) De George <67293991+adegeo@users.noreply.github.com> --- docs/fundamentals/toc.yml | 2 + .../zip-tar-best-practices/csharp/Program.cs | 269 ++++++++++++++ .../csharp/Project.csproj | 11 + docs/standard/io/zip-tar-best-practices.md | 349 ++++++++++++++++++ 4 files changed, 631 insertions(+) create mode 100644 docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs create mode 100644 docs/standard/io/snippets/zip-tar-best-practices/csharp/Project.csproj create mode 100644 docs/standard/io/zip-tar-best-practices.md diff --git a/docs/fundamentals/toc.yml b/docs/fundamentals/toc.yml index 3394d08173dff..0f12cf034b3b4 100644 --- a/docs/fundamentals/toc.yml +++ b/docs/fundamentals/toc.yml @@ -967,6 +967,8 @@ items: href: ../standard/io/how-to-add-or-remove-access-control-list-entries.md - name: "How to: Compress and Extract Files" href: ../standard/io/how-to-compress-and-extract-files.md + - name: ZIP and TAR best practices + href: ../standard/io/zip-tar-best-practices.md - name: Composing Streams href: ../standard/io/composing-streams.md - name: "How to: Convert Between .NET Framework Streams and Windows Runtime Streams" diff --git a/docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs b/docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs new file mode 100644 index 0000000000000..b2c750a9f0788 --- /dev/null +++ b/docs/standard/io/snippets/zip-tar-best-practices/csharp/Program.cs @@ -0,0 +1,269 @@ +using System.Formats.Tar; +using System.IO.Compression; +// +void SafeExtractEntry(ZipArchiveEntry entry, string destinationPath, long maxDecompressedSize) +{ + // The runtime enforces that entry.Open() will never produce more than + // entry.Length bytes, so checking the declared size is sufficient. + if (entry.Length > maxDecompressedSize) + { + throw new InvalidOperationException( + $"Entry '{entry.FullName}' declares size {entry.Length}, exceeding limit {maxDecompressedSize}."); + } + + entry.ExtractToFile(destinationPath, overwrite: false); +} +// + +// +void SafeExtractArchive(ZipArchive archive, string destinationDir, + long maxTotalSize, int maxEntryCount) +{ + // Flat zip bombs can contain many entries that each expand to large sizes. + // Reject the archive early if the entry count exceeds your limit. + if (archive.Entries.Count > maxEntryCount) + { + throw new InvalidOperationException("Archive contains an excessive number of entries."); + } + + long totalExtracted = 0; + foreach (ZipArchiveEntry entry in archive.Entries) + { + totalExtracted = checked(totalExtracted + entry.Length); + if (totalExtracted > maxTotalSize) + { + throw new InvalidOperationException( + $"Archive total decompressed size exceeds the allowed limit of {maxTotalSize} bytes."); + } + // ... extract each entry with per-entry limits too + } +} +// + +// +void ValidatePaths(ZipArchive archive, string destinationDir) +{ + string fullDestDir = Path.GetFullPath(destinationDir); + if (!fullDestDir.EndsWith(Path.DirectorySeparatorChar)) + fullDestDir += Path.DirectorySeparatorChar; + + foreach (ZipArchiveEntry entry in archive.Entries) + { + string destPath = Path.GetFullPath(Path.Join(fullDestDir, entry.FullName)); + + if (!destPath.StartsWith(fullDestDir, StringComparison.Ordinal)) + throw new IOException( + $"Entry '{entry.FullName}' would extract outside the destination directory."); + } +} +// + +// +void DangerousExtract(string extractDir) +{ + // ⚠️ DANGEROUS: entry.FullName could contain "../" sequences + using ZipArchive archive = ZipFile.OpenRead("archive.zip"); + foreach (ZipArchiveEntry entry in archive.Entries) + { + string destinationPath = Path.Combine(extractDir, entry.FullName); + entry.ExtractToFile(destinationPath, overwrite: true); // Might write outside of `extractDir` + } +} +// + +// +void SafeExtractZip(string archivePath, string destinationDir, + long maxTotalSize, long maxEntrySize, int maxEntryCount) +{ + // Resolve the destination to an absolute path and ensure it ends with a + // directory separator. This trailing separator is essential — without it, + // the StartsWith check below could be tricked by paths like + // "/safe-dir-evil/" matching "/safe-dir". + string fullDestDir = Path.GetFullPath(destinationDir); + if (!fullDestDir.EndsWith(Path.DirectorySeparatorChar)) + fullDestDir += Path.DirectorySeparatorChar; + + Directory.CreateDirectory(fullDestDir); + + using var archive = new ZipArchive(File.OpenRead(archivePath), ZipArchiveMode.Read); + + // Check the entry count up front. ZIP central directory is read eagerly, + // so archive.Entries.Count is available immediately without iterating. + if (archive.Entries.Count > maxEntryCount) + throw new InvalidOperationException("Archive contains too many entries."); + + long totalSize = 0; + foreach (ZipArchiveEntry entry in archive.Entries) + { + // Enforce per-entry and cumulative size limits using the declared uncompressed size. + totalSize += entry.Length; + if (entry.Length > maxEntrySize) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' exceeds per-entry size limit."); + if (totalSize > maxTotalSize) + throw new InvalidOperationException("Archive exceeds total size limit."); + + // Resolve the full destination path using Path.GetFullPath, which + // normalizes away any "../" segments. Then verify the result still + // starts with the destination directory. + string destPath = Path.GetFullPath(Path.Join(fullDestDir, entry.FullName)); + if (!destPath.StartsWith(fullDestDir, StringComparison.Ordinal)) + throw new IOException( + $"Entry '{entry.FullName}' would extract outside the destination."); + + // By convention, directory entries in ZIP archives have names ending + // in '/'. Path.GetFileName returns empty for these, so we use that + // to distinguish directories from files. + if (string.IsNullOrEmpty(Path.GetFileName(destPath))) + { + Directory.CreateDirectory(destPath); + } + else + { + // Create the parent directory and any missing intermediate directories. + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + entry.ExtractToFile(destPath, overwrite: false); + } + } +} +// + +// +void SafeExtractTar(Stream archiveStream, string destinationDir, + long maxTotalSize, long maxEntrySize, int maxEntryCount) +{ + // Same trailing-separator technique as the ZIP example. + string fullDestDir = Path.GetFullPath(destinationDir); + if (!fullDestDir.EndsWith(Path.DirectorySeparatorChar)) + fullDestDir += Path.DirectorySeparatorChar; + + Directory.CreateDirectory(fullDestDir); + + using var reader = new TarReader(archiveStream); + TarEntry? entry; + long totalSize = 0; + int entryCount = 0; + + // TAR has no central directory — entries are read one at a time. + // GetNextEntry() returns null when the archive is exhausted. + while ((entry = reader.GetNextEntry()) is not null) + { + if (++entryCount > maxEntryCount) + throw new InvalidOperationException("Archive contains too many entries."); + + if (entry.Length > maxEntrySize) + throw new InvalidOperationException( + $"Entry '{entry.Name}' exceeds per-entry size limit."); + totalSize += entry.Length; + if (totalSize > maxTotalSize) + throw new InvalidOperationException("Archive exceeds total size limit."); + + // Allow-list of entry types to process. Any type not listed here is + // silently skipped. We exclude symlinks, hard links (which can + // escape the destination), and metadata types + // (GlobalExtendedAttributes, ExtendedAttributes, LongLink, and + // LongPath) that contain no file data. + TarEntryType[] allowedTypes = + [ + TarEntryType.RegularFile, + TarEntryType.V7RegularFile, + TarEntryType.ContiguousFile, + TarEntryType.Directory + ]; + + if (!allowedTypes.Contains(entry.EntryType)) + continue; + + // Normalize and validate the path, same as the ZIP example. + string destPath = Path.GetFullPath(Path.Join(fullDestDir, entry.Name)); + if (!destPath.StartsWith(fullDestDir, StringComparison.Ordinal)) + throw new IOException( + $"Entry '{entry.Name}' would extract outside the destination."); + + if (entry.EntryType is TarEntryType.Directory) + { + Directory.CreateDirectory(destPath); + } + else + { + // Create the parent directory and any missing intermediate directories. + Directory.CreateDirectory(Path.GetDirectoryName(destPath)!); + entry.ExtractToFile(destPath, overwrite: false); + } + } +} +// + +// +bool IsLinkTargetSafe(TarEntry entry, string fullDestDir) +{ + // A symlink with an absolute (rooted) target is resolved from the filesystem root, not from the extraction directory. + if (Path.IsPathRooted(entry.LinkName)) + return false; + + if (!fullDestDir.EndsWith(Path.DirectorySeparatorChar)) + fullDestDir += Path.DirectorySeparatorChar; + + string resolvedTarget; + + if (entry.EntryType is TarEntryType.SymbolicLink) + { + // Symlink targets are relative to the symlink's own parent directory, or absolute. + string entryDir = Path.GetDirectoryName( + Path.GetFullPath(Path.Join(fullDestDir, entry.Name)))!; + resolvedTarget = Path.GetFullPath(Path.Join(entryDir, entry.LinkName)); + } + else + { + // Hard link targets are relative to the destination directory root. + resolvedTarget = Path.GetFullPath(Path.Join(fullDestDir, entry.LinkName)); + } + + return resolvedTarget.StartsWith(fullDestDir, StringComparison.Ordinal); +} +// + +// +void StreamingModify() +{ + // ✅ Streaming approach for large archives + using var input = new ZipArchive(File.OpenRead("large.zip"), ZipArchiveMode.Read); + using var output = new ZipArchive(File.Create("modified.zip"), ZipArchiveMode.Create); + + foreach (var entry in input.Entries) + { + if (ShouldKeep(entry)) + { + var newEntry = output.CreateEntry(entry.FullName); + using var src = entry.Open(); + using var dst = newEntry.Open(); + src.CopyTo(dst); + } + } +} + +bool ShouldKeep(ZipArchiveEntry entry) => true; +// + +// +void TarStreamingRead(Stream archiveStream, string destDir) +{ + using var reader = new TarReader(archiveStream); + TarEntry? entry; + while ((entry = reader.GetNextEntry()) is not null) + { + // DataStream is only valid until the next GetNextEntry() call, + // so consume or copy the data before advancing. + if (entry.DataStream is not null) + { + string destPath = Path.Join(destDir, entry.Name); + using var fileStream = File.Create(destPath); + entry.DataStream.CopyTo(fileStream); + } + } + + // Alternatively, pass copyContents: true to retain entry data + // in a separate MemoryStream that remains valid after advancing: + // entry = reader.GetNextEntry(copyContents: true); +} +// diff --git a/docs/standard/io/snippets/zip-tar-best-practices/csharp/Project.csproj b/docs/standard/io/snippets/zip-tar-best-practices/csharp/Project.csproj new file mode 100644 index 0000000000000..18bdc06b0760f --- /dev/null +++ b/docs/standard/io/snippets/zip-tar-best-practices/csharp/Project.csproj @@ -0,0 +1,11 @@ + + + + Exe + net11.0 + enable + enable + + + + diff --git a/docs/standard/io/zip-tar-best-practices.md b/docs/standard/io/zip-tar-best-practices.md new file mode 100644 index 0000000000000..93c0beab80dfa --- /dev/null +++ b/docs/standard/io/zip-tar-best-practices.md @@ -0,0 +1,349 @@ +--- +title: Best practices for ZIP and TAR archives +description: Learn best practices for working with ZIP and TAR archives in .NET, including API selection, trusted and untrusted extraction patterns, memory management, and platform considerations. +ms.topic: best-practice +ms.date: 04/10/2026 +ai-usage: ai-assisted +dev_langs: + - "csharp" +helpviewer_keywords: + - "I/O [.NET], compression" + - "compression" + - "ZIP" + - "TAR" + - "zip bomb" + - "path traversal" + - "Zip Slip" + - "archive security" +--- + +# Best practices for working with ZIP and TAR archives in .NET + +This article covers best practices for working with ZIP and TAR archives in .NET. You'll learn how to choose the right API for your scenario, use the convenience methods effectively for trusted input, and safely handle untrusted archives to protect against common attacks like path traversal and zip bombs. + +.NET provides built-in support for two of the most common archive formats: + +- **ZIP** (`System.IO.Compression`): A compressed archive format that bundles multiple files and directories into a single file. ZIP supports per-entry compression (Deflate, Deflate64, Stored). The primary types are for reading and writing archives, for file-based convenience methods, and for extraction helpers. + +- **TAR** (`System.Formats.Tar`): A Unix-origin archive format that stores files, directories, and metadata (permissions, ownership, timestamps) without compression. .NET supports the V7, UStar, PAX, and GNU formats. The primary types are and for streaming access, and for file-based convenience methods. TAR is often combined with a compression layer (for example, for `.tar.gz` files). + +## Choose the right API + +.NET offers two categories of archive APIs. Pick the category that matches your scenario. + +- [Convenience APIs (one-shot operations)](#convenience-apis-one-shot-operations) +- [Streaming APIs (entry-by-entry control)](#streaming-apis-entry-by-entry-control) + +If you control the archive source (your own build output, known-safe backups), the convenience APIs are the simplest choice. If the archive comes from an external source (user uploads, downloads, network transfers), use the streaming APIs with the safety checks described in this article. + +> [!CAUTION] +> ZIP and TAR archives differ significantly in what they store. ZIP primarily transmits files, while TAR transmits a complete filesystem topology, including file types, symbolic links, hard links, permissions, and other metadata. This difference has important security implications: TAR's richer structure gives an adversary more ways to influence how data is represented on disk, well beyond just filenames and file contents. Exercise extra caution when processing untrusted TAR archives. + +### Convenience APIs (one-shot operations) + +Use these APIs to create or extract an entire archive in a single call. They're ideal for simple, trusted scenarios. + +- +- +- +- + +Best for: simple workflows with trusted input, quick scripts, and build tooling. + +### Streaming APIs (entry-by-entry control) + +Use these APIs for full control over each archive entry. They're essential for large archives or untrusted input. + +- **ZIP:** Use to open an archive and iterate, read, or write entries selectively. Use to extract individual entries, or to extract all entries from an already-opened archive. + +- **TAR:** Use and for sequential entry-by-entry access. Use to extract individual entries. + +Best for: large archives, selective extraction, untrusted input, and custom processing. + +> [!TIP] +> Import the `System.IO.Compression` namespace to access the extension methods on and . + +## Work with trusted archives + +When the archive source is known and trusted, the [convenience methods](#convenience-apis-one-shot-operations) give you a safe, one-line extraction path: + +- and handle path validation automatically. They sanitize entry names, resolve each entry's full path, and verify the resolved path stays inside the destination directory. + +- has overloads that default to not overwriting existing files. All overloads require the `overwriteFiles` parameter, so you must always choose explicitly. + +- When overwriting is enabled during ZIP extraction, .NET extracts to a temporary file first and only replaces the target after successful extraction. This prevents partial corruption if the extraction fails. + +- TAR extraction handles overwriting differently: it deletes the existing file before writing the replacement. If extraction fails after deletion (for example, due to an I/O error or process interruption), the original file is lost and the replacement might be incomplete. Consider backing up critical files before overwriting with TAR extraction. + +> [!NOTE] +> The convenience methods don't enforce size limits, entry count limits, or other policies needed for safe extraction of untrusted archives. If that matters even for trusted input (for example, very large archives), use the streaming approach described in [Handle untrusted archives safely](#handle-untrusted-archives-safely). + +## Handle untrusted archives safely + +For untrusted input—user uploads, third-party downloads, or network transfers—iterate over entries manually and enforce your own safety checks. The following subsections describe what you need to enforce and why. + +- [What the convenience methods don't protect you from](#what-the-convenience-methods-dont-protect-you-from) +- [Enforce size and entry count limits](#enforce-size-and-entry-count-limits) +- [Validate destination paths](#validate-destination-paths) +- [Handle symbolic and hard links (TAR)](#handle-symbolic-and-hard-links-tar) +- [Complete safe extraction examples](#complete-safe-extraction-examples) + +### What the convenience methods don't protect you from + +`ExtractToDirectory` protects against *path traversal*—an attack where a malicious entry name like `../../etc/passwd` tries to write outside the destination directory. The method resolves each entry's full path and rejects any that fall outside the target directory (for TAR, this check also covers symbolic link targets). However, `ExtractToDirectory` doesn't enforce size limits or entry count limits. + +### Enforce size and entry count limits + +Neither nor limits the total uncompressed size or the number of entries extracted, and neither do the `ExtractToDirectory` convenience methods. You must enforce these limits yourself. + +> [!IMPORTANT] +> A small compressed file can expand to terabytes of data—this is known as a *zip bomb*. Zip bombs come in two forms: +> +> - **Flat (non-recursive):** A single archive contains many entries that each decompress to a large size. DEFLATE achieves ratios up to ~1032×, so 100 KB of compressed data can expand to ~100 MB. Scaling up the entry count or entry size produces terabytes of output in a single extraction pass. These bombs are effective against any zip parser, including .NET. +> - **Recursive (nested):** An archive contains inner archives, each containing more archives, multiplying the expansion at every layer. The classic `42.zip` uses five layers of 16 nested zips to reach 4.5 PB. However, .NET's `ExtractToDirectory` doesn't recursively extract inner archives—it treats them as opaque files—so recursive bombs aren't a concern unless your code opens the extracted archives again. +> +> Always enforce limits on decompressed size and entry count when extracting untrusted archives. + +- Enforce per-entry size limits + + :::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="SafeExtractEntry"::: + +- Track aggregate size and entry count + + :::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="SafeExtractArchive"::: + +> [!TIP] +> The same approach applies to TAR archives. Since TAR files are read entry-by-entry via , track both the cumulative data size and entry count as you iterate. + +### Validate destination paths + +When you use the streaming APIs, you're responsible for validating every entry's destination path. They perform no path validation at all. + +For every entry, resolve the destination to an absolute path and verify it falls within your target directory: + +:::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="PathValidation"::: + +Key points: + +- `Path.GetFullPath()` resolves relative segments like `../` into an absolute path. +- The `StartsWith` check ensures the resolved path is still inside the destination. +- The trailing directory separator on `fullDestDir` is critical—without it, a path like `/safe-dir-evil/file` would incorrectly match `/safe-dir`. + +> [!WARNING] +> The following APIs leave you completely unprotected against path traversal. You must validate paths yourself before calling them. + +- writes to whatever path you give it—no sanitization, no boundary check. +- returns a raw `Stream`—the caller decides where to write. +- writes to the given path without validating it against any directory boundary. + +**Vulnerable pattern—DO NOT USE without validation:** + +:::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="VulnerablePattern"::: + +### Handle symbolic and hard links (TAR) + +TAR archives support symbolic links and hard links, which introduce attack vectors beyond basic path traversal: + +- **Symlink escape:** A symlink entry points to an arbitrary location (for example, `/etc/`), then a subsequent file entry relative to the symlink directory writes through the link to that external location. +- **Hard link to sensitive file:** A hard link target references a file outside the extraction directory. Because a hard link shares the same inode as the original, any code that later opens the hard link for writing (for example, with `File.Create` or `File.WriteAllText`) modifies the original file's contents. + +The safest approach for untrusted archives is to skip link entries entirely: + +```csharp +if (entry.EntryType is TarEntryType.SymbolicLink or TarEntryType.HardLink) + continue; // Skip link entries for untrusted input +``` + +If you need to preserve links, validate that the link target resolves within your destination directory before creating it: + +:::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="ValidateSymlink"::: + +If your use case requires extracting archives with hard links but you want to avoid hard links on disk, copies the file content instead of creating a hard link. This eliminates hard-link-based attacks and produces more portable output on Windows. + +For reference, validates both the entry path and link target path against the destination directory boundary. If either resolves outside, an is thrown. rejects symbolic and hard link entries entirely—it throws . + +### Complete safe extraction examples + +Combine path traversal validation, size limits, entry count limits, and link handling in a single extraction loop. + +- **ZIP** + + The following method extracts a ZIP archive while enforcing all recommended safety checks: + + :::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="SafeExtractZip"::: + +- **TAR** + + TAR extraction differs from ZIP in several ways: entries are read sequentially (there's no central directory), link entries need explicit handling, and the `DataStream` must be consumed before advancing to the next entry. + + :::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="SafeExtractTar"::: + +## Memory and performance considerations + +Understanding how .NET manages memory for ZIP and TAR operations helps you avoid unexpected issues with large or untrusted archives. + +- [ZipArchive memory usage](#ziparchive-memory-usage) +- [TAR streaming model](#tar-streaming-model) +- [Thread safety](#thread-safety) + +### ZipArchive memory usage + +Don't use for large or untrusted archives. When you open a in `Update` mode and call or on an entry, its uncompressed data is loaded into a to support in-place modifications. Accessing entry metadata (such as , , or ) does not trigger decompression. For large or malicious archives, opening entry content streams can cause . Check before calling to avoid decompressing unexpectedly large entries. + +Additionally, when you open a in mode with an **unseekable** stream (for example, a network stream), the runtime buffers the entire archive contents in memory to enable seeking through the central directory. + +**Recommendation:** Only use `Update` mode for archives you trust and know are small enough to fit in memory. and modes are safer because they stream entry data rather than buffering it entirely in memory. To modify a large archive, open the original in `Read` mode, create a new archive in `Create` mode, and selectively copy entries: + +:::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="StreamingApproach"::: + +### TAR streaming model + + reads entries one at a time and doesn't buffer the entire archive. However, for unseekable streams, each entry's is only valid until the next call. If you need to retain entry data, either copy it immediately or pass `copyContents: true` to , which copies the entry data into a separate that remains valid after advancing. Like , `copyContents: true` loads the full entry into memory, so check entry sizes before using it with untrusted archives. + +:::code language="csharp" source="./snippets/zip-tar-best-practices/csharp/Program.cs" id="TarStreaming"::: + +### Thread safety + +, , and are not thread-safe. Don't access an instance from multiple threads concurrently. If you need to read multiple archives in parallel, open a separate instance per thread. For writing, synchronize access externally—multiple writers can't safely target the same archive file concurrently. + +## Platform considerations + +Archive behavior can vary between Windows and Unix. Keep these differences in mind when writing cross-platform code. + +- [Unix file permissions](#unix-file-permissions) +- [Special entry types (TAR)](#special-entry-types-tar) +- [File name sanitization differs by platform](#file-name-sanitization-differs-by-platform) + +### Unix file permissions + +- **ZIP:** Unix permissions are stored in the upper 16 bits of . When extracting on Unix via `ExtractToDirectory` or `ExtractToFile`, the runtime restores ownership permissions (read/write/execute for user/group/other), subject to the process umask. SetUID, SetGID, and StickyBit are stripped. Permissions are not applied if the upper bits are zero. This happens when the ZIP was created on Windows, because .NET on Windows sets `DefaultFileExternalAttributes` to `0`. On Windows, these attributes are always ignored during extraction. +- **TAR:** The property represents `UnixFileMode` and can store all 12 permission bits (read/write/execute for user/group/other, plus SetUID, SetGID, and StickyBit). When extracting on Unix via `ExtractToDirectory` or `ExtractToFile`, the runtime applies only the 9 ownership bits (rwx for user/group/other), subject to the process umask. SetUID, SetGID, and StickyBit are stripped for security. + +When processing untrusted archives, be aware that extracted files may have executable permissions set by the archive author. Untrusted archives could contain malicious executable files. + +### Special entry types (TAR) + +Block devices, character devices, and FIFOs can only be created on Unix. Extracting these on Windows throws an exception. Elevated privileges are required to create block and character device entries. + +### File name sanitization differs by platform + +On Windows, when using `ExtractToDirectory`, the runtime replaces control characters and `"*:<>?|` with underscores in entry names. On Unix, only null characters are replaced. Archive entries with names like `file:name.txt` are renamed to `file_name.txt` on Windows but extracted as-is on Unix. The per-entry APIs (`Open()`, `ExtractToFile()`) do not perform any name sanitization, so when using them with entry names from untrusted archives, validate the name and path before extracting (as shown in the [Validate destination paths](#validate-destination-paths) section). + +## Data integrity + +ZIP and TAR archives use different integrity checks. ZIP stores CRC-32 values for entry data. TAR stores a header checksum for each entry header. + +Starting with .NET 11, the runtime validates ZIP CRC-32 values automatically when reading ZIP entries. When you read an entry's data stream to completion, the runtime compares the computed CRC-32 value of the decompressed data against the value stored in the archive. If the values don't match, an is thrown. Starting with .NET 11, the runtime also validates TAR header checksums when reading TAR entry headers. + +> [!NOTE] +> In versions earlier than .NET 11, the runtime didn't validate ZIP CRC-32 values on read. The runtime computed CRC-32 values when writing ZIP entries for storage in the archive, but didn't verify them during extraction. If you target a runtime earlier than .NET 11, corrupt or tampered ZIP entries might be accepted silently. +> +> CRC-32 isn't a cryptographic hash—it detects accidental corruption but doesn't protect against intentional tampering by a sophisticated attacker. + +## Untrusted metadata + +### ZIP comments and extra fields + +- **Archive and entry comments** are arbitrary strings. If your application displays or processes comments, sanitize them appropriately. +- **Extra fields** are binary key-value pairs attached to each entry. The runtime preserves unknown extra fields and trailing data when reading and writing archives in mode and round-trips them as-is. If your application reads or interprets extra fields, validate their contents. +- **Entry name encoding:** when writing, the runtime uses ASCII for entry names that contain only ASCII printable characters (32-126) and UTF-8 (with the language encoding flag set) for names that contain other characters. When reading without a custom encoding, entries with or without the language encoding flag are decoded as UTF-8 (which also correctly decodes ASCII). Use the `entryNameEncoding` parameter on to override encoding when needed, but be aware the override affects all entries uniformly. + +## Encryption considerations (.NET 11+) + +.NET 11 adds support for reading and writing encrypted ZIP archives. The following subsections explain how to choose an encryption method, read encrypted entries, and use encrypted archives with the convenience APIs. + +- [Choose AES-256 for new archives](#choose-aes-256-for-new-archives) +- [Read encrypted entries](#read-encrypted-entries) +- [Convenience methods with encryption](#convenience-methods-with-encryption) + +> [!NOTE] +> ZIP encryption support (ZipCrypto and WinZip AES) is new in .NET 11. + +The `ZipEncryptionMethod` enum specifies the encryption method: + +| Value | Description | +|-------|-------------| +| `None` | No encryption. | +| `ZipCrypto` | Legacy ZIP encryption. Use only for backward compatibility—vulnerable to known-plaintext attacks. | +| `Aes128` | WinZip AES-128. | +| `Aes192` | WinZip AES-192. | +| `Aes256` | WinZip AES-256. **Recommended**—strongest available option. | +| `Unknown` | Returned when the entry uses an encryption method that .NET does not support. | + +### Choose AES-256 for new archives + +When creating encrypted entries, always prefer `Aes256`. `ZipCrypto` is a legacy method with known cryptographic weaknesses and shouldn't be relied upon for security—use it only when interoperating with tools that don't support WinZip AES. + +```csharp +string password = "your-password-here"; + +// ⚠️ Weak encryption — use only for backward compatibility +archive.CreateEntry("file.txt", password, ZipEncryptionMethod.ZipCrypto); + +// ✅ Prefer AES-256 +archive.CreateEntry("file.txt", password, ZipEncryptionMethod.Aes256); +``` + +### Read encrypted entries + +Use `ZipArchiveEntry.EncryptionMethod` to check the encryption method, and provide a password to `Open`: + +```csharp +using ZipArchive archive = ZipFile.OpenRead("encrypted.zip"); + +foreach (ZipArchiveEntry entry in archive.Entries) +{ + if (entry.EncryptionMethod == ZipEncryptionMethod.Unknown) + { + // Unsupported encryption method, skip this entry + continue; + } + + using Stream stream = entry.Open("myPassword"); + // ... read the decrypted data +} +``` + +Attempting to open an entry that uses an unsupported encryption method (`ZipEncryptionMethod.Unknown`) throws . + +### Convenience methods with encryption + +New option types let you pass a password and encryption method to the convenience APIs: + +```csharp +// Extract an encrypted archive +ZipFile.ExtractToDirectory("encrypted.zip", destDir, new ZipExtractionOptions +{ + Password = "myPassword".AsMemory(), + OverwriteFiles = false +}); + +// Create an encrypted archive +ZipFile.CreateFromDirectory(sourceDir, "encrypted.zip", new ZipFileCreationOptions +{ + Password = "myPassword".AsMemory(), + EncryptionMethod = ZipEncryptionMethod.Aes256, + CompressionLevel = CompressionLevel.Optimal +}); +``` + +## Best practices checklist + +Before deploying code that handles archives from untrusted sources, verify you've addressed each of the following: + +- **Manual iteration:** Don't use `ExtractToDirectory` for untrusted input—iterate entries manually to enforce all limits. +- **Path traversal:** Validate all destination paths with `Path.GetFullPath()` + `StartsWith()`. +- **Decompression bombs:** Enforce limits on decompressed size (per-entry and total) and entry count. +- **Symlink/hardlink attacks (TAR):** Validate link targets resolve within the destination, or skip link entries entirely. +- **Memory limits:** Avoid for large untrusted archives. Avoid mode with unseekable streams from untrusted sources. +- **Thread safety:** Don't share , , or instances across threads. +- **Untrusted metadata:** Treat entry names, comments, and extra fields as untrusted input. Sanitize before display or processing. +- **Overwrite behavior:** Default to `overwrite: false`. +- **Resource disposal:** Always dispose , , , and their streams. + +## Related content + +- +- +- +-