Skip to content

Commit 91dcce2

Browse files
committed
Add detpackage dotnet tool
A CliFx single-command tool wrapping DeterministicPackage.ConvertAsync, packaged as DeterministicIoPackaging.Tool. The path parameter takes a file or a directory and converts in place; --target redirects the output to a file or a mirrored directory tree. --check writes nothing and exits 1 when a package is not already deterministic, so a build can gate on it. Directory input defaults to every known package extension, from nupkg through the Office Open XML and vsix containers. An already deterministic package is not rewritten on an in place run, so its timestamp is left alone. A target nested inside the input directory is excluded from enumeration so a recursive run cannot feed its own output back in, and "*.ext" patterns are re-checked against the real extension because Windows 8.3 matching makes "*.doc" match "report.docx".
1 parent 7fb8dfb commit 91dcce2

11 files changed

Lines changed: 397 additions & 0 deletions

claude.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,5 +127,8 @@ Run it from the repository root. It pairs each received file with the verified f
127127
- `src/DeterministicIoPackaging/` - Main library
128128
- `Patching/` - XML patchers for different file types
129129
- `DeterministicPackage.cs` - Entry point with patcher registration
130+
- `src/DeterministicIoPackaging.Tool/` - `detpackage`, the CliFx dotnet tool wrapping `DeterministicPackage.ConvertAsync`
131+
- `ConvertCommand.cs` - the single (default) command
132+
- `FileResolver.cs` - expands the path parameter into source/target file pairs
130133
- `src/Tests/` - Tests using Verify for snapshot testing
131134
- `tools/` - Utility projects (e.g., CreateDocx for generating test files)

readme.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,56 @@ var target = await DeterministicPackage.ConvertAsync(sourceStream);
8080
<!-- endSnippet -->
8181

8282

