feat(lib): canonical asset hashes behind the canonicalAssetHashes feature flag#323
feat(lib): canonical asset hashes behind the canonicalAssetHashes feature flag#323vincenthsh wants to merge 1 commit into
Conversation
sakul-learning
left a comment
There was a problem hiding this comment.
Two emitted-artifact/hash consistency issues need resolution; details are inline. I’ll post the configured-check and test-gap summary separately.
| * copySync preserves directory permissions, | ||
| * | ||
| * in sorted directory order with `/`-separated relative paths, so renames, | ||
| * entry-boundary shifts, permission changes, empty-directory changes, and |
There was a problem hiding this comment.
Blocking: Canonical hashing records every non-root directory, including empty ones, but archiveSync() only recurses into directories and never emits ZIP directory entries. For AssetType.ARCHIVE, adding an empty directory therefore changes assetHash and the synthesized asset path while producing identical ZIP bytes. That conflicts with #322’s criterion limiting this sensitivity to assets where the directory is emitted. Please either emit explicit directory entries in archives or make canonical hashing asset-type-aware and omit directory records for archives that do not emit them; an archive-level regression should prove the hash tracks the emitted artifact.
| assetHash: staticModuleAssetHash ?? hashPath(relativeAssetPath), | ||
| assetHash: | ||
| staticModuleAssetHash ?? | ||
| hashPath(relativeAssetPath, { |
There was a problem hiding this comment.
Blocking: The emitted module asset contains only moduleSources copied into tmpDir, but this hashes their lowest common source ancestor. Unrelated siblings under that ancestor can affect the automatic hash without appearing in the emitted asset; canonical path/mode/directory framing adds new churn from unrelated renames, mode changes, and empty directories. Please hash tmpDir after copying (or frame exactly the selected module sources), and add a regression proving unrelated siblings do not change the module asset hash.
Testing and coverage evaluationAll configured checks passed at
The canonical hash suite runs through the normal Nx/Jest target and provides useful behavioral coverage for renames, entry-boundary shifts, permission changes, empty directories, file/symlink distinctions, symlink retargeting, and deterministic trees. Two output-level gaps remain and correspond to the inline correctness findings:
Ponytail/artifact-value pass: the core framing tests earn their maintenance cost and run in normal CI. Some canonical symlink cases overlap the stacked #321 coverage, but they still exercise a separate implementation. I would prioritize the two missing emitted-artifact regressions over adding further helper-level cases. No workflow files changed, and I found no new security issue in the touched code. |
### Description Closes #320. `TerraformAsset` walked source trees with `fs.statSync`, which follows symlinks. On symlinked trees (e.g. pnpm's `node_modules`) this meant: - **Archive bloat**: a target reachable through N symlink paths was copied N times into the zip (15 MB → 34 MB on a real Lambda bundle for byte-identical logical content). - **No symlink fidelity**: archives contained zero symlink entries — links became full copies. - **Hard synth crash**: circular symlinks (legitimate in pnpm trees) hit `ELOOP`. Notably this crashed for **every** asset type, not just `ARCHIVE`, because `hashPath` runs in the `TerraformAsset` constructor. This is a fork regression, not inherited from cdktf: upstream archives with `archiver`, which walks with `lstat` and emits real symlink entries (cycles are structurally impossible there). The regression entered when `archiver` was replaced by yazl in #95 — yazl has no symlink awareness at all (`addFile` stats-and-follows) — and survived the yazl → fflate rewrite in #148, which kept the hand-rolled `statSync` walk. ### The fix All three walkers in `packages/cdktn/src/private/fs.ts` now use `lstatSync` and treat symlinks first-class, restoring `archiver`/cdktf parity: - **`archiveSync`** emits real symlink entries: the `readlink` target as STORED entry data with `S_IFLNK | perms` in the unix external attributes and version-made-by host = Unix — exactly what `archiver` produces and what Info-ZIP `unzip`, Go `archive/zip`, and the AWS Lambda runtime (verified live on `nodejs22.x` in #320) recreate as symlinks. Never recursing through links makes cycles unreachable by construction. fflate 0.8.2 supports this natively via per-file `[data, { os, attrs, level }]` tuples (attrs must be caller-pre-shifted, `(mode << 16) >>> 0`). - **`hashPath`** hashes a symlink by its target path instead of following it — retargeting a link still changes the asset hash, but shared targets are no longer double-counted and cycles no longer crash the constructor. - **`copySync`** (`AssetType.DIRECTORY`) recreates symlinks with `fs.symlinkSync`. Two latent zip-metadata gaps fixed along the way (both also `archiver`/cdktf parity): - Regular-file unix modes are now preserved (executable bits were silently stripped — matters for Lambda binaries and `.bin` scripts). - Entry mtimes are pinned to a fixed 1980 date, making archives byte-reproducible across synths (fflate defaults `mtime` to `Date.now()`, so every synth previously produced different zip bytes — perpetual drift for anyone hashing the archive, e.g. `filebase64sha256`). The pin uses the local-time `Date` constructor deliberately: fflate encodes DOS dates from local getters and rejects years < 1980, so a UTC midnight date would underflow to 1979 in timezones west of UTC. Prior art considered and rejected: `terraform-provider-archive` always dereferences and has no cycle detection — it accumulated the opt-in `exclude_symlink_directories` band-aid (v2.4.0, hashicorp/terraform-provider-archive#183; follow-up defect fixed in v2.4.2, #298) and still `ELOOP`s on cycles. Reverting to `archiver` would reintroduce the `execSync` child-process bridge that #148 removed (its API is async-only). ### Hash compatibility (updated after review — downscoped) Review surfaced a domain collision in the first cut of the `hashPath` change (file bytes and symlink-target bytes shared one unframed stream). Per the requested downscope this PR now carries only the compatibility-preserving resolution (reviewer's option 3): - the legacy content hash is kept byte-identical for symlink-free trees (proven by a test that recomputes the historical md5 independently); - symlink metadata (relative path + target) is framed into a separate digest and combined under a tagged outer hash **only when symlinks exist** — so hashes still change only for symlink-bearing trees, which today either bloat 2-3× or crash outright. The canonical entry-framed scheme and its `canonicalAssetHashes` feature flag moved to the stack: design issue #322 → implementation #323 → docs #324. `TerraformModuleAsset` stays in scope per review: it shares the corrected copier (its private copier sent directory symlinks into `copyFileSync` → `EISDIR`) with a regression test. ### Testing - `packages/cdktn/test/archive-symlink.test.ts`: 10 tests — the issue's repros (file/dir symlink preservation via system-`unzip` round-trip + `lstat`, dedup of shared targets, `ELOOP` on cycles for both `archiveSync` and `hashPath`, hash-changes-on-retarget), `copySync` symlink recreation, executable-bit preservation, and byte-identical archives across runs. 5 of the 6 core repros fail on `main`. - Full `cdktn` package suite green (453 passed; the one pre-existing `matchers.test.ts` → `toPlanSuccessfully` failure shells out to a real `terraform plan` and fails identically without this change). - Issue #320's literal repro script against the built lib: `zipinfo` shows `lrwxr-xr-x ... stor` entries for `link-a`/`link-b`, one payload copy (538-byte zip vs 3× 200 KiB before), and the cyclic tree archives to a 120-byte zip instead of aborting synth. ### 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 - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation if applicable - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective or that my feature works if applicable - [x] New and existing unit tests pass locally with my changes 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Introduces the canonical entry-framed asset hash from open-constructs#322 as an opt-in FUTURE_FLAGS entry (on for new projects, opt-in for existing ones, default at the next major). The scheme is modeled on git trees and Nix NAR and frames everything that affects the emitted artifact: - files: 'F <mode> <relPath>NUL<size>NUL' + content, where mode is the octal permission mask archiveSync preserves in zip external attributes - symlinks: 'L <mode> <relPath>NUL<target length>NUL' + target - directories: 'D <relPath>NUL' including empty ones (copySync materializes them); no mode, since neither emitter preserves directory permissions Entries are framed in sorted order with /-separated relative paths, so renames, entry-boundary shifts, permission changes, empty-directory changes, file-vs-symlink swaps, and symlink retargeting all change the hash -- closing the metadata hole identified in review (0644 -> 0755 previously changed archive bytes but not the hash). The legacy scheme remains the default for existing projects and is untouched by the flag. Closes open-constructs#322 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9d97256 to
11bc329
Compare
sakul-learning
left a comment
There was a problem hiding this comment.
The rebase onto merged #321 leaves both previously identified emitted-artifact/hash consistency blockers unresolved. Reposting them on the current diff.
| hash.update(data); | ||
| } else if (stat.isDirectory()) { | ||
| if (relPath) { | ||
| hash.update(`D ${relPath}\0`); |
There was a problem hiding this comment.
Blocking: This canonical directory record applies to AssetType.ARCHIVE too, but archiveSync() only recurses into directories and never emits ZIP directory entries. Adding an empty directory therefore changes the archive’s assetHash and synthesized path while leaving the emitted ZIP unchanged. This conflicts with #322’s criterion limiting empty-directory sensitivity to assets where the directory is emitted. Please either emit explicit directory entries in archives or make canonical hashing asset-type-aware and omit directory records for archives that do not emit them; add an archive-level regression that correlates the hash with emitted ZIP content.
| assetHash: staticModuleAssetHash ?? hashPath(relativeAssetPath), | ||
| assetHash: | ||
| staticModuleAssetHash ?? | ||
| hashPath(relativeAssetPath, { |
There was a problem hiding this comment.
Blocking: The emitted module asset contains only moduleSources copied into tmpDir, but this hashes their entire lowest common source ancestor. Unrelated siblings can affect the automatic hash without appearing in the emitted asset; canonical path/mode/directory framing additionally makes unrelated renames, mode changes, and empty directories cause asset-path churn. Please hash tmpDir after copying (or frame exactly the selected module sources), and add a regression proving unrelated siblings do not change the module asset hash.
Description
Closes #322.
Stacked on #321— #321 has merged and this branch is rebased onto currentmain; the diff is now the single canonical-hash commit.Adds the canonical entry-framed asset hash as a
canonicalAssetHashesfeature flag (FUTURE_FLAGS: enabled for new projects bycdktn init, opt-in viacdktf.jsoncontext for existing projects, becomes the default at the next major). The legacy scheme — including #321's compatibility-preserving symlink handling — stays untouched as the default.The canonical representation
Modeled on git trees and Nix NAR, which serialize a full metadata model rather than payload framing alone. Every record contains what affects the emitted artifact:
F <mode> <relPath>\0<size>\0+ contentL <mode> <relPath>\0<target byte length>\0+ targetD <relPath>\0<mode>is the octal permission mask (0o7777bits) — exactly the bitsarchiveSyncpreserves in zip external attributes, closing the hole found in review where0644→0755changed archive bytes but not the hash.archiveSyncnorcopySyncpreserves directory permissions), so adding/removing an empty directory is visible./-separated relative paths; a symlink at the root is followed, matching how the asset source path is opened when the artifact is emitted.Acceptance criteria from #322
Each has a dedicated test in
packages/cdktn/test/canonical-asset-hash.test.ts:0644→0755changes the canonical hashTesting.app({ enableFutureFlags: false })covers the compat case per thefeatures.tstest rule)Testing
Full
cdktnsuite green (464 passing; the pre-existing environmentalmatchers.test.ts→toPlanSuccessfullyfailure shells out to a realterraform planand is unrelated). jsii build green. Docs for the flag are being handled separately in the docs repository (the stacked docs PR #324 was closed in favor of that).Checklist
🤖 Generated with Claude Code