Skip to content
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
62f712a
Initial plan
Copilot Jul 20, 2026
695b6fa
Add TarDirectory task, registration, and resources
Copilot Jul 20, 2026
c0998f2
Add TarDirectory tests and docs; verified end-to-end
Copilot Jul 20, 2026
d769dc6
Address code review: remove redundant null-forgiving op and fix doc g…
Copilot Jul 20, 2026
6ba6a02
Error on unrecognized TarDirectory Compression instead of falling back
Copilot Jul 20, 2026
4835444
Add optional Format parameter to TarDirectory (Pax/Gnu/Ustar/V7)
Copilot Jul 20, 2026
e804b4a
Address review: rename recursive entry variable and avoid array alloc…
Copilot Jul 20, 2026
ae91e65
Simplify recursive foreach tuple deconstruction in TarDirectory
Copilot Jul 20, 2026
a89e30a
Update TFM to net11.0 and update accompanying files
ViktorHofer Jul 20, 2026
fa7d097
Try to fix build and template_feed style fixes
ViktorHofer Jul 20, 2026
e3e476d
Fix terminal logger tests
ViktorHofer Jul 20, 2026
4aa4918
Use a distinct stage2 binlog name to avoid collision with stage1
ViktorHofer Jul 20, 2026
d0c8407
Publish stage1 binlog alongside stage2 binlog in multi-stage builds
ViktorHofer Jul 20, 2026
81ffe89
Copy entire stage1 log folder back so CI publishes all stage1 logs
ViktorHofer Jul 20, 2026
81cfb85
Fix torn NuGet state in netcore bootstrap by not overlaying package N…
ViktorHofer Jul 20, 2026
82da8e8
Potential fix for pull request finding
ViktorHofer Jul 20, 2026
8a4f6aa
Potential fix for pull request finding
ViktorHofer Jul 20, 2026
0826b41
Update cts ymls
ViktorHofer Jul 20, 2026
81489a3
Merge net11.0 (#14455) to enable new BCL tar/compression APIs
Copilot Jul 20, 2026
2cf0cdc
Modernize TarDirectory with .NET 11 APIs and add ZStandard compression
Copilot Jul 20, 2026
6b0ebf0
Use scoped using declaration for compression stream disposal
Copilot Jul 20, 2026
56c9290
Merge branch 'main' into copilot/create-tar-files-task
ViktorHofer Jul 21, 2026
d6d155d
Inline TryParseFormat helper into its call site in TarDirectory
Copilot Jul 21, 2026
9078ac8
Remove redundant Compression* const fields in favor of TarCompression…
Copilot Jul 21, 2026
e23164c
Merge branch 'main' into copilot/create-tar-files-task
ViktorHofer Jul 21, 2026
640ca3d
Add Untar task mirroring Unzip for extracting tar archives
Copilot Jul 21, 2026
bc12d42
Convert TarDirectory to typed task-parameter binding
Copilot Jul 21, 2026
b0c19c1
Fix CI build failure: remove unused _enumOutput field (IDE0052)
Copilot Jul 22, 2026
902c727
Convert Untar to typed task-parameter binding
baronfel Jul 27, 2026
f15b146
Push BuildEngine3.Yield() past precondition checks in TarDirectory
baronfel Jul 27, 2026
b463549
Use FileInfo.OpenRead() to open source tar in Untar
baronfel Jul 27, 2026
5f30643
Use FileInfo.OpenWrite() to open the destination tar in TarDirectory
baronfel Jul 27, 2026
f3ff1b2
Rewrite TarDirectory to a sorted TarWriter loop for deterministic ord…
baronfel Jul 27, 2026
ae0c30f
Add DeterministicTimestamp parameter to TarDirectory
baronfel Jul 27, 2026
3826a0f
Detect symlinks via LinkTarget instead of the ReparsePoint attribute
baronfel Aug 3, 2026
040eb7d
Fix tar-slip error handling in Untar
baronfel Aug 5, 2026
6b538fd
Fix TarDirectory non-incremental error and clean up partial archives
baronfel Aug 5, 2026
6a911b2
Rename _cancellationToken field to _cancellationTokenSource in Untar
baronfel Aug 5, 2026
4088800
Add PreservePermissions parameter to Untar task
baronfel Aug 5, 2026
45f850c
Always extract via runtime ExtractToFileAsync; remove PreservePermiss…
baronfel Aug 5, 2026
c329d40
Suppress CA1416 on Unix-only File.GetUnixFileMode test call
baronfel Aug 5, 2026
ab57fd3
Annotate optional DeterministicTimestamp parameter as string?
baronfel Aug 6, 2026
0567404
Update TarDirectory and Untar task registrations
ViktorHofer Aug 7, 2026
482900c
Range-check Unix-seconds timestamp before FromUnixTimeSeconds (B1)
baronfel Aug 7, 2026
cbd01c9
Truncate destination with FileMode.Create when writing tar archive (H1)
baronfel Aug 7, 2026
c46eb5d
Continue extraction on UnauthorizedAccessException, not just IOExcept…
baronfel Aug 7, 2026
1bc209a
Guard directory-entry creation so a bad entry doesn't abort extractio…
baronfel Aug 7, 2026
3a83366
Hoist FailIfNotIncremental check above destination-exists handling in…
baronfel Aug 7, 2026
5e7d3c0
Only translate directory separators in tar entry names on non-Unix pl…
baronfel Aug 7, 2026
a49b637
Fix Untar FailIfNotIncremental to log a real error instead of a progr…
baronfel Aug 7, 2026
a603513
Make TarDirectory cancelable (M5)
baronfel Aug 7, 2026
c42b1a0
Register TarDirectory/Untar via NET TaskHost instead of runtime-condi…
baronfel Aug 7, 2026
6131cba
Refactor Untar to await extraction instead of blocking per entry
baronfel Aug 7, 2026
6d8366e
Make TarDirectory write pipeline async with a single top-level GetAwa…
baronfel Aug 7, 2026
c750775
Extract Untar destination-directory validation into TryCreateDestinat…
baronfel Aug 7, 2026
f1c0cc3
Extract Untar per-archive loop body into TryExtractTarballAsync
baronfel Aug 7, 2026
491b107
Extract TryGetDeterministicTimestamp helper in TarDirectory
baronfel Aug 7, 2026
ead67c5
Register TarDirectory/Untar per Viktor's SDK-path proposal
baronfel Aug 7, 2026
bb98cfd
Use MSBuild version property function for SDK version gate
baronfel Aug 7, 2026
a6b3769
Use AssemblyFile for SDK-path TarDirectory/Untar UsingTask entries
Copilot Aug 10, 2026
be9f953
Move Runtime="NET" tar UsingTasks out of the default tasks file
ViktorHofer Aug 10, 2026
37f8d67
Use cross-runtime-resolvable parameter types on TarDirectory and Untar
ViktorHofer Aug 10, 2026
c1e62b3
Add E2E test covering inbox tar task resolution from .NET Framework M…
ViktorHofer Aug 10, 2026
72e27cc
Update UsingTask conditions for TarDirectory and Untar
ViktorHofer Aug 10, 2026
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
3 changes: 3 additions & 0 deletions documentation/specs/question.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ Error when SkipUnchangedFiles is true.
`ZipDirectory`
Error if the destination zip file doesn't exists.

`TarDirectory`
Error if the destination tar file doesn't exist.
Comment thread
baronfel marked this conversation as resolved.
Outdated

## Common Error

- **Typographical error**. Spelling, casing, or incorrect path. Check if the target inputs and outputs real files.
Expand Down
21 changes: 21 additions & 0 deletions src/Build.UnitTests/BackEnd/TaskBuilderTestTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ public CustomStruct(IConvertible value)
/// </summary>
internal static readonly CustomStruct[] s_customStructArray = new CustomStruct[] { new CustomStruct(43), new CustomStruct(44) };

/// <summary>
/// An enum used to validate engine-level binding of string values to enum task parameters.
/// </summary>
public enum TestTaskEnum
{
None,
First,
Second,
}

/// <summary>
/// The task host.
/// </summary>
Expand Down Expand Up @@ -701,6 +711,17 @@ public AbsolutePath AbsolutePathParam
}
}

