Skip to content

Commit 76dd4ff

Browse files
vincenthshclaudeso0k
authored
feat(lib): canonical asset hashes behind the canonicalAssetHashes feature flag (#323)
### Description Closes #322. ~~Stacked on #321~~ — #321 has merged and this branch is rebased onto current `main`; the diff is now the single canonical-hash commit. Adds the canonical entry-framed asset hash as a `canonicalAssetHashes` feature flag (`FUTURE_FLAGS`: enabled for new projects by `cdktn init`, opt-in via `cdktf.json` context 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: | entry | frame | | --- | --- | | file | `F <mode> <relPath>\0<size>\0` + content | | symlink | `L <mode> <relPath>\0<target byte length>\0` + target | | directory (incl. empty) | `D <relPath>\0` | - `<mode>` is the octal permission mask (`0o7777` bits) — exactly the bits `archiveSync` preserves in zip external attributes, closing the hole found in review where `0644` → `0755` changed archive bytes but not the hash. - Directories contribute explicit records (no mode — neither `archiveSync` nor `copySync` preserves directory permissions), so adding/removing an empty directory is visible. - Entries are framed in sorted directory order with `/`-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`: - [x] File rename changes the canonical hash (and a paired assertion that legacy cannot see it) - [x] Entry-boundary shifts change the canonical hash - [x] File/symlink swaps and symlink retargeting change the canonical hash - [x] `0644` → `0755` changes the canonical hash - [x] Adding/removing an empty directory changes the canonical hash - [x] Identical logical trees hash deterministically (two locations + repeated runs) - [x] Legacy hashing remains available (flag off; `Testing.app({ enableFutureFlags: false })` covers the compat case per the `features.ts` test rule) ### Testing Full `cdktn` suite green (464 passing; the pre-existing environmental `matchers.test.ts` → `toPlanSuccessfully` failure shells out to a real `terraform plan` and 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 - [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 (stacked docs PR follows) - [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> Co-authored-by: vincent de smet <vincent.drl@gmail.com>
1 parent 95753a9 commit 76dd4ff

5 files changed

Lines changed: 480 additions & 14 deletions

File tree

packages/cdktn/src/features.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,26 @@ export const FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS =
3838
*/
3939
export const VALIDATE_FUNCTION_VERSIONS = "validateFunctionVersions";
4040

41+
/**
42+
* When enabled, TerraformAsset and TerraformModuleAsset compute asset hashes
43+
* with a canonical entry-framed scheme modeled on git trees and Nix NAR:
44+
* every entry contributes its type, permission mask (for files and
45+
* symlinks — the bits archiveSync preserves in zip external attributes),
46+
* relative path, payload size, and payload, in sorted order, and
47+
* directories — including empty ones — contribute explicit records. Renames,
48+
* entry-boundary shifts, mode changes, empty-directory changes, and
49+
* file-vs-symlink swaps all change the hash.
50+
*
51+
* When disabled, the legacy content-concatenation hash is kept for
52+
* compatibility: trees without symlinks hash byte-identically to earlier
53+
* releases, and only symlink-bearing trees (whose hashes had to change when
54+
* symlink following was fixed) get a tagged hash that separates symlink
55+
* metadata from file content.
56+
*/
57+
export const CANONICAL_ASSET_HASHES = "canonicalAssetHashes";
58+
4159
export const FUTURE_FLAGS = {
4260
[FAIL_ON_CONSTRUCTS_OUTSIDE_OF_STACKS]: "true",
4361
[VALIDATE_FUNCTION_VERSIONS]: "true",
62+
[CANONICAL_ASSET_HASHES]: "true",
4463
};

packages/cdktn/src/private/fs.ts

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ export function archiveSync(src: string, dest: string) {
7676
try {
7777
const files: Record<string, [Uint8Array, ZipOptions]> = {};
7878
const walk = (dir: string, prefix: string) => {
79-
for (const entry of fs.readdirSync(dir)) {
79+
// Sorted so entry order — and therefore archive bytes — cannot depend
80+
// on filesystem enumeration order; matches the canonical hash walk.
81+
for (const entry of fs.readdirSync(dir).sort()) {
8082
const full = path.join(dir, entry);
8183
const zipPath = prefix ? `${prefix}/${entry}` : entry;
8284
const stat = fs.lstatSync(full);
@@ -115,20 +117,51 @@ export function archiveSync(src: string, dest: string) {
115117
}
116118
}
117119

120+
export interface HashPathOptions {
121+
/**
122+
* Use the canonical entry-framed hash instead of the legacy
123+
* content-concatenation hash. Enabled through the `canonicalAssetHashes`
124+
* feature flag.
125+
*/
126+
readonly canonical?: boolean;
127+
/**
128+
* Frame for an archive artifact: archiveSync never emits ZIP directory
129+
* entries, so canonical hashing omits directory records and the hash
130+
* tracks the emitted archive bytes exactly. Non-empty directories stay
131+
* visible through the relative paths of their contents. Has no effect on
132+
* the legacy scheme, which never records directories.
133+
*/
134+
readonly archive?: boolean;
135+
}
136+
118137
/**
119138
* Compute a stable MD5 hash of a file or directory's contents.
120-
* File contents fold into one digest in directory-listing order, exactly as
121-
* earlier releases did, so trees without symlinks keep their historical
122-
* hashes. Symlinks are hashed by their metadata (path + target) instead of
123-
* being followed, so shared targets are not double-counted and cycles cannot
124-
* recurse; that metadata goes into a second digest, and only when symlinks
125-
* exist are the two combined under a tagged outer hash — the tag keeps
126-
* symlink metadata in a separate domain from file bytes, so a file
127-
* containing `foo` can never collide with a symlink targeting `foo`.
139+
* In both schemes symlinks are hashed by their metadata (path + target)
140+
* instead of being followed, so shared targets are not double-counted and
141+
* cycles cannot recurse; a symlink at the root itself is followed, matching
142+
* how the asset source path is opened when the artifact is emitted.
128143
* @param src - path to a file or directory to hash
144+
* @param options - hash scheme selection, see {@link HashPathOptions}
129145
* @returns uppercased hex digest, truncated to HASH_LEN characters
130146
*/
131-
export function hashPath(src: string): string {
147+
export function hashPath(src: string, options: HashPathOptions = {}): string {
148+
const digest = options.canonical
149+
? canonicalHashPath(src, !options.archive)
150+
: legacyHashPath(src);
151+
return digest.slice(0, HASH_LEN).toUpperCase();
152+
}
153+
154+
/**
155+
* Legacy-compatible hash: file contents fold into one digest in
156+
* directory-listing order, exactly as earlier releases did, so trees without
157+
* symlinks keep their historical hashes. Symlink metadata goes into a second
158+
* digest, and only when symlinks exist are the two combined under a tagged
159+
* outer hash — the tag keeps symlink metadata in a separate domain from file
160+
* bytes, so a file containing `foo` can never collide with a symlink
161+
* targeting `foo`.
162+
* @param src - path to a file or directory to hash
163+
*/
164+
function legacyHashPath(src: string): string {
132165
const content = crypto.createHash("md5");
133166
const links = crypto.createHash("md5");
134167
let linkCount = 0;
@@ -160,13 +193,70 @@ export function hashPath(src: string): string {
160193

161194
hashRecursion(src, "", true);
162195
if (linkCount === 0) {
163-
return content.digest("hex").slice(0, HASH_LEN).toUpperCase();
196+
return content.digest("hex");
164197
}
165198
const outer = crypto.createHash("md5");
166199
outer.update("cdktn/asset-hash/symlinks/v1\0");
167200
outer.update(content.digest("hex"));
168201
outer.update(links.digest("hex"));
169-
return outer.digest("hex").slice(0, HASH_LEN).toUpperCase();
202+
return outer.digest("hex");
203+
}
204+
205+
/**
206+
* Canonical hash, modeled on git trees and Nix NAR: every entry is framed
207+
* with everything that affects the emitted artifact —
208+
*
209+
* - files: `F <mode> <relPath>\0<size>\0` + content, where `<mode>` is
210+
* the octal permission mask (`0o7777` bits) that archiveSync
211+
* preserves in zip external attributes,
212+
* - symlinks: `L <mode> <relPath>\0<target byte length>\0` + target,
213+
* - directories (including empty ones, which copySync materializes):
214+
* `D <relPath>\0`, with no mode — neither archiveSync nor
215+
* copySync preserves directory permissions,
216+
*
217+
* in sorted directory order with `/`-separated relative paths, so renames,
218+
* entry-boundary shifts, permission changes, empty-directory changes, and
219+
* file-vs-symlink swaps all change the digest.
220+
* @param src - path to a file or directory to hash
221+
* @param includeDirectories - record directory entries; false for archive
222+
* artifacts, where the emitted zip has no directory entries
223+
*/
224+
function canonicalHashPath(src: string, includeDirectories: boolean): string {
225+
const hash = crypto.createHash("md5");
226+
227+
/**
228+
* Walk `p`, framing each entry into the enclosing hash accumulator.
229+
* @param p - path to walk
230+
* @param relPath - path of `p` relative to the walk root, `/`-separated
231+
* @param isRoot - follow a symlink only at the root, matching how the
232+
* asset's own path is resolved when the artifact is emitted
233+
*/
234+
function hashRecursion(p: string, relPath: string, isRoot = false) {
235+
const stat = isRoot ? fs.statSync(p) : fs.lstatSync(p);
236+
const mode = (stat.mode & PERM_MASK).toString(8);
237+
if (stat.isSymbolicLink()) {
238+
const target = fs.readlinkSync(p);
239+
hash.update(`L ${mode} ${relPath}\0${Buffer.byteLength(target)}\0`);
240+
hash.update(target);
241+
} else if (stat.isFile()) {
242+
const data = fs.readFileSync(p);
243+
hash.update(`F ${mode} ${relPath}\0${data.length}\0`);
244+
hash.update(data);
245+
} else if (stat.isDirectory()) {
246+
if (relPath && includeDirectories) {
247+
hash.update(`D ${relPath}\0`);
248+
}
249+
for (const filename of fs.readdirSync(p).sort()) {
250+
hashRecursion(
251+
path.resolve(p, filename),
252+
relPath ? `${relPath}/${filename}` : filename,
253+
);
254+
}
255+
}
256+
}
257+
258+
hashRecursion(src, "", true);
259+
return hash.digest("hex");
170260
}
171261

172262
/**

packages/cdktn/src/terraform-asset.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
hashPath,
1010
findFileAboveCwd,
1111
} from "./private/fs";
12+
import { CANONICAL_ASSET_HASHES } from "./features";
1213
import { ISynthesisSession } from "./synthesize";
1314
import { addCustomSynthesis } from "./synthesize/synthesizer";
1415
import { TerraformStack } from "./terraform-stack";
@@ -78,7 +79,12 @@ export class TerraformAsset extends Construct {
7879
const stat = fs.statSync(this.sourcePath);
7980
const inferredType = stat.isFile() ? AssetType.FILE : AssetType.DIRECTORY;
8081
this.type = config.type ?? inferredType;
81-
this.assetHash = config.assetHash || hashPath(this.sourcePath);
82+
this.assetHash =
83+
config.assetHash ||
84+
hashPath(this.sourcePath, {
85+
canonical: !!this.node.tryGetContext(CANONICAL_ASSET_HASHES),
86+
archive: this.type === AssetType.ARCHIVE,
87+
});
8288

8389
if (stat.isFile() && this.type !== AssetType.FILE) {
8490
throw assetExpectsDirectory(id, config.path);

packages/cdktn/src/terraform-module-asset.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import * as fs from "fs";
1010
import { TerraformStack } from "./terraform-stack";
1111
import { AssetType, TerraformAsset } from "./terraform-asset";
1212
import { copySync, hashPath } from "./private/fs";
13+
import { CANONICAL_ASSET_HASHES } from "./features";
1314

1415
const TERRAFORM_MODULE_ASSET_SYMBOL = Symbol.for("cdktf.TerraformModuleAsset");
1516

@@ -66,10 +67,19 @@ export class TerraformModuleAsset extends Construct {
6667
}
6768

6869
// Create asset based on tmp dir
70+
const canonical = !!this.node.tryGetContext(CANONICAL_ASSET_HASHES);
6971
this.asset = new TerraformAsset(this, "asset", {
7072
path: tmpDir,
7173
type: AssetType.DIRECTORY,
72-
assetHash: staticModuleAssetHash ?? hashPath(relativeAssetPath),
74+
// The emitted asset is only the module sources copied into tmpDir, so
75+
// the canonical scheme hashes that exact tree — unrelated siblings
76+
// under the sources' common ancestor cannot churn the hash. Legacy
77+
// keeps hashing the ancestor to preserve historical hashes.
78+
assetHash:
79+
staticModuleAssetHash ??
80+
(canonical
81+
? hashPath(tmpDir, { canonical: true })
82+
: hashPath(relativeAssetPath)),
7383
});
7484
}
7585

0 commit comments

Comments
 (0)