(Submitted with Claude Code)
Describe the bug
A ClickOnce deployment manifest owns the files beside it: ClickOnceSigner.SignAsync reaches into file.Directory!, signs the payload files itself, then regenerates the manifest hashes via mage -update and signs the manifests.
That ownership isn't reflected in how inputs are selected. If the payload files are also matched as top-level inputs -- e.g. sign code ... "**/*" over a publish folder -- Signer.SignAsync fans out over them with Parallel.ForEachAsync, giving each its own temporary directory, and they get signed a second time, concurrently, with no knowledge of the deployment manifest's run.
Two things go wrong:
- Both runs write the same output path (the payload's own run via
fi.CopyTo(output.FullName, ...), the manifest's run via CopySigningDependencies(fi, output.Directory!, ...)). Last writer wins, and which one that is isn't deterministic.
- Each pass produces its own timestamp countersignature, so the two signed copies differ byte for byte. The hashes
mage baked into the manifest describe the copy the manifest's run produced, which may not be the copy that ships.
The result is a manifest whose hashes don't match the deployed files.
Repro steps
- Publish a ClickOnce application.
- Run the tool against the publish folder with a pattern that matches both the
.application and the payload files (e.g. "**/*").
- Verify the deployed application.
Expected behavior
Files owned by a deployment manifest are signed once, as part of their owner, and the manifest's hashes describe the files that actually ship.
Actual behavior
Owned payload files are signed twice -- once by their owner, once as inputs of their own -- racing on the output path and leaving the manifest hashes potentially stale.
Additional context
This is the same shape as the MSI/external-cabinet problem, where an MSI owns cabinets that can also be matched as inputs and rebuilt independently.
#873 introduces a general mechanism for it: ISigningDependencyReader / ISigningDependencyProvider, which let a format declare the files it owns. Signer then removes owned files from the input list before the fan-out, so they're signed only as part of their owner, and CopySigningDependencies becomes a generic copy over the declared set.
That PR only implements a reader for MSI. Migrating ClickOnce onto the same mechanism should fix this, and would let ClickOnceSigner.CopySigningDependencies -- currently a hand-rolled sweep of the whole sibling directory -- be replaced by a GetSigningDependencies implementation. It's deliberately left out of #873 because it changes a shipping code path and deserves separate review.
Related: #643 and #737 describe workflows that sign ClickOnce files individually, which is the mode that runs into this.
(Submitted with Claude Code)
Describe the bug
A ClickOnce deployment manifest owns the files beside it:
ClickOnceSigner.SignAsyncreaches intofile.Directory!, signs the payload files itself, then regenerates the manifest hashes viamage -updateand signs the manifests.That ownership isn't reflected in how inputs are selected. If the payload files are also matched as top-level inputs -- e.g.
sign code ... "**/*"over a publish folder --Signer.SignAsyncfans out over them withParallel.ForEachAsync, giving each its own temporary directory, and they get signed a second time, concurrently, with no knowledge of the deployment manifest's run.Two things go wrong:
fi.CopyTo(output.FullName, ...), the manifest's run viaCopySigningDependencies(fi, output.Directory!, ...)). Last writer wins, and which one that is isn't deterministic.magebaked into the manifest describe the copy the manifest's run produced, which may not be the copy that ships.The result is a manifest whose hashes don't match the deployed files.
Repro steps
.applicationand the payload files (e.g."**/*").Expected behavior
Files owned by a deployment manifest are signed once, as part of their owner, and the manifest's hashes describe the files that actually ship.
Actual behavior
Owned payload files are signed twice -- once by their owner, once as inputs of their own -- racing on the output path and leaving the manifest hashes potentially stale.
Additional context
This is the same shape as the MSI/external-cabinet problem, where an MSI owns cabinets that can also be matched as inputs and rebuilt independently.
#873 introduces a general mechanism for it:
ISigningDependencyReader/ISigningDependencyProvider, which let a format declare the files it owns.Signerthen removes owned files from the input list before the fan-out, so they're signed only as part of their owner, andCopySigningDependenciesbecomes a generic copy over the declared set.That PR only implements a reader for MSI. Migrating ClickOnce onto the same mechanism should fix this, and would let
ClickOnceSigner.CopySigningDependencies-- currently a hand-rolled sweep of the whole sibling directory -- be replaced by aGetSigningDependenciesimplementation. It's deliberately left out of #873 because it changes a shipping code path and deserves separate review.Related: #643 and #737 describe workflows that sign ClickOnce files individually, which is the mode that runs into this.