From d570e96f5d99c28cdd85b7287aada679f812bf9a Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 22 Sep 2026 18:05:00 -0400 Subject: [PATCH] fix(media-use): drop the sanitizer's workspace import, add parity test freeze.mjs importing @hyperframes/core/sanitize-svg broke the test-skills CI lane: packages/cli/src/media-use tests run with node builtins only, zero install, by design (media-use is vendored standalone into other projects, e.g. packages/desktop). Port the sanitizer into media-use/lib/svg-sanitize.mjs instead, and drop the now-unused core export. Parity between the port and packages/core/src/figma/sanitizeSvg.ts is enforced by a new test (scripts/check-svg-sanitize-parity.test.mjs, run in the test:scripts lane, which does have an install) over a hostile-SVG corpus, so the two cannot drift silently. --- package.json | 2 +- packages/cli/src/media-use/lib/freeze.mjs | 2 +- .../cli/src/media-use/lib/svg-sanitize.mjs | 36 ++++++++++++ packages/core/package-subpaths.json | 6 -- packages/core/package.json | 10 ---- scripts/check-svg-sanitize-parity.test.mjs | 56 +++++++++++++++++++ 6 files changed, 94 insertions(+), 18 deletions(-) create mode 100644 packages/cli/src/media-use/lib/svg-sanitize.mjs create mode 100644 scripts/check-svg-sanitize-parity.test.mjs diff --git a/package.json b/package.json index 8743467b56..74921c60f5 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,7 @@ "player:perf": "bun run --filter @hyperframes/player perf", "format:check": "oxfmt --check .", "knip": "knip", - "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", + "test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-registry-set-delta.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-pr-captures.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-media-use-copy-parity.test.mjs scripts/check-svg-sanitize-parity.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts scripts/catalog-drift.test.ts scripts/catalog-fetch-mirror.test.ts scripts/catalog-script-inlining.test.ts scripts/catalog-detail.test.ts scripts/generate-catalog-pages.test.ts scripts/verify-catalog-payloads.test.ts scripts/registry-skill-files.test.ts scripts/creator-editing-capabilities.test.mjs scripts/generate-catalog-previews.test.ts scripts/registry-primitive-payloads.test.ts registry/components/pan-stations/pan-stations.test.mjs && vitest run scripts/catalog/ scripts/contrastRatchet.test.ts scripts/generate-catalog-payloads.test.ts", "typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json", "test:skills": "node --test 'skills/**/*.test.mjs' 'packages/cli/src/media-use/**/*.test.mjs'", "generate:previews": "tsx scripts/generate-template-previews.ts", diff --git a/packages/cli/src/media-use/lib/freeze.mjs b/packages/cli/src/media-use/lib/freeze.mjs index dbfc12741d..b8ca20e50f 100644 --- a/packages/cli/src/media-use/lib/freeze.mjs +++ b/packages/cli/src/media-use/lib/freeze.mjs @@ -1,5 +1,5 @@ import { fetchMedia, isPublicMediaUrl } from "./media-fetch.mjs"; -import { sanitizeSvg } from "@hyperframes/core/sanitize-svg"; +import { sanitizeSvg } from "./svg-sanitize.mjs"; import { writeFileSync, copyFileSync, mkdirSync, readFileSync } from "node:fs"; import { dirname } from "node:path"; diff --git a/packages/cli/src/media-use/lib/svg-sanitize.mjs b/packages/cli/src/media-use/lib/svg-sanitize.mjs new file mode 100644 index 0000000000..21b435e087 --- /dev/null +++ b/packages/cli/src/media-use/lib/svg-sanitize.mjs @@ -0,0 +1,36 @@ +// Port of packages/core/src/figma/sanitizeSvg.ts; parity enforced by scripts/check-svg-sanitize-parity.test.mjs. + +function replaceStable(input, pattern, replacement) { + let out = input; + let prev; + do { + prev = out; + out = out.replace(pattern, replacement); + } while (out !== prev); + return out; +} + +function isAllowedHref(value) { + const v = value.trim().toLowerCase(); + return v.startsWith("#") || v.startsWith("data:image/"); +} + +export function sanitizeSvg(svg) { + let out = svg; + out = replaceStable(out, /]*>/gi, ""); + out = replaceStable(out, /]*\/>/gi, ""); + out = replaceStable(out, /]*>/gi, ""); + out = replaceStable(out, /]*>/gi, ""); + out = replaceStable(out, /]*\/>/gi, ""); + out = out.replace(/<\/(?:script|style|foreignObject)\b[^>]*>/gi, ""); + out = replaceStable(out, /\son[a-z]+\s*=\s*"[^"]*"/gi, ""); + out = replaceStable(out, /\son[a-z]+\s*=\s*'[^']*'/gi, ""); + out = replaceStable(out, /\son[a-z]+\s*=\s*[^\s>'"]+/gi, ""); + out = out.replace(/\s(href|xlink:href)\s*=\s*"([^"]*)"/gi, (m, _attr, value) => + isAllowedHref(value) ? m : "", + ); + out = out.replace(/\s(href|xlink:href)\s*=\s*'([^']*)'/gi, (m, _attr, value) => + isAllowedHref(value) ? m : "", + ); + return out; +} diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index f4e53543cb..c520a7e9da 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -44,12 +44,6 @@ "types": "./dist/utils/richTextSanitize.d.ts", "environments": ["browser", "bun", "node"] }, - "./sanitize-svg": { - "source": "./src/figma/sanitizeSvg.ts", - "runtime": "./dist/figma/sanitizeSvg.js", - "types": "./dist/figma/sanitizeSvg.d.ts", - "environments": ["browser", "bun", "node"] - }, "./composition-contract": { "source": "./src/compositionContract.ts", "runtime": "./dist/compositionContract.js", diff --git a/packages/core/package.json b/packages/core/package.json index ea5c0ee72d..f7c2a3db8e 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -58,12 +58,6 @@ "import": "./src/utils/richTextSanitize.ts", "types": "./src/utils/richTextSanitize.ts" }, - "./sanitize-svg": { - "bun": "./src/figma/sanitizeSvg.ts", - "node": "./dist/figma/sanitizeSvg.js", - "import": "./src/figma/sanitizeSvg.ts", - "types": "./src/figma/sanitizeSvg.ts" - }, "./composition-contract": { "bun": "./src/compositionContract.ts", "node": "./dist/compositionContract.js", @@ -468,10 +462,6 @@ "import": "./dist/utils/richTextSanitize.js", "types": "./dist/utils/richTextSanitize.d.ts" }, - "./sanitize-svg": { - "import": "./dist/figma/sanitizeSvg.js", - "types": "./dist/figma/sanitizeSvg.d.ts" - }, "./composition-contract": { "import": "./dist/compositionContract.js", "types": "./dist/compositionContract.d.ts" diff --git a/scripts/check-svg-sanitize-parity.test.mjs b/scripts/check-svg-sanitize-parity.test.mjs new file mode 100644 index 0000000000..f6dbccfab8 --- /dev/null +++ b/scripts/check-svg-sanitize-parity.test.mjs @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { sanitizeSvg as coreSanitizeSvg } from "../packages/core/src/figma/sanitizeSvg.ts"; +import { sanitizeSvg as mediaUseSanitizeSvg } from "../packages/cli/src/media-use/lib/svg-sanitize.mjs"; + +// media-use/lib/svg-sanitize.mjs is a hand-kept port of +// packages/core/src/figma/sanitizeSvg.ts (media-use's test lane forbids +// workspace imports). This corpus is the trip wire: if either changes +// without the other, one of these cases stops matching. +const HOSTILE_CORPUS = [ + ["", "script element"], + ['', "self-closing script"], + ['', "style block"], + ['', "foreignObject subtree"], + ["", "self-closing foreignObject"], + ['', "quoted on* handler"], + [``, "single-quoted on* handler"], + ["", "unquoted on* handler"], + ['', "javascript: href"], + ['', "javascript: xlink:href"], + ['', "allowed data:image/ href"], + ['', "allowed local fragment href"], + ['', "data:text/html href"], + [ + "", + "nested/overlapping script tags", + ], + [ + "", + "nested foreignObject", + ], + [ + '', + "nested svg element", + ], + ["", "mixed-case on* handler names"], + ['', "mixed-case href attribute name"], +]; + +describe("svg-sanitize parity: core vs media-use port", () => { + for (const [input, label] of HOSTILE_CORPUS) { + it(`matches core output for: ${label}`, () => { + assert.equal(mediaUseSanitizeSvg(input), coreSanitizeSvg(input)); + }); + } + + it("both strip every hostile marker from the full corpus", () => { + for (const [input] of HOSTILE_CORPUS) { + const out = mediaUseSanitizeSvg(input); + assert.ok(!/