/// <summary>
/// An enum parameter, used to validate engine-level binding of string values to enum task parameters.
/// </summary>
public TestTaskEnum EnumParam
{
set
{
_testTaskHost?.ParameterSet("EnumParam", value);
}
}

/// <summary>
/// An AbsolutePath array parameter.
/// </summary>
Expand Down
43 changes: 43 additions & 0 deletions src/Build.UnitTests/BackEnd/TaskExecutionHost_Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,49 @@ public void TestSetTaskItemIntArrayParamEmptyItem()

#endregion

#region Enum Params

/// <summary>
/// Validate that setting an enum parameter binds the string value (case-insensitively) to the enum member.
/// </summary>
[Fact]
public void TestSetEnumParam()
{
ValidateTaskParameter("EnumParam", "Second", TaskBuilderTestTask.TestTaskEnum.Second);
}

/// <summary>
/// Validate that enum binding is case-insensitive.
/// </summary>
[Fact]
public void TestSetEnumParamCaseInsensitive()
{
ValidateTaskParameter("EnumParam", "first", TaskBuilderTestTask.TestTaskEnum.First);
}

/// <summary>
/// Validate that setting the parameter with an empty value does not cause it to be set.
/// </summary>
[Fact]
public void TestSetEnumParamEmptyAttribute()
{
ValidateTaskParameterNotSet("EnumParam", "");
}

