Repository navigation
fix(core): length-frame file contents in the content digest - #372
Merged
Merged
Conversation
A one-file tree and a two-file tree produce the same content digest on main, because file contents are hashed without their length. This test fails until the digest frames each file's contents. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The content digest pinned by skills.lock, global-skills.lock, bundle records and bundle archives hashed each file's path with its length but the file's contents without it. The byte stream did not decode to a single tree, so two different skill directories could share a digest. The digest now has a versioned form, `sha256-tree-v2:<hex>`: a domain tag, then for every file its UTF-8 relative path and its contents, each prefixed with a big-endian u64 length. Paths are joined from their components, so a Unix file name containing a backslash no longer reads as a nested path; non-UTF-8 names are refused; a file whose length changes while it is hashed is an error. Digests written by older releases (64 bare hex characters) are still accepted when they match, with a one-time warning, so existing Locks, bundle records and bundle artifacts keep installing. Every digest fastskill writes is in the new form, and a legacy value is replaced whenever its record is rewritten. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…R-0017 `project install --lock` restores from the Lock without changing it. A locked bundle restore now records the digests it verified instead of upgrading them, so a Lock holding legacy digests stays byte-identical under --lock; every other rewrite still records the current form. Documents the versioned content digest: ADR-0017, the Content digest term in CONTEXT.md, the CHANGELOG, the lockfile and bundle docs, and the digest reference in ADR-0016. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
The content digest is what
skills.lockandglobal-skills.lockpin aschecksum, what bundlerecords and bundle artifacts pin per member and per release, and what install, update, restore
and remove compare with the disk before changing anything. It hashed each file's relative path
with its length but the file's contents without their length. The hashed byte stream therefore
did not decode to a single directory tree, and two different skill directories could produce the
same digest. Content that differs from what a Lock or bundle pinned could pass verification.
The first commit adds
content_digest_distinguishes_file_boundaries, which reproduced this onmain(two different trees with one digest) and passes after the fix.The fix
New module
core/content_digest.rs(ADR-0017). A digest is nowsha256-tree-v2:<64 hex>: SHA-256 over a length-prefixed domain tag, then for every regular file,in byte order of its path, the UTF-8 relative path and the contents, each prefixed with a
big-endian
u64length. Also:\no longer hashes likea nested path.
Compatibility and migration
A legacy digest is 64 bare hex characters. The recorded form picks the algorithm used to check it.
Legacy values are still accepted when they match, with a one-time
warn_oncewarning, soproject install --lockkeeps working after an upgrade with no edits. fastskill only ever writesthe current form.
skills.lock[[skills]].checksumskill add,skill update, a newly resolved root)global-skills.lockchecksumskills.lock[[bundles]]release + member digestsproject installskills.lock[[overrides]].digest.fastskill/bundle-history.tomlskills.lockinside a bundle artifact /.fastskill/bundles/*.zipbundle buildproject install --locknever changes the Lock (documented contract). A locked bundle restorekeeps the digests it verified, so the Lock stays byte-identical.
because no release produced it. The release-digest algorithm is unchanged, but it hashes the
member digests, so a rebuilt artifact has a new release digest. Both forms are treated as the
same release.
value are each checked against the content.
fastskill-bundle-lock-v1are unchanged. Bumping them would makeolder releases refuse the file with a message suggesting that the lockfile be deleted.
Residual risk and what is not fixed
with a legacy digest passes wherever that digest is still recorded, including under
project install --lock, which deliberately doesn't rewrite. Refusing legacy digests would closethis. It would also break every committed Lock and every existing bundle, with no way to verify
what they pinned. This PR takes the compatible path and warns. A later release can refuse legacy
digests. Until then, re-resolving an entry (
skill update) or rebuilding a bundle removes theweaker record.
CI job that shares a project must upgrade together (noted in the CHANGELOG).
Lock must verify on every platform) and empty directories (ZIP artifacts and git don't preserve
them consistently).
Docs
CONTEXT.md.manifest-system.mdx,bundle-command.mdxandtwo-scope-lock-model.md.Testing
bash scripts/run-tests.shpasses: 2010 tests, 0 failures, every modified production file above 90% line coverage, source-size check green.refusals, and form dispatch;
--lock, upgraded onrewrite, and edit protection still enforced;
🤖 Generated with Claude Code