Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 90 additions & 20 deletions packages/cdktn/src/private/fs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,33 @@
import * as fs from "fs";
import * as path from "path";
import * as crypto from "crypto";
import { zipSync } from "fflate";
import { strToU8, zipSync } from "fflate";
import type { ZipOptions } from "fflate";
import { assetCanNotCreateZipArchive } from "../errors";

const HASH_LEN = 32;

// Unix file-type bits for zip external attributes (st_mode upper nibble)
const S_IFLNK = 0o120000;
const S_IFREG = 0o100000;
const PERM_MASK = 0o7777;
// "version made by" host byte; unzip only honors unix mode attrs when set
const ZIP_OS_UNIX = 3;
// Pinned so archives are byte-reproducible across synths. fflate encodes
// DOS dates from local-time getters and rejects years outside 1980-2099,
// so this must be a local-time 1980 date (a UTC one underflows to 1979 in
// timezones west of UTC).
const ZIP_ENTRY_MTIME = new Date(1980, 0, 1);

/**
* Zip external attributes for a unix mode: file-type and permission bits
* belong in the high 16 bits of the 32-bit field.
* @param mode - unix st_mode bits (type | permissions)
*/
function zipAttrs(mode: number): number {
return (mode << 16) >>> 0;
}

// Full implementation at https://github.com/jprichardson/node-fs-extra/blob/master/lib/copy/copy-sync.js
/**
* Copy a file or directory. The directory can have contents and subfolders.
Expand All @@ -21,11 +43,12 @@ export function copySync(src: string, dest: string) {
*/
Comment thread
so0k marked this conversation as resolved.
function copyItem(p: string) {
const sourcePath = path.resolve(src, p);
const stat = fs.statSync(sourcePath);
if (stat.isFile()) {
const stat = fs.lstatSync(sourcePath);
if (stat.isSymbolicLink()) {
fs.symlinkSync(fs.readlinkSync(sourcePath), path.resolve(dest, p));
} else if (stat.isFile()) {
fs.copyFileSync(sourcePath, path.resolve(dest, p));
}
if (stat.isDirectory()) {
} else if (stat.isDirectory()) {
walkSubfolder(p);
}
}
Expand All @@ -51,52 +74,99 @@ export function copySync(src: string, dest: string) {
*/
export function archiveSync(src: string, dest: string) {
try {
const files: Record<string, Uint8Array> = {};
const files: Record<string, [Uint8Array, ZipOptions]> = {};
const walk = (dir: string, prefix: string) => {
for (const entry of fs.readdirSync(dir)) {
const full = path.join(dir, entry);
const zipPath = prefix ? `${prefix}/${entry}` : entry;
if (fs.statSync(full).isDirectory()) {
const stat = fs.lstatSync(full);
if (stat.isSymbolicLink()) {
// Store the link target as the entry data with S_IFLNK attrs so
// extractors recreate the symlink instead of a copy of the target.
// Never recursing through links also makes cycles unreachable.
files[zipPath] = [
strToU8(fs.readlinkSync(full)),
{
os: ZIP_OS_UNIX,
attrs: zipAttrs(S_IFLNK | (stat.mode & PERM_MASK)),
level: 0,
},
];
} else if (stat.isDirectory()) {
walk(full, zipPath);
} else {
files[zipPath] = fs.readFileSync(full);
files[zipPath] = [
fs.readFileSync(full),
{
os: ZIP_OS_UNIX,
attrs: zipAttrs(S_IFREG | (stat.mode & PERM_MASK)),
},
];
}
}
};
walk(src, "");
fs.writeFileSync(dest, zipSync(files, { level: 9 }));
fs.writeFileSync(
dest,
zipSync(files, { level: 9, mtime: ZIP_ENTRY_MTIME }),
);
} catch (err: any) {
throw assetCanNotCreateZipArchive(src, dest, err);
}
}

/**
* Compute a stable MD5 hash of a file or directory's contents.
* Directories are hashed by recursively folding each file's contents into
* the same digest, in directory-listing order.
* File contents fold into one digest in directory-listing order, exactly as
* earlier releases did, so trees without symlinks keep their historical
* hashes. Symlinks are hashed by their metadata (path + target) instead of
* being followed, so shared targets are not double-counted and cycles cannot
* recurse; that metadata goes into a second digest, and only when symlinks
* exist are the two combined under a tagged outer hash — the tag keeps
* symlink metadata in a separate domain from file bytes, so a file
* containing `foo` can never collide with a symlink targeting `foo`.
* @param src - path to a file or directory to hash
* @returns uppercased hex digest, truncated to HASH_LEN characters
*/
export function hashPath(src: string): string {
const hash = crypto.createHash("md5");
const content = crypto.createHash("md5");
const links = crypto.createHash("md5");
let linkCount = 0;

/**
* Walk `p`, feeding any file contents into the enclosing hash accumulator.
* Walk `p`, feeding file contents and symlink metadata into the enclosing
* accumulators.
* @param p - path to walk
* @param relPath - path of `p` relative to the walk root, `/`-separated
* @param isRoot - follow a symlink only at the root, matching how the
* asset's own path is resolved when it is read
*/
function hashRecursion(p: string) {
const stat = fs.statSync(p);
if (stat.isFile()) {
hash.update(fs.readFileSync(p));
function hashRecursion(p: string, relPath: string, isRoot = false) {
const stat = isRoot ? fs.statSync(p) : fs.lstatSync(p);
if (stat.isSymbolicLink()) {
links.update(`${relPath}\0${fs.readlinkSync(p)}\0`);
linkCount++;
} else if (stat.isFile()) {
content.update(fs.readFileSync(p));
} else if (stat.isDirectory()) {
fs.readdirSync(p).forEach((filename) =>
hashRecursion(path.resolve(p, filename)),
hashRecursion(
path.resolve(p, filename),
relPath ? `${relPath}/${filename}` : filename,
),
);
}
}

hashRecursion(src);
return hash.digest("hex").slice(0, HASH_LEN).toUpperCase();
hashRecursion(src, "", true);
if (linkCount === 0) {
return content.digest("hex").slice(0, HASH_LEN).toUpperCase();
}
const outer = crypto.createHash("md5");
outer.update("cdktn/asset-hash/symlinks/v1\0");
outer.update(content.digest("hex"));
outer.update(links.digest("hex"));
return outer.digest("hex").slice(0, HASH_LEN).toUpperCase();
}

/**
Expand Down
18 changes: 1 addition & 17 deletions packages/cdktn/src/terraform-module-asset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import * as os from "os";
import * as fs from "fs";
import { TerraformStack } from "./terraform-stack";
import { AssetType, TerraformAsset } from "./terraform-asset";
import { hashPath } from "./private/fs";
import { copySync, hashPath } from "./private/fs";

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

Expand Down Expand Up @@ -127,19 +127,3 @@ export function findLowestCommonPath(paths: string[]): string | undefined {
const relativePath = path.relative(process.cwd(), absolutePathPrefix);
return relativePath === "" ? "." : relativePath;
}

/**
* Copies a file or directory recursively
* @param from
* @param to
*/
function copySync(from: string, to: string) {
if (fs.lstatSync(from).isDirectory()) {
fs.mkdirSync(to, { recursive: true });
for (const file of fs.readdirSync(from)) {
copySync(path.join(from, file), path.join(to, file));
}
} else {
fs.copyFileSync(from, to);
}
}
Loading
Loading