83+
## CLI tool
84+
85+
A [dotnet tool](https://learn.microsoft.com/en-us/dotnet/core/tools/global-tools) that applies the same conversion to files on disk.
86+
87+
* https://nuget.org/packages/DeterministicIoPackaging.Tool
88+
89+
```
90+
dotnet tool install -g DeterministicIoPackaging.Tool
91+
```
92+
93+
94+
### Usage
95+
96+
```
97+
detpackage <path> [options]
98+
```
99+
100+
`path` is a package file, or a directory containing packages. It is converted in place unless `--target` is used.
101+
102+
* `-t|--target` Write results here instead of modifying the input in place. An output file path when the input is a file, otherwise a directory mirroring the input tree.
103+
* `-p|--pattern` Search patterns applied when the input is a directory. Defaults to every known package extension: `*.nupkg`, `*.snupkg`, `*.vsix`, `*.docx`, `*.docm`, `*.dotx`, `*.xlsx`, `*.xlsm`, `*.xltx`, `*.pptx`, `*.pptm`, `*.potx`. Repeat the option for multiple patterns.
104+
* `-r|--recursive` Recurse into subdirectories when the input is a directory.
105+
* `--check` Report which packages are not already deterministic without writing anything. Exits with code 1 if any are found.
106+
* `--continue-on-error` Keep processing the remaining files after a failure, then exit with code 1.
107+
* `-q|--quiet` Suppress per file and summary output. Errors are still written.
108+
109+
A package that is already deterministic is left untouched, so an in place run does not disturb its timestamp.
110+
111+
112+
### Examples
113+
114+
Convert one package in place:
115+
116+
```
117+
detpackage MyPackage.1.0.0.nupkg
118+
```
119+
120+
Convert a tree into a separate output directory:
121+
122+
```
123+
detpackage ./input -r --target ./output
124+
```
125+
126+
Fail a build when any package is not deterministic:
127+
128+
```
129+
detpackage ./artifacts -r --check
130+
```
131+
132+
83133
## Icon
84134

85135
[Pi](https://thenounproject.com/icon/pi-2131020/) designed by [Zaidan](https://thenounproject.com/creator/mzaidanfiros/) from [The Noun Project](https://thenounproject.com).
Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
[Command(
2+
Description = "Rewrites a System.IO.Packaging file so the same source package always produces byte-identical output.")]
3+
public partial class ConvertCommand : ICommand
4+
{
5+
// Every System.IO.Packaging format the library is known to handle: NuGet packages, the Office
6+
// Open XML documents, and the VSIX container.
7+
static string[] defaultPatterns =
8+
[
9+
"*.nupkg",
10+
"*.snupkg",
11+
"*.vsix",
12+
"*.docx",
13+
"*.docm",
14+
"*.dotx",
15+
"*.xlsx",
16+
"*.xlsm",
17+
"*.xltx",
18+
"*.pptx",
19+
"*.pptm",
20+
"*.potx"
21+
];
22+
23+
[CommandParameter(
24+
0,
25+
Name = "path",
26+
Description = "Package file, or directory containing packages, to convert. Converted in place unless --target is used.")]
27+
public required string Input { get; set; }
28+
29+
[CommandOption(
30+
"target",
31+
't',
32+
Description = "Write results here instead of modifying the input in place. An output file path when the input is a file, otherwise a directory mirroring the input tree.")]
33+
public string? Target { get; set; }
34+
35+
[CommandOption(
36+
"pattern",
37+
'p',
38+
Description = "Search patterns applied when the input is a directory. Defaults to every known package extension.")]
39+
public string[] Patterns { get; set; } = defaultPatterns;
40+
41+
[CommandOption(
42+
"recursive",
43+
'r',
44+
Description = "Recurse into subdirectories when the input is a directory.")]
45+
public bool Recursive { get; set; }
46+
47+
[CommandOption(
48+
"check",
49+
Description = "Report which packages are not already deterministic without writing anything. Exits with code 1 if any are found.")]
50+
public bool Check { get; set; }
51+
52+
[CommandOption(
53+
"continue-on-error",
54+
Description = "Keep processing the remaining files after a failure, then exit with code 1.")]
55+
public bool ContinueOnError { get; set; }
56+
57+
[CommandOption(
58+
"quiet",
59+
'q',
60+
Description = "Suppress per file and summary output. Errors are still written.")]
61+
public bool Quiet { get; set; }
62+
63+
public async ValueTask ExecuteAsync(IConsole console)
64+
{
65+
if (Check &&
66+
Target != null)
67+
{
68+
throw new CommandException("--check does not write anything, so it cannot be combined with --target.");
69+
}
70+
71+
if (Patterns.Length == 0)
72+
{
73+
throw new CommandException("--pattern requires at least one value.");
74+
}
75+
76+
var jobs = FileResolver.Resolve(Input, Target, Patterns, Recursive);
77+
if (jobs.Count == 0)
78+
{
79+
throw new CommandException($"No files matching {string.Join(", ", Patterns)} found in: {Input}");
80+
}
81+
82+
var cancel = console.RegisterCancellationHandler();
83+
var changed = 0;
84+
var failed = 0;
85+
86+
foreach (var job in jobs)
87+
{
88+
try
89+
{
90+
if (await Handle(console, job, cancel))
91+
{
92+
changed++;
93+
}
94+
}
95+
catch (Exception exception)
96+
when (exception is not OperationCanceledException)
97+
{
98+
if (!ContinueOnError)
99+
{
100+
throw new CommandException($"{Relative(job.Source)}: {exception.Message}", innerException: exception);
101+
}
102+
103+
failed++;
104+
await console.Error.WriteLineAsync($"failed: {Relative(job.Source)}: {exception.Message}");
105+
}
106+
}
107+
108+
await WriteSummary(console, jobs.Count, changed, failed);
109+
}
110+
111+
// Returns whether converting altered the package.
112+
async Task<bool> Handle(IConsole console, FileJob job, Cancel cancel)
113+
{
114+
var source = await File.ReadAllBytesAsync(job.Source, cancel);
115+
116+
// Read fully into memory first: an in place run overwrites the file the conversion read from.
117+
using var sourceStream = new MemoryStream(source, writable: false);
118+
using var targetStream = await DeterministicPackage.ConvertAsync(sourceStream, cancel);
119+
120+
var converted = targetStream.ToArray();
121+
var isChanged = !converted.AsSpan().SequenceEqual(source);
122+
123+
if (Check)
124+
{
125+
if (isChanged)
126+
{
127+
await Write(console, $"not deterministic: {Relative(job.Source)}");
128+
}
129+
130+
return isChanged;
131+
}
132+
133+
// An unchanged package is left alone on an in place run rather than rewritten with the same
134+
// bytes, so its timestamp is not disturbed. A separate target always has to be written.
135+
if (isChanged ||
136+
!job.IsInPlace)
137+
{
138+
var directory = Path.GetDirectoryName(job.Target);
139+
if (directory != null)
140+
{
141+
Directory.CreateDirectory(directory);
142+
}
143+
144+
await File.WriteAllBytesAsync(job.Target, converted, cancel);
145+
}
146+
147+
var status = isChanged ? "converted" : "unchanged";
148+
if (job.IsInPlace)
149+
{
150+
await Write(console, $"{status}: {Relative(job.Source)}");
151+
}
152+
else
153+
{
154+
await Write(console, $"{status}: {Relative(job.Source)} -> {Relative(job.Target)}");
155+
}
156+
157+
return isChanged;
158+
}
159+
160+
async Task WriteSummary(IConsole console, int total, int changed, int failed)
161+
{
162+
if (Check)
163+
{
164+
if (changed > 0 ||
165+
failed > 0)
166+
{
167+
throw new CommandException($"{Count(total)} checked, {changed} not deterministic{(failed > 0 ? $", {failed} failed" : null)}.");
168+
}
169+
170+
await Write(console, $"{Count(total)} checked, all deterministic.");
171+
return;
172+
}
173+
174+
await Write(console, $"{Count(total)} processed, {changed} converted.");
175+
176+
if (failed > 0)
177+
{
178+
throw new CommandException($"{Count(failed)} failed.");
179+
}
180+
}
181+
182+
Task Write(IConsole console, string message)
183+
{
184+
if (Quiet)
185+
{
186+
return Task.CompletedTask;
187+
}
188+
189+
return console.Output.WriteLineAsync(message);
190+
}
191+
192+
static string Count(int value) => value == 1 ? "1 file" : $"{value} files";
193+
194+
static string Relative(string path) => Path.GetRelativePath(Directory.GetCurrentDirectory(), path);
195+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
<PropertyGroup>
3+
<TargetFramework>net10.0</TargetFramework>
4+
<OutputType>Exe</OutputType>
5+
<PackAsTool>true</PackAsTool>
6+
<ToolCommandName>detpackage</ToolCommandName>
7+
<SignAssembly>false</SignAssembly>
8+
<RollForward>LatestMajor</RollForward>
9+
<PackageTags>packaging, opc, nupkg, xlsx, docx, pptx, deterministic, reproducible, cli, dotnet-tool</PackageTags>
10+
<Description>Command line tool that modifies System.IO.Packaging files (nupkg, xlsx, docx, pptx) to ensure they are deterministic. Helpful for testing, build reproducibility, security verification, and ensuring package integrity across different build environments.</Description>
11+
</PropertyGroup>
12+
<ItemGroup>
13+
<PackageReference Include="CliFx" />
14+
<PackageReference Include="ProjectDefaults" PrivateAssets="all" />
15+
<PackageReference Include="Microsoft.Sbom.Targets" PrivateAssets="all" Condition="'$(CI)' == 'true'" />
16+
<ProjectReference Include="..\DeterministicIoPackaging\DeterministicIoPackaging.csproj" />
17+
</ItemGroup>
18+
</Project>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A single file to process, and where its result is written. Target equals Source for an in place run.
2+
record FileJob(string Source, string Target)
3+
{
4+
public bool IsInPlace => string.Equals(Source, Target, PathComparison.Value);
5+
}
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Expands the input path parameter into the set of files to process, pairing each with its output
2+
// path. Both the file and the directory forms are supported, and an omitted target means in place.
3+
static class FileResolver
4+
{
5+
public static IReadOnlyList<FileJob> Resolve(string input, string? target, IReadOnlyList<string> patterns, bool recursive)
6+
{
7+
var fullInput = Path.GetFullPath(input);
8+
9+
if (File.Exists(fullInput))
10+
{
11+
return [new(fullInput, ResolveFileTarget(fullInput, target))];
12+
}
13+
14+
if (Directory.Exists(fullInput))
15+
{
16+
return ResolveDirectory(fullInput, target, patterns, recursive);
17+
}
18+
19+
throw new CommandException($"Path not found: {input}");
20+
}
21+
22+
// A target that names an existing directory, or is written with a trailing separator, keeps the
23+
// source file name. Anything else is the output file path itself.
24+
static string ResolveFileTarget(string source, string? target)
25+
{
26+
if (target == null)
27+
{
28+
return source;
29+
}
30+
31+
if (Directory.Exists(target) ||
32+
EndsWithSeparator(target))
33+
{
34+
return Path.Combine(Path.GetFullPath(target), Path.GetFileName(source));
35+
}
36+
37+
return Path.GetFullPath(target);
38+
}
39+
40+
static IReadOnlyList<FileJob> ResolveDirectory(string directory, string? target, IReadOnlyList<string> patterns, bool recursive)
41+
{
42+
var fullTarget = target == null ? null : Path.GetFullPath(target);
43+
var searchOption = recursive ? SearchOption.AllDirectories : SearchOption.TopDirectoryOnly;
44+
45+
// Sorted so the order of the run does not depend on the order of the patterns or on the
46+
// order the file system happens to enumerate in, and to drop duplicates when patterns overlap.
47+
var sources = new SortedSet<string>(PathComparison.Comparer);
48+
foreach (var pattern in patterns)
49+
{
50+
foreach (var file in Directory.EnumerateFiles(directory, pattern, searchOption))
51+
{
52+
if (!MatchesExtension(pattern, file))
53+
{
54+
continue;
55+
}
56+
57+
// A target nested inside the input directory would otherwise feed its own output
58+
// back in on a recursive run.
59+
if (fullTarget != null &&
60+
IsUnder(fullTarget, file))
61+
{
62+
continue;
63+
}
64+
65+
sources.Add(file);
66+
}
67+
}
68+
69+
var jobs = new List<FileJob>(sources.Count);
70+
foreach (var source in sources)
71+
{
72+
if (fullTarget == null)
73+
{
74+
jobs.Add(new(source, source));
75+
continue;
76+
}
77+
78+
jobs.Add(new(source, Path.Combine(fullTarget, Path.GetRelativePath(directory, source))));
79+
}
80+
81+
return jobs;
82+
}
83+
84+
// Windows keeps legacy 8.3 name matching, so a "*.doc" search pattern also matches "report.docx".
85+
// Re-check the extension for the plain "*.extension" pattern shape.
86+
static bool MatchesExtension(string pattern, string file)
87+
{
88+
if (!pattern.StartsWith("*.") ||
89+
pattern.IndexOf('*', 2) != -1 ||
90+
pattern.Contains('?'))
91+
{
92+
return true;
93+
}
94+
95+
return string.Equals(Path.GetExtension(file), pattern[1..], StringComparison.OrdinalIgnoreCase);
96+
}
97+
98+
static bool IsUnder(string directory, string path) =>
99+
path.StartsWith(directory.TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, PathComparison.Value);
100+
101+
static bool EndsWithSeparator(string path) =>
102+
path.EndsWith(Path.DirectorySeparatorChar) ||
103+
path.EndsWith(Path.AltDirectorySeparatorChar);
104+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
global using CliFx;
2+
global using CliFx.Binding;
3+
global using CliFx.Infrastructure;
4+
global using DeterministicIoPackaging;

0 commit comments

Comments
 (0)