/// <summary>
/// Validate that setting an enum parameter to a value that does not map to a defined member is an error.
/// </summary>
[Fact]
public void TestSetEnumParamInvalidValue()
{
var parameters = GetStandardParametersDictionary(true);
parameters["EnumParam"] = ("NotADefinedValue", ElementLocation.Create("foo.proj"));

Should.Throw<InvalidProjectFileException>(() => _host.SetTaskParameters(parameters));
}

#endregion

#region FileInfo Params

/// <summary>
Expand Down
13 changes: 13 additions & 0 deletions src/Framework/ValueTypeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,19 @@ public static object Parse(string value, Type targetType)
return value;
}

// Enums bind by their (case-insensitive) member name, and values that do not map to a
// defined member are rejected so the engine surfaces a clear parameter-binding error.
if (targetType.IsEnum)
{
object result = Enum.Parse(targetType, value, ignoreCase: true);
if (!Enum.IsDefined(targetType, result))
{
throw new ArgumentException($"'{value}' is not a defined value of enum type {targetType.Name}.", nameof(value));
}

return result;
}
Comment thread
baronfel marked this conversation as resolved.

// Everything else (numeric types, char, etc.) is converted via Convert.ChangeType, pinned
// to InvariantCulture. This is deliberately permissive: a future analyzer is expected to
// steer authors toward the first-class supported types above and away from relying on this
Expand Down
248 changes: 248 additions & 0 deletions src/Tasks.UnitTests/TarDirectory_Tests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

// TarDirectory relies on System.Formats.Tar which is only available on .NET (not .NET Framework).
#if NET

using System;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using Microsoft.Build.UnitTests;
using Shouldly;
using Xunit;

namespace Microsoft.Build.Tasks.UnitTests
{
public class TarDirectory_Tests
{
private readonly MockEngine _mockEngine = new MockEngine();

[Theory]
[InlineData(TarDirectory.TarCompression.None)]
[InlineData(TarDirectory.TarCompression.GZip)]
[InlineData(TarDirectory.TarCompression.ZStandard)]
public void CanTarDirectory(TarDirectory.TarCompression compression)
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
TransientTestFolder sourceFolder = testEnvironment.CreateFolder(createFolder: true);

testEnvironment.CreateFile(sourceFolder, "6DE6060259C44DB6B145159376751C22.txt", "6DE6060259C44DB6B145159376751C22");
testEnvironment.CreateFile(sourceFolder, "CDA3DD8C25A54A7CAC638A444CB1EAD0.txt", "CDA3DD8C25A54A7CAC638A444CB1EAD0");

string tarFilePath = Path.Combine(testEnvironment.CreateFolder(createFolder: true).Path, "test.tar");

TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,
Compression = compression,
DestinationFile = new FileInfo(tarFilePath),
SourceDirectory = new DirectoryInfo(sourceFolder.Path),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeTrue(_mockEngine.Log);

_mockEngine.Log.ShouldContain(sourceFolder.Path, customMessage: _mockEngine.Log);
_mockEngine.Log.ShouldContain(tarFilePath, customMessage: _mockEngine.Log);

// Should not contain any warnings in the TarDirectory bucket (MSB4321 - MSB4330).
_mockEngine.Log.ShouldNotContain("MSB432", customMessage: _mockEngine.Log);

GetTarEntryNames(tarFilePath, compression)
.ShouldBe(
[
"6DE6060259C44DB6B145159376751C22.txt",
"CDA3DD8C25A54A7CAC638A444CB1EAD0.txt"
],
ignoreOrder: true);
}
}

[Fact]
public void CanOverwriteExistingFile()
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
TransientTestFolder sourceFolder = testEnvironment.CreateFolder(createFolder: true);

testEnvironment.CreateFile(sourceFolder, "F1C22D660B0D4DAAA296C1B980320B03.txt", "F1C22D660B0D4DAAA296C1B980320B03");
testEnvironment.CreateFile(sourceFolder, "AA825D1CB154492BAA58E1002CE1DFEB.txt", "AA825D1CB154492BAA58E1002CE1DFEB");

TransientTestFile file = testEnvironment.CreateFile(testEnvironment.DefaultTestDirectory, "test.tar", contents: "test");

TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,
DestinationFile = new FileInfo(file.Path),
Overwrite = true,
SourceDirectory = new DirectoryInfo(sourceFolder.Path),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeTrue(_mockEngine.Log);

_mockEngine.Log.ShouldContain(sourceFolder.Path, customMessage: _mockEngine.Log);
_mockEngine.Log.ShouldContain(file.Path, customMessage: _mockEngine.Log);

GetTarEntryNames(file.Path, TarDirectory.TarCompression.None)
.ShouldBe(
[
"F1C22D660B0D4DAAA296C1B980320B03.txt",
"AA825D1CB154492BAA58E1002CE1DFEB.txt"
],
ignoreOrder: true);
}
}

