Skip to content

feat(lib): support a pluggable asset hash algorithm - #473

Draft
eduardomourar wants to merge 1 commit into
open-constructs:mainfrom
eduardomourar:feat/lib-asset-hash-algorithm
Draft

eduardomourar wants to merge 1 commit into
open-constructs:mainfrom
eduardomourar:feat/lib-asset-hash-algorithm

Conversation

@eduardomourar

Copy link
Copy Markdown
Contributor

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

  • I have updated the PR title to match CDKTN's style guide
  • I have run the linter on my code locally
  • I have performed a self-review of my code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation if applicable
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works if applicable
  • New and existing unit tests pass locally with my changes

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.
@eduardomourar
eduardomourar requested a review from a team as a code owner October 6, 2026 23:46
@so0k so0k added the assets label Oct 7, 2026
@sakul-learning

sakul-learning commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Review summary

No blocking correctness or security issues found. The algorithm is propagated consistently through AssetHash, AssetStaging, the source-hash cache key, bundler-output hashing, and TerraformAsset; the default MD5 behavior remains compatible. I left two non-blocking inline comments:

Verification at head 42ad876215c18f6b077691d2671711af89381739:

  • pnpm exec jest packages/cdktn/test/validations.test.ts --runInBand — 37 tests and 8 snapshots passed
  • pnpm exec nx build cdktn — jsii succeeded with 0 errors and 0 warnings
  • pnpm exec nx test cdktn --runInBand — 48 suites, 693 tests, and 300 snapshots passed

* `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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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}$/);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@so0k so0k left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

  1. Hash cache key (asset-staging.ts, algorithm: this.hashAlgorithm in cacheKey). AssetStaging keeps one hash cache per app. Before hashing a source directory it builds a key from the inputs - if algorithm wer 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. If algorithm field 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 the stack() helper builds a fresh App per call and the cache is per root. A case with two AssetStaging over the same source in one stack (default and SHA256), asserting each format, would own it.
  2. OUTPUT hashing with a bundler (algorithm passed to hashPath(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.OUTPUT and SHA256. This test catches regressions if algorithm were dropped from the second hashPath call path (where assetHashType: AssetHashType.Output and a bundler is set.
  3. Both sides of canonicalAssetHashes. hashPath picks canonicalHashPath or legacyHashPath from the flag, and this diff threads algorithm through both; features.ts asks for a test of the disabled and the enabled case. Every new TerraformAsset test runs with the flag enabled, so the legacy branch is never exercised with SHA256. That branch is what any project without the flag in its cdktf.json runs: those created before 0.24.0, or not created by cdktn init. A test with the flag off catches algorithm not reaching legacyHashPath, or one of its createHash calls 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 containing hello hashes to plain sha256("hello") = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 (checked locally against this head).
  4. Default value pins, one per scheme. The default path was rewritten: hashPath now resolves the algorithm and formats through formatDigest, and the extraHash/salt/bundlerKey folds do the same. The promise is that hashes computed without hashAlgorithm do 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 hashAlgorithm gives 5D41402ABC4B2A76B9719D911017C592, which is plain md5("hello") truncated and uppercased, so the literal is an independent oracle and not a value recomputed by hashPath.
    • Canonical (flag on). Projects initialised with cdktn init since 0.24.0 have the flag on, and nothing pins a canonical value: the canonical tests are relational, and the integration literals in test/typescript/modules run 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 on main and asserted unchanged on this branch. The canonical scheme hashes file permission bits, so the fixture needs an explicit chmod and a POSIX gate.
  5. 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 raw Digest method not supported. The sibling assetHashType has 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.

Comment on lines +234 to +243
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +245 to +264
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}$/);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +577 to +589
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);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

so0k added a commit that referenced this pull request Oct 8, 2026
#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>
@eduardomourar
eduardomourar marked this pull request as draft October 8, 2026 19:44
@eduardomourar

eduardomourar commented Oct 8, 2026 •

Copy link
Copy Markdown
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants