Skip to content
Merged
24 changes: 24 additions & 0 deletions .fallowrc.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,19 @@
"file": "packages/studio/src/utils/timelineElementSplit.ts",
"exports": ["buildPatchTarget", "readFileContent"],
},
// freezeUrl and freezeLocalFile are public API for Task 4 manifest flow
// and the /figma skill integration; not yet imported by current code.
{
"file": "packages/core/src/figma/freeze.ts",
"exports": ["freezeUrl", "freezeLocalFile"],
},
// mediaDir, typeDirPath, isFigmaManifestRecord: exported from manifest.ts
// for barrel wiring in Task 8 (one-batch export across Tasks 2-7).
// Not yet consumed by any code in the current stack.
{
"file": "packages/core/src/figma/manifest.ts",
"exports": ["mediaDir", "typeDirPath", "isFigmaManifestRecord"],
},
],
"ignoreDependencies": [
// Runtime/dynamic deps not visible to static analysis: tsup `external`,
Expand Down Expand Up @@ -325,6 +338,12 @@
// → assert-remove-spawn shape; each verifies a distinct prune behavior, so
// extracting the shared scaffold would obscure what each case asserts.
"packages/cli/src/commands/skills.test.ts",
// figma test files: freeze.test.ts and manifest.test.ts share a common
// arrange/act/assert setup preamble (file creation + test dir handling) that
// is minimal boilerplate; collapsing it into a shared fixture would reduce
// readability of each test's independent setup.
"packages/core/src/figma/freeze.test.ts",
"packages/core/src/figma/manifest.test.ts",
// layout-audit.browser.test.ts: parallel arrange/act/assert cases per audit
// rule (overflow / overlap / occlusion) that each install their own mocked
// geometry + computed-style before asserting. The clone groups are this
Expand Down Expand Up @@ -447,6 +466,11 @@
// label-position code added earlier in the file shifted its line number,
// so the line-shift fingerprint re-flags this inherited finding.
"packages/parsers/src/gsapParserAcorn.ts",
// isFigmaManifestRecord: type guard with 19 cyclomatic due to chained
// field validation (id/type/path/source types + value shape guards).
// This is the correct shape for a discriminating type guard; refactoring
// into smaller guards would obscure the unified validation contract.
"packages/core/src/figma/manifest.ts",
],
},
}
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,12 @@
"import": "./src/fonts/systemFontLocator.ts",
"types": "./src/fonts/systemFontLocator.ts"
},
"./figma": {
"bun": "./src/figma/index.ts",
"node": "./dist/figma/index.js",
"import": "./src/figma/index.ts",
"types": "./src/figma/index.ts"
},
"./schemas/registry.json": "./schemas/registry.json",
"./schemas/registry-item.json": "./schemas/registry-item.json"
},
Expand All @@ -226,6 +232,10 @@
"import": "./dist/editing/affordances.js",
"types": "./dist/editing/affordances.d.ts"
},
"./figma": {
"import": "./dist/figma/index.js",
"types": "./dist/figma/index.d.ts"
},
"./generators": {
"import": "./dist/generators/hyperframes.js",
"types": "./dist/generators/hyperframes.d.ts"
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/figma/assetSnippet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { buildAssetSnippet } from "./assetSnippet";
import type { FigmaManifestRecord } from "./types";

const record: FigmaManifestRecord = {
id: "image_001",
type: "image",
path: ".media/images/image_001.png",
source: "figma",
description: 'Hero "banner"',
width: 240,
height: 57,
provenance: { source: "figma", fileKey: "FK", nodeId: "92:573", format: "png" },
};

describe("buildAssetSnippet", () => {
it("emits an img tag with src, dims, escaped alt, and data-figma-id", () => {
const { path, html } = buildAssetSnippet(record);
expect(path).toBe(".media/images/image_001.png");
expect(html).toContain('src=".media/images/image_001.png"');
expect(html).toContain('width="240"');
expect(html).toContain('height="57"');
expect(html).toContain('data-figma-id="92:573"');
expect(html).toContain(""banner"");
expect(html).not.toContain('"banner"');
});
});
20 changes: 20 additions & 0 deletions packages/core/src/figma/assetSnippet.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { AssetSnippet, FigmaManifestRecord } from "./types";

function escapeAttr(value: string): string {
return value
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}

export function buildAssetSnippet(record: FigmaManifestRecord): AssetSnippet {
// Every interpolated attribute is escaped — path and nodeId are
// system-generated today, but defense-in-depth is free here.
const alt = escapeAttr(record.description ?? record.id);
const src = escapeAttr(record.path);
const w = record.width !== undefined ? ` width="${record.width}"` : "";
const h = record.height !== undefined ? ` height="${record.height}"` : "";
const html = `<img src="${src}" alt="${alt}"${w}${h} data-figma-id="${escapeAttr(record.provenance.nodeId)}" />`;
return { path: record.path, html };
}
58 changes: 58 additions & 0 deletions packages/core/src/figma/freeze.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { exceedsFreezeCap, freezeBytes, MAX_FREEZE_BYTES } from "./freeze";

const dirs: string[] = [];
function scratch(): string {
const d = mkdtempSync(join(tmpdir(), "hf-freeze-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});

describe("exceedsFreezeCap", () => {
it("is false at and under the cap, true just over it", () => {
expect(exceedsFreezeCap(MAX_FREEZE_BYTES)).toBe(false);
expect(exceedsFreezeCap(MAX_FREEZE_BYTES + 1)).toBe(true);
});
});

describe("freezeBytes", () => {
it("writes bytes to a nested path and returns the length", () => {
const dest = join(scratch(), "images", "a.png");
const bytes = new Uint8Array([1, 2, 3, 4]);
expect(freezeBytes(bytes, dest)).toBe(4);
expect(Array.from(readFileSync(dest))).toEqual([1, 2, 3, 4]);
});

it("throws on empty bytes", () => {
expect(() => freezeBytes(new Uint8Array(0), join(scratch(), "x"))).toThrow();
});
});

describe("freezeUrl allowlist", () => {
it("accepts figma + figma-s3 hosts, https only", async () => {
const { isAllowedFreezeUrl } = await import("./freeze");
expect(isAllowedFreezeUrl("https://s3-alpha-sig.figma.com/img/x")).toBe(true);
expect(isAllowedFreezeUrl("https://figma-alpha-api.s3.us-west-2.amazonaws.com/images/x")).toBe(
true,
);
expect(isAllowedFreezeUrl("http://s3-alpha-sig.figma.com/img/x")).toBe(false);
expect(isAllowedFreezeUrl("https://169.254.169.254/latest/meta-data/")).toBe(false);
expect(isAllowedFreezeUrl("https://evilfigma.com/x")).toBe(false);
expect(isAllowedFreezeUrl("file:///etc/passwd")).toBe(false);
expect(isAllowedFreezeUrl("not a url")).toBe(false);
});

it("refuses to freeze from a non-allowlisted url", async () => {
const { freezeUrl } = await import("./freeze");
await expect(freezeUrl("http://localhost:6379/x", "/tmp/never")).rejects.toThrow(
/refusing non-figma url/,
);
});
});
67 changes: 67 additions & 0 deletions packages/core/src/figma/freeze.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* "Freeze" = write asset bytes to local disk permanently so renders never
* re-fetch from figma (design spec §5) — not Object.freeze.
*/

import { copyFileSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";

// ponytail: bound the write so a hostile/runaway source can't fill the disk.
export const MAX_FREEZE_BYTES = 256 * 1024 * 1024;

export function exceedsFreezeCap(byteLength: number): boolean {
return byteLength > MAX_FREEZE_BYTES;
}

export function freezeBytes(bytes: Uint8Array, destPath: string): number {
if (bytes.length === 0) throw new Error("freeze failed: empty bytes");
if (exceedsFreezeCap(bytes.length))
throw new Error(`freeze failed: ${bytes.length} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
// Exclusive create; on EEXIST remove and retry — never write through an
// existing file or planted symlink (CodeQL js/insecure-temporary-file).
try {
writeFileSync(destPath, bytes, { flag: "wx" });

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
Comment thread
vanceingalls marked this conversation as resolved.
Dismissed
Comment thread
vanceingalls marked this conversation as resolved.
Dismissed
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== "EEXIST") throw err;
rmSync(destPath);
writeFileSync(destPath, bytes, { flag: "wx" });

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.

Check warning

Code scanning / CodeQL

Network data written to file Medium

Write to file system depends on
Untrusted data
.
Comment thread
vanceingalls marked this conversation as resolved.
Dismissed
Comment thread
vanceingalls marked this conversation as resolved.
Dismissed
}
return bytes.length;
}

/**
* Only figma-owned hosts may be frozen from a URL — render/CDN responses
* come from figma.com subdomains or figma's S3 buckets. Blocks SSRF via a
* crafted manifest/config URL (metadata endpoints, internal services).
*/
export function isAllowedFreezeUrl(url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== "https:") return false;
const host = parsed.hostname;
return host === "figma.com" || host.endsWith(".figma.com") || host.endsWith(".amazonaws.com");
}

export async function freezeUrl(url: string, destPath: string): Promise<number> {
if (!isAllowedFreezeUrl(url))
throw new Error(`freeze failed: refusing non-figma url ${url} (https + figma hosts only)`);
const res = await fetch(url);
if (!res.ok) throw new Error(`freeze failed: HTTP ${res.status}`);
const declared = Number(res.headers.get("content-length") ?? 0);
if (exceedsFreezeCap(declared))
throw new Error(`freeze failed: content-length ${declared} exceeds ${MAX_FREEZE_BYTES} cap`);
return freezeBytes(new Uint8Array(await res.arrayBuffer()), destPath);
}

export function freezeLocalFile(srcPath: string, destPath: string): void {
const size = statSync(srcPath).size;
if (exceedsFreezeCap(size))
throw new Error(`freeze failed: ${size} bytes exceeds ${MAX_FREEZE_BYTES} cap`);
mkdirSync(dirname(destPath), { recursive: true });
copyFileSync(srcPath, destPath);
}
9 changes: 9 additions & 0 deletions packages/core/src/figma/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { MAX_FREEZE_BYTES } from "./index";

describe("figma barrel", () => {
it("re-exports the freeze cap constant", () => {
expect(MAX_FREEZE_BYTES).toBe(256 * 1024 * 1024);
});
});
2 changes: 2 additions & 0 deletions packages/core/src/figma/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./types";
export { MAX_FREEZE_BYTES } from "./freeze";
93 changes: 93 additions & 0 deletions packages/core/src/figma/manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment node
import { describe, expect, it, afterEach } from "vitest";
import { appendFileSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { appendRecord, findByFigmaNode, manifestPath, nextId, readManifest } from "./manifest";
import type { FigmaManifestRecord } from "./types";

const dirs: string[] = [];
function project(): string {
const d = mkdtempSync(join(tmpdir(), "hf-manifest-"));
dirs.push(d);
return d;
}
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});

function rec(id: string, nodeId: string): FigmaManifestRecord {
return {
id,
type: "image",
path: `.media/images/${id}.png`,
source: "figma",
provenance: { source: "figma", fileKey: "FK", nodeId, format: "png" },
};
}

describe("manifest", () => {
it("appends and reads back records", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendRecord(p, rec("image_002", "3:4"));
const all = readManifest(p);
expect(all.map((r) => r.id)).toEqual(["image_001", "image_002"]);
expect(all[1]?.provenance.nodeId).toBe("3:4");
});

it("finds a record by figma node", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
expect(findByFigmaNode(p, "FK", "1:2")?.id).toBe("image_001");
expect(findByFigmaNode(p, "FK", "9:9")).toBeNull();
});

it("allocates incrementing ids per type", () => {
const p = project();
expect(nextId(p, "image")).toBe("image_001");
appendRecord(p, rec("image_001", "1:2"));
expect(nextId(p, "image")).toBe("image_002");
});

it("skips a manifest line that doesn't match the record shape", () => {
const p = project();
appendRecord(p, rec("image_001", "1:2"));
appendFileSync(manifestPath(p), JSON.stringify({ foo: "bar" }) + "\n");
appendRecord(p, rec("image_002", "3:4"));
expect(readManifest(p).map((r) => r.id)).toEqual(["image_001", "image_002"]);
});

it("nextId scans other writers' rows (media-use) so ids never collide", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
// media-use shaped row: no provenance.source — fails the figma guard
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "image_007",
type: "image",
path: ".media/images/image_007.png",
source: "unsplash",
provenance: { provider: "unsplash" },
}) + "\n",
);
expect(nextId(p, "image")).toBe("image_008");
});

it("rejects manifest rows with a non image/video type", () => {
const p = project();
mkdirSync(join(p, ".media"), { recursive: true });
appendFileSync(
manifestPath(p),
JSON.stringify({
id: "audio_001",
type: "audio",
path: "x",
source: "figma:F/1",
provenance: { source: "figma", fileKey: "F", nodeId: "1:1" },
}) + "\n",
);
expect(readManifest(p)).toHaveLength(0);
});
});
Loading
Loading