[Fact]
public void LogsErrorIfDestinationExists()
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
TransientTestFolder folder = testEnvironment.CreateFolder(createFolder: true);

TransientTestFile file = testEnvironment.CreateFile("foo.tar", "foo");

TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,
DestinationFile = new FileInfo(file.Path),
SourceDirectory = new DirectoryInfo(folder.Path),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeFalse(_mockEngine.Log);

_mockEngine.Log.ShouldContain("MSB4322", customMessage: _mockEngine.Log);
}
}

[Fact]
public void LogsErrorIfDirectoryDoesNotExist()
{
TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,
DestinationFile = new FileInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"), "test.tar")),
SourceDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"))),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeFalse(_mockEngine.Log);

_mockEngine.Log.ShouldContain("MSB4321", customMessage: _mockEngine.Log);
}

[Theory]
[InlineData(TarEntryFormat.Pax)]
[InlineData(TarEntryFormat.Gnu)]
[InlineData(TarEntryFormat.Ustar)]
[InlineData(TarEntryFormat.V7)]
public void CanTarDirectoryWithFormat(TarEntryFormat format)
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
TransientTestFolder sourceFolder = testEnvironment.CreateFolder(createFolder: true);

testEnvironment.CreateFile(sourceFolder, "3F6D2F2E3C1A4B5C8D9E0F1A2B3C4D5E.txt", "content");

string tarFilePath = Path.Combine(testEnvironment.CreateFolder(createFolder: true).Path, "test.tar");

TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,
Format = format,
DestinationFile = new FileInfo(tarFilePath),
SourceDirectory = new DirectoryInfo(sourceFolder.Path),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeTrue(_mockEngine.Log);

GetTarEntryFormats(tarFilePath)
.ShouldAllBe(entryFormat => entryFormat == format, _mockEngine.Log);

GetTarEntryNames(tarFilePath, TarDirectory.TarCompression.None)
.ShouldBe(["3F6D2F2E3C1A4B5C8D9E0F1A2B3C4D5E.txt"]);
}
}

[Fact]
public void UnknownFormatFallsBackToPax()
{
using (TestEnvironment testEnvironment = TestEnvironment.Create())
{
TransientTestFolder sourceFolder = testEnvironment.CreateFolder(createFolder: true);

testEnvironment.CreateFile(sourceFolder, "1A2B3C4D5E6F70819AABBCCDDEEFF001.txt", "content");

string tarFilePath = Path.Combine(testEnvironment.CreateFolder(createFolder: true).Path, "test.tar");

TarDirectory tarDirectory = new TarDirectory
{
BuildEngine = _mockEngine,

// TarEntryFormat.Unknown is not a real archive format; the task falls back to the Pax default.
Format = TarEntryFormat.Unknown,
DestinationFile = new FileInfo(tarFilePath),
SourceDirectory = new DirectoryInfo(sourceFolder.Path),
TaskEnvironment = TaskEnvironmentHelper.CreateForTest(),
};

tarDirectory.Execute().ShouldBeTrue(_mockEngine.Log);

GetTarEntryFormats(tarFilePath)
.ShouldAllBe(entryFormat => entryFormat == TarEntryFormat.Pax, _mockEngine.Log);
}
}

private static List<string> GetTarEntryNames(string tarFilePath, TarDirectory.TarCompression compression)
{
List<string> names = new List<string>();

using FileStream stream = new FileStream(tarFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);

// Wrap the file stream in a matching decompression stream, if the archive was compressed.
Stream? decompressionStream = compression switch
{
TarDirectory.TarCompression.None => null,
TarDirectory.TarCompression.GZip => new GZipStream(stream, CompressionMode.Decompress),
TarDirectory.TarCompression.ZStandard => new ZstandardStream(stream, CompressionMode.Decompress),
_ => throw new ArgumentException($"Unexpected compression '{compression}'.", nameof(compression)),
};
Comment thread
baronfel marked this conversation as resolved.

using (decompressionStream)
{
using TarReader reader = new TarReader(decompressionStream ?? stream);
for (TarEntry? entry = reader.GetNextEntry(); entry is not null; entry = reader.GetNextEntry())
{
if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile)
{
names.Add(entry.Name);
}
}
}

return names;
}

private static List<TarEntryFormat> GetTarEntryFormats(string tarFilePath)
{
List<TarEntryFormat> formats = new List<TarEntryFormat>();

using FileStream stream = new FileStream(tarFilePath, FileMode.Open, FileAccess.Read, FileShare.Read);
using TarReader reader = new TarReader(stream);
for (TarEntry? entry = reader.GetNextEntry(); entry is not null; entry = reader.GetNextEntry())
{
formats.Add(entry.Format);
}

return formats;
}
}
}

#endif
Loading