-
Notifications
You must be signed in to change notification settings - Fork 3.1k
feat(core): figma module foundations — types, parseFigmaRef, freeze, manifest, asset snippet #1868
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ebf7e2d
be6b55c
6369b1c
cc8473a
72db951
375ed27
77cef97
9e0eabc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"'); | ||
| }); | ||
| }); |
| 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, "<") | ||
| .replace(/>/g, ">") | ||
| .replace(/"/g, """); | ||
| } | ||
|
|
||
| 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 }; | ||
| } |
| 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/, | ||
| ); | ||
| }); | ||
| }); |
| 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 failureCode scanning / CodeQL Insecure temporary file High
Insecure creation of file in
the os temp dir Error loading related location Loading Check warningCode scanning / CodeQL Network data written to file Medium
Write to file system depends on
Untrusted data Error loading related location Loading |
||
|
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 failureCode scanning / CodeQL Insecure temporary file High
Insecure creation of file in
the os temp dir Error loading related location Loading Check warningCode scanning / CodeQL Network data written to file Medium
Write to file system depends on
Untrusted data Error loading related location Loading |
||
|
vanceingalls marked this conversation as resolved.
Dismissed
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); | ||
| } | ||
| 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); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from "./types"; | ||
| export { MAX_FREEZE_BYTES } from "./freeze"; |
| 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); | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.