@1agh/maude 0.58.2 → 0.58.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/annotations-bindings.ts +83 -4
- package/apps/studio/api.ts +6 -1
- package/apps/studio/bin/_fetch-asset.mjs +169 -5
- package/apps/studio/bin/_import-asset.mjs +72 -0
- package/apps/studio/bin/_import-figma.mjs +1121 -0
- package/apps/studio/bin/_video-playwright.mjs +86 -3
- package/apps/studio/bin/import-figma.sh +38 -0
- package/apps/studio/bin/read-annotations.mjs +11 -1
- package/apps/studio/bun.lock +16 -22
- package/apps/studio/canvas-edit.ts +29 -5
- package/apps/studio/client/app.jsx +44 -1
- package/apps/studio/client/export-center.jsx +42 -4
- package/apps/studio/client/panels/CloudBar.jsx +92 -1
- package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
- package/apps/studio/client/panels/GitPanel.jsx +26 -6
- package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
- package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
- package/apps/studio/client/panels/timeline-parse.js +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +7 -0
- package/apps/studio/client/styles/4-components.css +134 -0
- package/apps/studio/clip-ops.ts +93 -17
- package/apps/studio/cloud/endpoints.ts +78 -10
- package/apps/studio/cloud/renew.ts +183 -0
- package/apps/studio/context.ts +2 -1
- package/apps/studio/dist/client.bundle.js +1261 -1261
- package/apps/studio/dist/runtime/@remotion_media.js +56 -136
- package/apps/studio/dist/runtime/@remotion_player.js +18 -18
- package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
- package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
- package/apps/studio/dist/runtime/remotion.js +12 -12
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/exporters/_browser-bundles.ts +20 -6
- package/apps/studio/exporters/_runtime.ts +19 -0
- package/apps/studio/exporters/degraded.ts +92 -0
- package/apps/studio/exporters/index.ts +5 -0
- package/apps/studio/exporters/jobs.ts +19 -0
- package/apps/studio/exporters/unsupported-media.ts +170 -0
- package/apps/studio/exporters/video-encode-lib.ts +27 -1
- package/apps/studio/exporters/video-render-lib.ts +6 -0
- package/apps/studio/exporters/video.ts +62 -1
- package/apps/studio/figma/assets.test.ts +372 -0
- package/apps/studio/figma/assets.ts +398 -0
- package/apps/studio/figma/client.test.ts +395 -0
- package/apps/studio/figma/client.ts +513 -0
- package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
- package/apps/studio/figma/comments-to-strokes.ts +173 -0
- package/apps/studio/figma/endpoints.ts +200 -0
- package/apps/studio/figma/sanitize.test.ts +256 -0
- package/apps/studio/figma/sanitize.ts +315 -0
- package/apps/studio/figma/style-map.ts +352 -0
- package/apps/studio/figma/to-artboard.test.ts +808 -0
- package/apps/studio/figma/to-artboard.ts +701 -0
- package/apps/studio/figma/to-render.test.ts +180 -0
- package/apps/studio/figma/to-render.ts +306 -0
- package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
- package/apps/studio/figma/to-strokes.test.ts +705 -0
- package/apps/studio/figma/to-strokes.ts +749 -0
- package/apps/studio/figma/to-tokens.test.ts +321 -0
- package/apps/studio/figma/to-tokens.ts +305 -0
- package/apps/studio/figma/types.ts +539 -0
- package/apps/studio/figma/url.test.ts +167 -0
- package/apps/studio/figma/url.ts +160 -0
- package/apps/studio/http.ts +129 -0
- package/apps/studio/sync/asset-push.ts +124 -0
- package/apps/studio/sync/connection-state.ts +11 -0
- package/apps/studio/sync/hub-link.ts +63 -7
- package/apps/studio/sync/hubs-config.ts +31 -3
- package/apps/studio/sync/index.ts +276 -26
- package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
- package/apps/studio/sync/presentation.ts +45 -1
- package/apps/studio/sync/supervisor.ts +5 -1
- package/apps/studio/sync/workspace-signin.ts +7 -3
- package/apps/studio/test/annotations-bindings.test.ts +150 -12
- package/apps/studio/test/canvas-create-api.test.ts +4 -1
- package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
- package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
- package/apps/studio/test/clip-addressing.test.ts +6 -1
- package/apps/studio/test/clip-ops.test.ts +5 -1
- package/apps/studio/test/cloud-endpoints.test.ts +96 -0
- package/apps/studio/test/cloud-renew.test.ts +205 -0
- package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
- package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
- package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
- package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
- package/apps/studio/test/figma-provenance.test.ts +108 -0
- package/apps/studio/test/figma-routes.test.ts +294 -0
- package/apps/studio/test/git-cloud-posture.test.ts +50 -0
- package/apps/studio/test/hub-link.test.ts +11 -0
- package/apps/studio/test/import-figma.test.ts +479 -0
- package/apps/studio/test/sync-asset-push.test.ts +124 -0
- package/apps/studio/test/sync-connection-state.test.ts +13 -0
- package/apps/studio/test/sync-hubs-config.test.ts +5 -0
- package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
- package/apps/studio/test/sync-path-pull.test.ts +63 -0
- package/apps/studio/test/sync-presentation.test.ts +77 -0
- package/apps/studio/test/sync-runtime.test.ts +316 -1
- package/apps/studio/test/video-comp.test.ts +23 -1
- package/apps/studio/test/workspace-containment.test.ts +1 -0
- package/apps/studio/video-comp.tsx +70 -6
- package/apps/studio/whats-new.json +27 -0
- package/apps/studio/workspace-mode.ts +4 -0
- package/cli/commands/design.mjs +8 -0
- package/package.json +8 -8
- package/plugins/flow/.claude-plugin/config.schema.json +3 -3
|
@@ -17,18 +17,69 @@ import {
|
|
|
17
17
|
rotatePoint,
|
|
18
18
|
type Stroke,
|
|
19
19
|
strokeBBox,
|
|
20
|
+
strokeHitTest,
|
|
20
21
|
strokeRotation,
|
|
21
22
|
} from './annotations-model.ts';
|
|
22
23
|
|
|
23
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* The largest bbox a TEXT stroke may present as a bind target, PER AXIS.
|
|
26
|
+
*
|
|
27
|
+
* A `TextStroke` has no stored `w`/`h` — `strokeBBox` SYNTHESIZES one from the
|
|
28
|
+
* content (`max(8, longest * fontSize * 0.55)` wide). So a long single line can
|
|
29
|
+
* project a bind strip tens of thousands of px wide, and `bindCandidate` scans
|
|
30
|
+
* topmost-first with no area preference: one such stroke would silently become a
|
|
31
|
+
* board-wide magnet that steals every endpoint the user drags. Text bigger than
|
|
32
|
+
* a plausible label is therefore not offered as a host at all (DDR-216 D9).
|
|
33
|
+
*/
|
|
34
|
+
export const MAX_TEXT_BIND_W = 1200;
|
|
35
|
+
export const MAX_TEXT_BIND_H = 480;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Hosts an arrow endpoint can attach to (shapes + cards, FigJam set).
|
|
39
|
+
*
|
|
40
|
+
* **Widened to `text` + `section` (DDR-216 D9).** FigJam binds connectors to
|
|
41
|
+
* anything, and on the only real board measured 2/2 connectors had at least one
|
|
42
|
+
* endpoint Maude could not bind — so an imported connector degraded to a frozen
|
|
43
|
+
* line. It is also a plain native gap: you could not attach an arrow to a text
|
|
44
|
+
* label or a section, independent of import.
|
|
45
|
+
*
|
|
46
|
+
* Three deliberate exclusions, each closing a regression the naive widening
|
|
47
|
+
* would have introduced:
|
|
48
|
+
*
|
|
49
|
+
* 1. **ANCHORED text is still not bindable.** `bindCandidate`/`anchorPoint`/
|
|
50
|
+
* `recomputeBoundArrows` call `strokeBBox(s)` WITHOUT the anchors map, and
|
|
51
|
+
* for a text stroke with `anchorId` that returns `null`. Admitting it would
|
|
52
|
+
* mint a bind that `anchorPoint` can never resolve — and `applyEnd` would
|
|
53
|
+
* keep it (it only strips when the host stops being bindable), freezing the
|
|
54
|
+
* arrow at stale coordinates forever. That is strictly worse than today's
|
|
55
|
+
* honest "strip the bind, freeze the endpoint". Anchored text lives inside a
|
|
56
|
+
* host shape which is itself bindable, so nothing is lost: bind to the shape.
|
|
57
|
+
* 2. **Oversized text is not offered** — see `MAX_TEXT_BIND_W`/`_H`.
|
|
58
|
+
* 3. **Groups are not bindable by construction** — Maude groups are a flat
|
|
59
|
+
* `groupIds[]` tag array, not addressable objects. A group-targeted import
|
|
60
|
+
* endpoint falls back to the group's geometric bbox and is reported.
|
|
61
|
+
*/
|
|
24
62
|
export function isBindable(s: Stroke): boolean {
|
|
25
|
-
|
|
63
|
+
if (
|
|
26
64
|
s.tool === 'rect' ||
|
|
27
65
|
s.tool === 'ellipse' ||
|
|
28
66
|
s.tool === 'polygon' ||
|
|
29
67
|
s.tool === 'sticky' ||
|
|
30
68
|
s.tool === 'image'
|
|
31
|
-
)
|
|
69
|
+
) {
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
if (s.tool === 'section') return true;
|
|
73
|
+
if (s.tool === 'text') {
|
|
74
|
+
if (s.anchorId) return false; // (1) — no resolvable bbox without the anchors map
|
|
75
|
+
const bb = strokeBBox(s);
|
|
76
|
+
// Per-AXIS, not an area product: an area cap is satisfiable by a very wide,
|
|
77
|
+
// very short strip, which is precisely the board-wide magnet shape. 4 000
|
|
78
|
+
// chars at the fontSize floor of 8 gives w ≈ 17 600 × h ≈ 9.6 — area 168 960,
|
|
79
|
+
// comfortably under a 640×480 product (post-implementation review F8).
|
|
80
|
+
return !!bb && bb.w > 0 && bb.h > 0 && bb.w <= MAX_TEXT_BIND_W && bb.h <= MAX_TEXT_BIND_H; // (2)
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
32
83
|
}
|
|
33
84
|
|
|
34
85
|
/** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
|
|
@@ -78,6 +129,20 @@ export function bindCandidate(
|
|
|
78
129
|
) {
|
|
79
130
|
continue;
|
|
80
131
|
}
|
|
132
|
+
// A SECTION is a large container, and the natural gesture is to draw one
|
|
133
|
+
// AROUND existing notes — which puts it later in the array, i.e. topmost.
|
|
134
|
+
// Testing its raw bbox would make the whole interior a magnet, so every
|
|
135
|
+
// arrow drawn between two stickies inside a section would bind to the
|
|
136
|
+
// section instead. `strokeHitTest` already encodes the right rule (a section
|
|
137
|
+
// is grabbed by its BORDER or its label chip; the interior stays
|
|
138
|
+
// click-through), so defer to it rather than inventing a second geometry.
|
|
139
|
+
//
|
|
140
|
+
// Deliberately NOT "prefer the smallest containing bbox": containment here
|
|
141
|
+
// is over a bbox INFLATED by `threshold`, so smallest-area lets an 8×8 dot
|
|
142
|
+
// 14 px AWAY beat the large rect the pointer is actually inside — and it
|
|
143
|
+
// inverts the visual invariant, letting a shape hidden behind an opaque one
|
|
144
|
+
// win a bind the user cannot see happening (DDR-216 D9 fix 4).
|
|
145
|
+
if (s.tool === 'section' && !strokeHitTest(s, wx, wy, threshold)) continue;
|
|
81
146
|
return {
|
|
82
147
|
hostId: s.id,
|
|
83
148
|
nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
|
|
@@ -155,7 +220,21 @@ export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
|
|
|
155
220
|
}
|
|
156
221
|
}
|
|
157
222
|
const pt = anchorPoint(host, effective.nx, effective.ny);
|
|
158
|
-
if (!pt)
|
|
223
|
+
if (!pt) {
|
|
224
|
+
// The host is bindable but its bbox is unresolvable right now (e.g. a
|
|
225
|
+
// text stroke whose anchors map isn't reachable from here). Returning
|
|
226
|
+
// silently would KEEP a bind that can never be honoured — the arrow
|
|
227
|
+
// would freeze at stale coordinates permanently, across save/load,
|
|
228
|
+
// while still claiming to be attached. Strip it instead: the arrow
|
|
229
|
+
// survives unbound with its endpoint frozen, which is the same honest
|
|
230
|
+
// degradation a deleted host already gets. (DDR-216 D9 fix 2 — inert
|
|
231
|
+
// before the `isBindable` widening, load-bearing after it.)
|
|
232
|
+
const copy: ArrowStroke = { ...next };
|
|
233
|
+
delete copy[end === 'start' ? 'startBind' : 'endBind'];
|
|
234
|
+
next = copy;
|
|
235
|
+
mutated = true;
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
159
238
|
if (end === 'start') {
|
|
160
239
|
if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
|
|
161
240
|
next = { ...next, x1: pt[0], y1: pt[1] };
|
package/apps/studio/api.ts
CHANGED
|
@@ -5260,7 +5260,12 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
5260
5260
|
// `loadCanvasMeta` call the DDR-093 DS-map loop below already makes for
|
|
5261
5261
|
// non-path-owned (`ui/`) canvases — no extra I/O for the common case.
|
|
5262
5262
|
const canvasKinds: Record<string, string> = {};
|
|
5263
|
-
|
|
5263
|
+
// DDR-216 D7 adds `imported-figma`. The badge deliberately reads as
|
|
5264
|
+
// THIRD-PARTY CONTENT rather than as provenance-therefore-trustworthy: a
|
|
5265
|
+
// clean "imported from Figma" stamp otherwise makes the most
|
|
5266
|
+
// attacker-influenced artifact in the tree look like real design work, to a
|
|
5267
|
+
// human AND to `design-system-keeper` / the critic panel.
|
|
5268
|
+
const NOTABLE_KINDS = new Set(['reconstructed-experimental', 'imported-figma']);
|
|
5264
5269
|
const defaultDs = cfg.defaultDesignSystem || cfg.designSystems?.[0]?.name || 'project';
|
|
5265
5270
|
// A file under `system/<folder>/` belongs to the DS that owns that folder —
|
|
5266
5271
|
// path-authoritative, because specimens/ui_kits rarely carry a sidecar
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
import { execFileSync } from 'node:child_process';
|
|
40
40
|
import { createHash } from 'node:crypto';
|
|
41
41
|
import { lookup } from 'node:dns/promises';
|
|
42
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
42
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync } from 'node:fs';
|
|
43
43
|
import { isIP } from 'node:net';
|
|
44
|
-
import { join, resolve, sep } from 'node:path';
|
|
44
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
45
45
|
import { pathToFileURL } from 'node:url';
|
|
46
46
|
|
|
47
47
|
// A real browser UA — some CDNs (wikimedia) 403 a bare curl/UA; the memory
|
|
@@ -202,7 +202,7 @@ export function classifyAddress(addr) {
|
|
|
202
202
|
* FetchAssetError (code 3) on anything else — non-https scheme, embedded
|
|
203
203
|
* credentials, missing host.
|
|
204
204
|
*/
|
|
205
|
-
export function parseHttpsTarget(rawUrl) {
|
|
205
|
+
export function parseHttpsTarget(rawUrl, opts = {}) {
|
|
206
206
|
let u;
|
|
207
207
|
try {
|
|
208
208
|
u = new URL(rawUrl);
|
|
@@ -221,9 +221,71 @@ export function parseHttpsTarget(rawUrl) {
|
|
|
221
221
|
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
222
222
|
throw new FetchAssetError(3, `bad port ${u.port}`);
|
|
223
223
|
}
|
|
224
|
+
// DDR-216 D4 — an OPTIONAL host allowlist, at the gate rather than at the
|
|
225
|
+
// caller. Absent (the default) = today's unrestricted behaviour, so the
|
|
226
|
+
// existing moodboard/research callers are untouched. The Figma lane always
|
|
227
|
+
// passes it.
|
|
228
|
+
//
|
|
229
|
+
// Matching is exact-or-DOTTED-SUFFIX, never a bare `endsWith`: `endsWith(
|
|
230
|
+
// 'figma.com')` admits `evil-figma.com`, and `endsWith('.amazonaws.com')`
|
|
231
|
+
// admits every S3 bucket on earth.
|
|
232
|
+
//
|
|
233
|
+
// This is a REACH control (which endpoint), never a CONTENT control — the
|
|
234
|
+
// Figma render bucket is a SHARED object store any account can write into,
|
|
235
|
+
// so the byte-level sniff below is what actually protects the tree. And it
|
|
236
|
+
// NARROWS the IP gate, never replaces it: an allowlisted hostname that
|
|
237
|
+
// resolves to 127.0.0.1 is still refused by `resolveSafeIp`.
|
|
238
|
+
if (opts.allowHosts?.length) {
|
|
239
|
+
const h = host.toLowerCase();
|
|
240
|
+
const ok = opts.allowHosts.some((suffix) => {
|
|
241
|
+
const s = String(suffix).toLowerCase().replace(/^\./, '');
|
|
242
|
+
return h === s || h.endsWith(`.${s}`);
|
|
243
|
+
});
|
|
244
|
+
if (!ok) throw new FetchAssetError(3, `host not in this lane's allowlist: ${safeHostLabel(h)}`);
|
|
245
|
+
}
|
|
246
|
+
// DDR-216 D4 — the Figma lane pins 443. Named as NEW logic rather than
|
|
247
|
+
// assumed: this function accepts 1–65535 by default and always has.
|
|
248
|
+
if (opts.pinPort443 && port !== 443) {
|
|
249
|
+
throw new FetchAssetError(3, `this lane requires port 443 (got ${port})`);
|
|
250
|
+
}
|
|
224
251
|
return { host, port };
|
|
225
252
|
}
|
|
226
253
|
|
|
254
|
+
/**
|
|
255
|
+
* A hostname reduced to a charset-validated token before it can be printed.
|
|
256
|
+
*
|
|
257
|
+
* DDR-216 D10: this verb's output is read BY an agent, so an upstream-controlled
|
|
258
|
+
* string must never appear verbatim in a message. A DNS label charset is narrow
|
|
259
|
+
* enough to be safe and specific enough to diagnose.
|
|
260
|
+
*/
|
|
261
|
+
function safeHostLabel(host) {
|
|
262
|
+
const cleaned = String(host)
|
|
263
|
+
.toLowerCase()
|
|
264
|
+
.replace(/[^a-z0-9.-]/g, '');
|
|
265
|
+
return cleaned.slice(0, 253) || '(unprintable)';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The `--raw-out` accept set: everything `sniffImageExt` accepts, PLUS SVG.
|
|
270
|
+
*
|
|
271
|
+
* `sniffImageExt` is deliberately NOT modified — it is the type gate the
|
|
272
|
+
* moodboard lane depends on, and a standing test asserts it still returns null
|
|
273
|
+
* for `<svg`/`<?xml`. The SVG probe below reuses the exact leading-token shape
|
|
274
|
+
* `_import-asset.mjs`'s own `svgPreParseReject` uses, so a payload accepted here
|
|
275
|
+
* is one that lane will actually attempt to parse (and then sanitize).
|
|
276
|
+
*/
|
|
277
|
+
export function sniffStagedKind(bytes) {
|
|
278
|
+
const raster = sniffImageExt(bytes);
|
|
279
|
+
if (raster) return raster;
|
|
280
|
+
// Text probe on the first bytes only — never decode the whole body to guess.
|
|
281
|
+
const head = Buffer.from(bytes.subarray(0, 256)).toString('utf8');
|
|
282
|
+
// Deliberately NARROWER than `svgPreParseReject`'s own leading-token set: a
|
|
283
|
+
// bare `<!--` prologue is legal SVG but it is also what lets an arbitrary text
|
|
284
|
+
// payload masquerade as one (review F2), and no Figma export starts with it.
|
|
285
|
+
// The full DDR-167 lane still validates properly downstream.
|
|
286
|
+
return /^\s*(<\?xml|<svg)/i.test(head) ? 'svg' : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
227
289
|
// ── image sniff + naming ─────────────────────────────────────────────────────
|
|
228
290
|
|
|
229
291
|
/**
|
|
@@ -408,10 +470,91 @@ export async function fetchAsset({
|
|
|
408
470
|
designRootRel = '.design',
|
|
409
471
|
maxBytes = DEFAULT_MAX_BYTES,
|
|
410
472
|
maxTime = DEFAULT_MAX_TIME,
|
|
473
|
+
allowHosts,
|
|
474
|
+
pinPort443 = false,
|
|
475
|
+
rawOut = null,
|
|
476
|
+
rawRoot = null,
|
|
411
477
|
}) {
|
|
412
|
-
const { host, port } = parseHttpsTarget(url);
|
|
478
|
+
const { host, port } = parseHttpsTarget(url, { allowHosts, pinPort443 });
|
|
413
479
|
const pinIp = await resolveSafeIp(host);
|
|
414
480
|
|
|
481
|
+
// ── `--raw-out` (DDR-216 D11) ──────────────────────────────────────────────
|
|
482
|
+
//
|
|
483
|
+
// Download under the FULL network gate (IP classification, DNS pin, redirect
|
|
484
|
+
// ban, size/time caps, host allowlist) but write to a CALLER-SUPPLIED path
|
|
485
|
+
// instead of `assets/`, and do NOT content-address or name the file.
|
|
486
|
+
//
|
|
487
|
+
// Why this exists: D11 composes two already-reviewed gates rather than
|
|
488
|
+
// widening one. A Figma vector export is an SVG, and this helper's raster-only
|
|
489
|
+
// sniff is load-bearing for its OTHER caller (the DDR-147 moodboard lane,
|
|
490
|
+
// where URLs are research-harvested and there is no allowlist at all). So the
|
|
491
|
+
// Figma lane stages the bytes here and hands them to `_import-asset.mjs`'s
|
|
492
|
+
// DDR-167 SVG lane (allowlist DOM-sanitize → SVGO validity gate) before
|
|
493
|
+
// anything is named or lands in the versioned tree.
|
|
494
|
+
//
|
|
495
|
+
// IT STILL SNIFFS. `--raw-out` must never mean "skip the type gate" — that
|
|
496
|
+
// would turn the one reviewable downloader into an unsniffed one (HTML, JS,
|
|
497
|
+
// polyglots, archives) for the next caller who copies it. It sniffs against
|
|
498
|
+
// an EXTENDED accept set; `sniffImageExt` itself is untouched, which is what
|
|
499
|
+
// keeps the standing "sniffImageExt still rejects SVG" test literally true.
|
|
500
|
+
if (rawOut) {
|
|
501
|
+
// CONTAINMENT (post-implementation review F2). Without this, `--raw-out`
|
|
502
|
+
// was an arbitrary-file-write primitive on the ONE helper whose whole job
|
|
503
|
+
// is to be the safe downloader: `resolve()` + `renameSync()` with no root
|
|
504
|
+
// relationship, no charset assertion, and a type gate a leading `<!--`
|
|
505
|
+
// satisfies. An agent that can run `maude design fetch-asset` (ACP
|
|
506
|
+
// default-allows `Bash(maude:*)`) could have written remote bytes over
|
|
507
|
+
// `CLAUDE.md` — durable prompt injection.
|
|
508
|
+
//
|
|
509
|
+
// So the mode now REQUIRES the caller to declare the directory it owns, and
|
|
510
|
+
// the target must resolve inside it. The Figma lane passes its per-run
|
|
511
|
+
// staging dir; there is no way to express "anywhere".
|
|
512
|
+
if (!rawRoot) {
|
|
513
|
+
throw new FetchAssetError(2, '--raw-out requires --raw-root (the directory the caller owns)');
|
|
514
|
+
}
|
|
515
|
+
// BOTH sides get realpath'd, or neither. On macOS `/var` and `/tmp` are
|
|
516
|
+
// themselves OS-level symlinks (`-> /private/var`, `-> /private/tmp`), so
|
|
517
|
+
// realpath'ing only the root compares `/private/var/…` against `/var/…` and
|
|
518
|
+
// rejects EVERY legitimate staging path — which is exactly how this shipped
|
|
519
|
+
// and why the first real import skipped all 29 of its assets. Same trap
|
|
520
|
+
// DDR-172 Decision 1 documents for its own symlink check.
|
|
521
|
+
//
|
|
522
|
+
// The target does not exist yet, so realpath its PARENT (which does — the
|
|
523
|
+
// caller made the staging dir) and rebuild the leaf onto it.
|
|
524
|
+
const rootAbs = realpathSync(resolve(rawRoot));
|
|
525
|
+
const requested = resolve(rawOut);
|
|
526
|
+
const parentAbs = realpathSync(dirname(requested));
|
|
527
|
+
const outAbs = join(parentAbs, basename(requested));
|
|
528
|
+
if (outAbs !== rootAbs && !outAbs.startsWith(rootAbs + sep)) {
|
|
529
|
+
throw new FetchAssetError(6, '--raw-out must resolve inside --raw-root');
|
|
530
|
+
}
|
|
531
|
+
// Never overwrite: a staged download is always a fresh file, so an existing
|
|
532
|
+
// target means either a collision or an attempt to clobber something.
|
|
533
|
+
if (existsSync(outAbs)) {
|
|
534
|
+
throw new FetchAssetError(6, '--raw-out target already exists');
|
|
535
|
+
}
|
|
536
|
+
const tmpRaw = `${outAbs}.part`;
|
|
537
|
+
try {
|
|
538
|
+
curlDownload({ url, host, port, pinIp, tmpAbs: tmpRaw, maxBytes, maxTime });
|
|
539
|
+
let data;
|
|
540
|
+
try {
|
|
541
|
+
data = readFileSync(tmpRaw);
|
|
542
|
+
} catch {
|
|
543
|
+
throw new FetchAssetError(4, 'download produced no file');
|
|
544
|
+
}
|
|
545
|
+
if (data.length === 0) throw new FetchAssetError(4, 'downloaded empty body');
|
|
546
|
+
if (data.length > maxBytes) throw new FetchAssetError(4, `file exceeds ${maxBytes} bytes`);
|
|
547
|
+
const kind = sniffStagedKind(data);
|
|
548
|
+
if (!kind) {
|
|
549
|
+
throw new FetchAssetError(5, 'not a png/jpg/gif/webp/svg payload (HTML/script rejected)');
|
|
550
|
+
}
|
|
551
|
+
renameSync(tmpRaw, outAbs);
|
|
552
|
+
return { ref: null, path: outAbs, name: null, bytes: data.length, ext: kind };
|
|
553
|
+
} finally {
|
|
554
|
+
rmSync(tmpRaw, { force: true });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
415
558
|
const { assetsDir } = containedAssetPath(root, designRootRel, 'placeholder.png');
|
|
416
559
|
mkdirSync(assetsDir, { recursive: true });
|
|
417
560
|
const tmpAbs = join(
|
|
@@ -476,6 +619,21 @@ function parseArgv(argv) {
|
|
|
476
619
|
case '--max-time':
|
|
477
620
|
out.maxTime = Number(argv[++i]);
|
|
478
621
|
break;
|
|
622
|
+
// DDR-216 D4/D11 — all three default to today's behaviour, so every
|
|
623
|
+
// existing caller of this helper is untouched.
|
|
624
|
+
case '--allow-host':
|
|
625
|
+
if (!out.allowHosts) out.allowHosts = [];
|
|
626
|
+
out.allowHosts.push(argv[++i]);
|
|
627
|
+
break;
|
|
628
|
+
case '--pin-port-443':
|
|
629
|
+
out.pinPort443 = true;
|
|
630
|
+
break;
|
|
631
|
+
case '--raw-out':
|
|
632
|
+
out.rawOut = argv[++i];
|
|
633
|
+
break;
|
|
634
|
+
case '--raw-root':
|
|
635
|
+
out.rawRoot = argv[++i];
|
|
636
|
+
break;
|
|
479
637
|
case '--json':
|
|
480
638
|
out.json = true;
|
|
481
639
|
break;
|
|
@@ -540,8 +698,14 @@ async function main() {
|
|
|
540
698
|
designRootRel: opts.designRoot,
|
|
541
699
|
maxBytes: opts.maxBytes,
|
|
542
700
|
maxTime: opts.maxTime,
|
|
701
|
+
...(opts.allowHosts ? { allowHosts: opts.allowHosts } : {}),
|
|
702
|
+
...(opts.pinPort443 ? { pinPort443: true } : {}),
|
|
703
|
+
...(opts.rawOut ? { rawOut: opts.rawOut } : {}),
|
|
704
|
+
...(opts.rawRoot ? { rawRoot: opts.rawRoot } : {}),
|
|
543
705
|
});
|
|
544
|
-
|
|
706
|
+
// `--raw-out` has no canvas ref (it deliberately writes outside `assets/`),
|
|
707
|
+
// so print the staged path instead of a null.
|
|
708
|
+
process.stdout.write(opts.json ? `${JSON.stringify(r)}\n` : `${r.ref ?? r.path}\n`);
|
|
545
709
|
} catch (err) {
|
|
546
710
|
process.stderr.write(`fetch-asset: ${err.message}\n`);
|
|
547
711
|
process.exit(err instanceof FetchAssetError ? err.code : 1);
|
|
@@ -623,6 +623,78 @@ export async function importSvg(svgText, { root, designRootRel = '.design' }) {
|
|
|
623
623
|
* only the rasterize step is blocked, so this fails loud there rather than
|
|
624
624
|
* pretending the whole pipeline is unavailable.
|
|
625
625
|
*/
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The execution canary for MANY sanitized SVGs, in ONE browser session.
|
|
629
|
+
*
|
|
630
|
+
* Same coverage as `runSvgExecutionCanary`, same probe, same hard network
|
|
631
|
+
* denial, same directly-navigated document — the only thing that changes is
|
|
632
|
+
* that the session is created once instead of once per file.
|
|
633
|
+
*
|
|
634
|
+
* Why it exists: measured on a real 6-page product file, a per-file canary cost
|
|
635
|
+
* ~4.5 s each, so a normal design system's icon set (530 distinct vector
|
|
636
|
+
* renders after component dedupe) would have taken ~40 minutes and forced the
|
|
637
|
+
* import to drop most of its artwork against an asset-count cap. The canary is
|
|
638
|
+
* not what needed relaxing — the session-per-file was.
|
|
639
|
+
*
|
|
640
|
+
* Returns the set of indices that TRIPPED, so the caller refuses exactly those
|
|
641
|
+
* and keeps the rest. A tripped canary is still a hard refusal for that file.
|
|
642
|
+
*/
|
|
643
|
+
export async function runSvgExecutionCanaryBatch(sanitizedSvgs, { timeoutMs = 15_000 } = {}) {
|
|
644
|
+
const tripped = new Set();
|
|
645
|
+
if (sanitizedSvgs.length === 0) return tripped;
|
|
646
|
+
const dir = mkdtempSync(join(tmpdir(), 'maude-import-svg-batch-'));
|
|
647
|
+
const probePath = join(dir, 'probe.js');
|
|
648
|
+
try {
|
|
649
|
+
writeFileSync(probePath, 'window.__MAUDE_IMPORT_CANARY__ = false;\n');
|
|
650
|
+
const paths = sanitizedSvgs.map((svg, i) => {
|
|
651
|
+
const p = join(dir, `candidate-${i}.svg`);
|
|
652
|
+
writeFileSync(p, svg);
|
|
653
|
+
return p;
|
|
654
|
+
});
|
|
655
|
+
await withSandboxedRender(
|
|
656
|
+
async (session) => {
|
|
657
|
+
for (let i = 0; i < paths.length; i += 1) {
|
|
658
|
+
session.open(pathToFileURL(paths[i]).href);
|
|
659
|
+
// The probe re-runs on EVERY navigation in this session (it is
|
|
660
|
+
// registered as an init script), so the flag is per-document — a
|
|
661
|
+
// clean file after a tripped one still reads false.
|
|
662
|
+
if (session.eval('window.__MAUDE_IMPORT_CANARY__ === true').trim() === 'true') {
|
|
663
|
+
tripped.add(i);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
},
|
|
667
|
+
{ timeoutMs, initScriptPath: probePath }
|
|
668
|
+
);
|
|
669
|
+
} finally {
|
|
670
|
+
rmSync(dir, { recursive: true, force: true });
|
|
671
|
+
}
|
|
672
|
+
return tripped;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Sanitize + canary + write MANY SVGs. The batched counterpart of `importSvg`.
|
|
677
|
+
* Returns one result per input, or `null` where the file was refused.
|
|
678
|
+
*/
|
|
679
|
+
export async function importSvgBatch(svgTexts, { root, designRootRel = '.design' }) {
|
|
680
|
+
const sanitized = svgTexts.map((t) => {
|
|
681
|
+
try {
|
|
682
|
+
return sanitizeSvgAllowlist(t);
|
|
683
|
+
} catch {
|
|
684
|
+
return null;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
const live = [];
|
|
688
|
+
for (let i = 0; i < sanitized.length; i += 1) if (sanitized[i] !== null) live.push(i);
|
|
689
|
+
|
|
690
|
+
const tripped = await runSvgExecutionCanaryBatch(live.map((i) => sanitized[i]));
|
|
691
|
+
return sanitized.map((svg, i) => {
|
|
692
|
+
if (svg === null) return null;
|
|
693
|
+
if (tripped.has(live.indexOf(i))) return null;
|
|
694
|
+
return writeContainedAsset(root, designRootRel, Buffer.from(svg, 'utf8'), 'svg');
|
|
695
|
+
});
|
|
696
|
+
}
|
|
697
|
+
|
|
626
698
|
export async function importPdf(inputPath, { root, designRootRel = '.design' }) {
|
|
627
699
|
const buffer = readPdfCapped(inputPath);
|
|
628
700
|
const pageCount = await getPdfPageCountIsolated(buffer);
|