Repository navigation
feat(lib): support a pluggable asset hash algorithm - #473
eduardomourar wants to merge 1 commit into
Conversation
Hashing was hard-coded to MD5, truncated to 32 uppercase hex characters. Callers that need an untruncated, lowercase digest to line up with a hash computed elsewhere (e.g. AWS CDK's SHA256 asset hashes) had no way to get one from core and had to reimplement hashing/bundler-output-capturing themselves. Add HashAlgorithm (MD5 | SHA256) to AssetOptions, threaded through AssetHash.of, AssetStaging and TerraformAsset. MD5 keeps the existing truncated/uppercased behavior; SHA256 is returned full-length and lowercase.
Review summaryNo blocking correctness or security issues found. The algorithm is propagated consistently through
Verification at head
|
| * `MD5` (the default) truncates to 32 uppercase hex characters, matching | ||
| * every hash this library has ever produced. `SHA256` is kept full-length | ||
| * and lowercase instead, matching the untruncated digest external tooling | ||
| * (e.g. AWS CDK) expects when an asset hash must line up with one computed |
There was a problem hiding this comment.
AssetOptions.hashAlgorithm changes the digest primitive and rendering, but not the bytes CDKTN feeds into that digest. For example, AssetHash.of() applies CDKTN’s canonical entry framing, so a file containing hello hashes to 27076d39dabf3e0cb647460e8ada94593e92938c2d7af0d6252b49d431f363e3, not the raw file SHA-256 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824. The legacy/canonical and source/output paths also frame different inputs. Could this wording say that SHA-256 selects CDKTN’s digest algorithm and output format, and reserve “line up with one computed elsewhere” for a specifically documented compatible input/framing path?
There was a problem hiding this comment.
Adding to this, since the claim is specifically about AWS CDK: I checked it against aws-cdk's actual core/lib/fs/fingerprint.ts rather than a raw file digest, and the two streams are structurally unrelated:
| AWS CDK | cdktn canonical | |
|---|---|---|
| prefix | hashes options.extra, options.follow, options.ignoreMode first |
nothing |
| per file | \x01 file:<path> \x02 <size>:<sha256(content)> \x03 — a nested content hash |
F <mode> <relPath>\0<len>\0<raw bytes> |
| framing | SOH / SOT / ETX | null bytes, includes file mode |
So it is not only that the framing differs from a raw digest — a cdktn SHA256 hash cannot equal a CDK hash for any tree. The matching 64-char lowercase format makes that easy to miss, since the values look interchangeable.
Two things beyond the doc wording: the PR description makes the same claim ("line up with … AWS CDK's SHA256 asset hashes"), so it is worth correcting there too. And if CDK-identical hashes are genuinely the goal — e.g. for TerraConstructs-ported constructs that compare them — that needs CDK's fingerprint algorithm rather than its digest, which per the layering in #380 would sit in a compat package rather than core.
There was a problem hiding this comment.
yeah I don't think we can match AWSCDK hash, but I'm curious if setting the algorithm to SHA256 allows preserving the asset keys from TerraConstructs - I don't think that's the end goal anyway as the CDKTN Asset pipeline is more careful and works differently from TerraConstructs so this would be fine as a breaking change when adopted into TerraConstructs
| hashAlgorithm: HashAlgorithm.SHA256, | ||
| }); | ||
|
|
||
| expect(hash).toMatch(/^[0-9a-f]{64}$/); |
There was a problem hiding this comment.
This verifies the 64-character lowercase shape, but it would also pass if the implementation accidentally used another 256-bit digest. The related assertions in canonical-asset-hash.test.ts likewise check shape and inequality rather than a known SHA-256 value. Could one of these cases assert an independently computed known answer, and could one focused AssetStaging case exercise the newly threaded OUTPUT path? Both test files run under the normal nx test cdktn target, so that small addition would protect the public algorithm contract without requiring a large matrix.
There was a problem hiding this comment.
Non-blocking test review. The new tests cover the SHA256 format for AssetHash.of, the pass-through from TerraformAsset, and both extraHash folds. A few production paths this diff changes have no test yet; listing them in rough order of user impact. All of them fit in the unit tier.
- Hash cache key (
asset-staging.ts,algorithm: this.hashAlgorithmincacheKey).AssetStagingkeeps one hash cache per app. Before hashing a source directory it builds a key from the inputs - ifalgorithmwer not part of the key, 2 assets in the same app pointing at the same directory but differing on algorithm would produce the same key. Ifalgorithmfield were dropped, the second asset in an App would get the first one's cached hash, in the wrong algorithm and format. No current test can see it, because thestack()helper builds a fresh App per call and the cache is per root. A case with twoAssetStagingover the same source in one stack (default and SHA256), asserting each format, would own it. - OUTPUT hashing with a bundler (
algorithmpassed tohashPath(produced, ...)). To ensure the one place where the hash is computed over a bundler's built output instead of the source directory. No test combines a bundler,AssetHashType.OUTPUTand SHA256. This test catches regressions ifalgorithmwere dropped from the secondhashPathcall path (whereassetHashType: AssetHashType.Outputand a bundler is set. - Both sides of
canonicalAssetHashes.hashPathpickscanonicalHashPathorlegacyHashPathfrom the flag, and this diff threadsalgorithmthrough both;features.tsasks for a test of the disabled and the enabled case. Every newTerraformAssettest runs with the flag enabled, so the legacy branch is never exercised with SHA256. That branch is what any project without the flag in itscdktf.jsonruns: those created before 0.24.0, or not created bycdktn init. A test with the flag off catchesalgorithmnot reachinglegacyHashPath, or one of itscreateHashcalls staying on MD5, which would hand those projects a truncated MD5 while they asked for SHA256. A known-answer case does it and also proves the digest really is SHA-256: the legacy scheme hashes file bytes only, so with the flag off a directory holding one file containinghellohashes to plainsha256("hello")=2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824(checked locally against this head). - Default value pins, one per scheme. The default path was rewritten:
hashPathnow resolves the algorithm and formats throughformatDigest, and the extraHash/salt/bundlerKey folds do the same. The promise is that hashes computed withouthashAlgorithmdo not move, because the hash is part of the staged asset path, so a moved hash re-stages and redeploys the asset. The unit tests only assert the 32-uppercase-hex shape, which still passes if every value changes (for example a different truncation, or a changed input to the digest). A literal pin on each scheme catches that, and two populations depend on it:- Legacy (flag off). The same one-file fixture without
hashAlgorithmgives5D41402ABC4B2A76B9719D911017C592, which is plainmd5("hello")truncated and uppercased, so the literal is an independent oracle and not a value recomputed byhashPath. - Canonical (flag on). Projects initialised with
cdktn initsince 0.24.0 have the flag on, and nothing pins a canonical value: the canonical tests are relational, and the integration literals intest/typescript/modulesrun with an empty context, so they are legacy too. The framing is cdktn's own, so there is no external oracle; the pin would be a value recorded onmainand asserted unchanged on this branch. The canonical scheme hashes file permission bits, so the fixture needs an explicitchmodand a POSIX gate.
- Legacy (flag off). The same one-file fixture without
- Out-of-range
hashAlgorithm. This one is a production gap first. From a non-TypeScript caller,"sha1"is accepted and returns a truncated uppercase SHA-1, and an unknown name throws Node's rawDigest method not supported. The siblingassetHashTypehas an explicit guard (assetHashTypeUnknown) and a test; the same treatment here would be consistent.
Inline comments below on two tests that look redundant and one that could be narrowed.
| test("hashAlgorithm changes the digest relative to the default", () => { | ||
| fs.writeFileSync(path.join(tempDir, "a.txt"), "hello"); | ||
|
|
||
| const md5Hash = AssetHash.of(tempDir); | ||
| const sha256Hash = AssetHash.of(tempDir, { | ||
| hashAlgorithm: HashAlgorithm.SHA256, | ||
| }); | ||
|
|
||
| expect(sha256Hash.toUpperCase()).not.toBe(md5Hash); | ||
| }); |
There was a problem hiding this comment.
Non-blocking: I think this test can be dropped. It compares a 64-character digest (uppercased) with a 32-character one, so the inequality holds for any two values of those shapes. The only regression it catches is the option being ignored by AssetHash.of, and the previous test (produces a full-length lowercase digest) already fails on that.
| test("hashAlgorithm SHA256 still changes with content and extraHash", () => { | ||
| const file = path.join(tempDir, "a.txt"); | ||
| fs.writeFileSync(file, "hello"); | ||
| const original = AssetHash.of(tempDir, { | ||
| hashAlgorithm: HashAlgorithm.SHA256, | ||
| }); | ||
|
|
||
| fs.writeFileSync(file, "goodbye"); | ||
| expect( | ||
| AssetHash.of(tempDir, { hashAlgorithm: HashAlgorithm.SHA256 }), | ||
| ).not.toBe(original); | ||
|
|
||
| fs.writeFileSync(file, "hello"); | ||
| const withExtra = AssetHash.of(tempDir, { | ||
| hashAlgorithm: HashAlgorithm.SHA256, | ||
| extraHash: "salt", | ||
| }); | ||
| expect(withExtra).not.toBe(original); | ||
| expect(withExtra).toMatch(/^[0-9a-f]{64}$/); | ||
| }); |
There was a problem hiding this comment.
Non-blocking: could this be narrowed to the one new contract? The last assertion (the extraHash fold keeps the 64-lowercase-hex format) is the part nothing else covers, since AssetHash.of has its own fold. The content-change half and the withExtra !== original check repeat changes when content changes and the extraHash test above on the same walk, and the name hides the assertion that matters. Something like extraHash folding keeps the SHA256 format with just the format assertion, or a two-row table with the test above (with and without extraHash), would say it directly.
| test("hashAlgorithm changes assetHash relative to the MD5 default", () => { | ||
| const md5Asset = new TerraformAsset(stack(), "asset", { | ||
| path: srcDir, | ||
| type: AssetType.DIRECTORY, | ||
| }); | ||
| const sha256Asset = new TerraformAsset(stack(), "asset2", { | ||
| path: srcDir, | ||
| type: AssetType.DIRECTORY, | ||
| hashAlgorithm: HashAlgorithm.SHA256, | ||
| }); | ||
|
|
||
| expect(sha256Asset.assetHash.toUpperCase()).not.toBe(md5Asset.assetHash); | ||
| }); |
There was a problem hiding this comment.
Non-blocking: same as the AssetHash.of one, I think this can be dropped. A 64-character value never equals a 32-character one, so it only fails if TerraformAsset does not forward the option, which the test just above already catches. It also would not notice the default flipping to SHA256: the uppercased SHA256 hash would still differ from the lowercase default.
#475) ### Related issue None. Docs and agent-tooling only; no package code is touched. ### Description Adds a `test-audit` agent skill under `.claude/skills/test-audit/`. It has two everyday uses and one occasional one: - **Authoring gate**: before a test is added, name the contract it protects, the regression that would fail it, the tier that should own it, why existing coverage misses it, and whether it needs a production seam no production caller needs. - **Audit**: sweep existing tests for ones that duplicate stronger proof, assert implementation rather than behavior, cannot fail, or keep test-only seams alive. - **Campaign** (`CAMPAIGN.md`): prune one package's or one owner area's whole test surface, one lane per PR. #### Origin The skill is adapted from the `test-audit` skill in [openclaw/openclaw](https://github.com/openclaw/openclaw) (MIT), taken at [`eb1b48c`](https://github.com/openclaw/openclaw/blob/eb1b48c58b61dec3cc273587d44e4f9a7eaa29f9/.agents/skills/test-audit/SKILL.md). `VENDOR.md` records this. The value bar, junk patterns, candidate evidence and campaign steps are upstream's. What is specific to this repository replaces the openclaw parts (Vitest, plugins, their PR tooling): - `TIERS.md`: the test tiers (static gates, unit, unit with the Terraform/OpenTofu matrix, integration, provider integration, examples), how CI routes each, what a green run does and does not prove, and the commands. - A "which tier owns it?" question in the authoring gate, so a contract is proven once at the cheapest tier that can observe the failure. - Snapshot rules, since much of the proof here is snapshots of synthesized JSON, HCL and generated bindings. - A retention bar naming this repo's contracts (synth output, logical IDs and asset hashes, names kept across the cdktf rename, feature flags, generated bindings, the version matrix, language bindings, the shipped `Testing` API). #### Doc alignment - `CLAUDE.md` recommends the skill when adding or changing tests. It no longer points at a constitution path that is not in the repository (the principles are stated inline), and it lists the PR title types and scopes `pr-lint.yml` actually allows. - `CONTRIBUTING.md` gains the `perf`, `test` and `revert` types, and a `pnpm test:watch` script that does not exist is replaced with the Nx watch command. #### Findings from trialling it The skill was run read-only over the diff of #473 (`feat(lib): support a pluggable asset hash algorithm`) as a first trial. The result is posted there as a [non-blocking review](#473 (review)). In short, of the seven tests that PR adds: - four pass the gate; - two are redundant: each asserts an inequality between a 64-character and a 32-character digest, which cannot fail while its sibling format test passes; - one could be narrowed to the single assertion nothing else covers. It also found production paths the diff changes with no test: the per-App hash cache key, OUTPUT hashing with a bundler, the new option on the legacy (flag-off) scheme, a literal pin for default hash values on either scheme, and a missing guard for an out-of-range algorithm. The trial fed back into the skill before this PR: one statement that turned out to be wrong was removed, the commands were corrected and verified, and the tier and command reference was split out into `TIERS.md` to keep `SKILL.md` close to the upstream's size. ### Checklist - [x] I have updated the PR title to match [CDKTN's style guide](https://github.com/open-constructs/cdk-terrain/blob/main/CONTRIBUTING.md#pull-requests-1) - [x] I have run the linter on my code locally (`prettier --check` on `CLAUDE.md` and `CONTRIBUTING.md`; `.claude/` is prettier-ignored) - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas (not applicable) - [ ] I have made corresponding changes to the [documentation](https://github.com/open-constructs/cdk-terrain-docs/tree/main/content) if applicable (not applicable) - [x] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works if applicable (not applicable) - [ ] New and existing unit tests pass locally with my changes (not run; no package code changed) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
As discussed, we will migrate TerraConstructs to MD5 (TerraConstructs/base#166), so there is no immediate need for this functionality. So putting this into draft. |
Related issue
Related #380
Description
Hashing was hard-coded to MD5, truncated to 32 uppercase hex characters. Callers that need an untruncated, lowercase digest to line up with a hash computed elsewhere (e.g. AWS CDK's SHA256 asset hashes) had no way to get one from core and had to reimplement hashing/bundler-output-capturing themselves.
Add HashAlgorithm (MD5 | SHA256) to AssetOptions, threaded through AssetHash.of, AssetStaging and TerraformAsset. MD5 keeps the existing truncated/uppercased behavior; SHA256 is returned full-length and lowercase.
Checklist