Skip to content

Commit 951a09f

Browse files
committed
fix(sdk,core): css tokenizer, override-set replay, setattribute safety, persist errors
1 parent cffada0 commit 951a09f

8 files changed

Lines changed: 129 additions & 37 deletions

File tree

bun.lock

Lines changed: 15 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/core/src/studio-api/helpers/sourceMutation.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -223,10 +223,35 @@ function isSafeAttributeValue(name: string, value: string): boolean {
223223
return true;
224224
}
225225

226+
// fallow-ignore-next-line complexity
226227
function patchStyleAttrString(style: string, property: string, value: string | null): string {
227228
const props = new Map<string, string>();
228229
const order: string[] = [];
229-
for (const decl of style.split(";")) {
230+
// Tokenize declarations robustly: values can contain ';' inside quoted strings
231+
// (e.g. content: ';') and ':' inside values (data URIs, url(), etc.).
232+
// Split on ';' only when outside quotes and balanced parens; the first ':' in
233+
// the resulting segment is the property/value separator (property names never
234+
// contain ':').
235+
let i = 0;
236+
while (i < style.length) {
237+
let depth = 0;
238+
let inSingle = false;
239+
let inDouble = false;
240+
const start = i;
241+
while (i < style.length) {
242+
const ch = style[i];
243+
if (ch === "'" && !inDouble) inSingle = !inSingle;
244+
else if (ch === '"' && !inSingle) inDouble = !inDouble;
245+
else if (!inSingle && !inDouble) {
246+
if (ch === "(") depth++;
247+
else if (ch === ")") depth = Math.max(0, depth - 1);
248+
else if (ch === ";" && depth === 0) break;
249+
}
250+
i++;
251+
}
252+
const decl = style.slice(start, i).trim();
253+
i++; // advance past ';'
254+
if (!decl) continue;
230255
const colon = decl.indexOf(":");
231256
if (colon < 0) continue;
232257
const key = decl.slice(0, colon).trim();

packages/sdk/src/engine/apply-patches.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,17 @@ function parsePath(path: string): ParsedPath | null {
5959

6060
/**
6161
* Replay a stored override-set onto a freshly-parsed base document (T3 init).
62-
* Property keys with null mean "restore base value" — a no-op on a fresh base.
63-
* Bare element keys with null are removal markers — the element is removed.
62+
* A null value means the property was explicitly deleted — emit a remove patch
63+
* so the base document matches the session state. (Removing a non-existent
64+
* property is a no-op in applyOne, so this is safe against fresh-base misses.)
6465
*/
6566
export function applyOverrideSet(parsed: ParsedDocument, overrides: OverrideSet): void {
6667
const patches: JsonPatchOp[] = [];
6768
for (const [key, value] of Object.entries(overrides)) {
6869
const path = keyToPath(key);
6970
if (!path) continue;
7071
if (value === null) {
71-
if (!key.includes(".")) patches.push({ op: "remove", path });
72+
patches.push({ op: "remove", path });
7273
continue;
7374
}
7475
patches.push({ op: "replace", path, value });

packages/sdk/src/engine/mutate.ts

Lines changed: 59 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,19 +40,57 @@ export interface MutationResult {
4040

4141
const EMPTY: MutationResult = { forward: [], inverse: [] };
4242

43-
/** Ops that require the Phase 3b parser-backed engine (meriyah/css-tree). */
44-
const PHASE3B_OPS = new Set([
45-
"setClassStyle",
46-
"addGsapTween",
47-
"setGsapTween",
48-
"setGsapKeyframe",
49-
"addGsapKeyframe",
50-
"removeGsapKeyframe",
51-
"removeGsapTween",
52-
"addLabel",
53-
"removeLabel",
43+
// ─── setAttribute safety ────────────────────────────────────────────────────
44+
45+
// Composition-reserved attributes — changing these breaks element identity or
46+
// the core/studio data model. Reject before mutating.
47+
const RESERVED_ATTRS = new Set([
48+
"data-hf-id",
49+
"data-composition-id",
50+
"data-width",
51+
"data-height",
52+
"data-start",
53+
"data-end",
54+
"data-track-index",
55+
"data-hold-start",
56+
"data-hold-end",
57+
"data-hold-fill",
5458
]);
5559

60+
const DANGEROUS_URI_SCHEMES = /^(?:javascript|vbscript):/i;
61+
const DANGEROUS_DATA_URI = /^data\s*:\s*text\/html/i;
62+
const URI_BEARING_ATTRS = new Set([
63+
"src",
64+
"href",
65+
"action",
66+
"formaction",
67+
"poster",
68+
"srcset",
69+
"xlink:href",
70+
]);
71+
72+
function validateSetAttribute(name: string, value: string | null): void {
73+
const lower = name.toLowerCase();
74+
if (RESERVED_ATTRS.has(lower)) {
75+
throw new Error(
76+
`setAttribute: "${name}" is a reserved composition attribute and cannot be reassigned. ` +
77+
`Use the appropriate typed method (setTiming, setHold, etc.) instead.`,
78+
);
79+
}
80+
if (lower.startsWith("on")) {
81+
throw new Error(
82+
`setAttribute: event-handler attributes ("${name}") are not permitted — ` +
83+
`they produce executable HTML that cannot be safely serialized.`,
84+
);
85+
}
86+
if (value !== null && URI_BEARING_ATTRS.has(lower)) {
87+
const trimmed = value.trim();
88+
if (DANGEROUS_URI_SCHEMES.test(trimmed) || DANGEROUS_DATA_URI.test(trimmed)) {
89+
throw new Error(`setAttribute: unsafe URI value for "${name}".`);
90+
}
91+
}
92+
}
93+
5694
export class UnsupportedOpError extends Error {
5795
// Stable error code — part of the public API contract (F7); hosts switch on
5896
// err.code rather than the message.
@@ -156,7 +194,11 @@ function handleSetText(parsed: ParsedDocument, ids: HfId[], value: string): Muta
156194
const oldText = getOwnText(el);
157195
setOwnText(el, value);
158196
const path = textPath(id);
159-
const p = scalarChange(path, oldText || null, value);
197+
// getOwnText always returns string ("" for empty) — use it directly so
198+
// the forward patch is always op:'replace', not op:'add'. An op:'add' on
199+
// a text path is semantically wrong for external JSON-patch consumers
200+
// (the path already exists; add would fail on strict appliers).
201+
const p = scalarChange(path, oldText, value);
160202
result.forward.push(p.forward);
161203
result.inverse.push(p.inverse);
162204
}
@@ -169,6 +211,7 @@ function handleSetAttribute(
169211
name: string,
170212
value: string | null,
171213
): MutationResult {
214+
validateSetAttribute(name, value);
172215
const result: MutationResult = { forward: [], inverse: [] };
173216
for (const id of ids) {
174217
const el = findById(parsed.document, id);
@@ -387,9 +430,10 @@ export function validateOp(parsed: ParsedDocument, op: EditOp): boolean {
387430
return findRoot(parsed.document) !== null;
388431
case "setCompositionMetadata":
389432
return true;
390-
// Phase 3b — not implemented yet; can() must report false so callers
391-
// can feature-detect instead of hitting UnsupportedOpError.
433+
// Phase 3b and unknown ops — report false so callers can feature-detect.
434+
// An unknown op type must never silently pass validation only to no-op
435+
// or throw in applyOp (which would violate the can() contract).
392436
default:
393-
return !PHASE3B_OPS.has(op.type);
437+
return false;
394438
}
395439
}

packages/sdk/src/engine/patches.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,9 @@ export function keyToPath(key: string): string | null {
115115
if (text?.[1]) return textPath(text[1]);
116116

117117
const attr = /^([^.]+)\.attr\.(.+)$/.exec(key);
118-
if (attr?.[1] && attr[2]) return attrPath(attr[1], attr[2]);
118+
// pathToKey stores the RFC 6902-encoded segment verbatim; do NOT call attrPath()
119+
// here (it would re-escape '~' → '~0'), just reconstruct the path directly.
120+
if (attr?.[1] && attr[2]) return `/elements/${attr[1]}/attributes/${attr[2]}`;
119121

120122
const timing = /^([^.]+)\.timing\.(start|end|trackIndex)$/.exec(key);
121123
if (timing?.[1]) return timingPath(timing[1], timing[2] as "start" | "end" | "trackIndex");

packages/sdk/src/persist-queue.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
* T3 (embedded) hosts own persistence — do not use this module.
99
*/
1010

11-
import type { Composition } from "./types.js";
11+
import type { Composition, PersistErrorEvent } from "./types.js";
1212
import type { PersistAdapter } from "./adapters/types.js";
1313

1414
export interface PersistQueueModule {
@@ -20,6 +20,8 @@ export interface PersistQueueModule {
2020
export interface PersistQueueOptions {
2121
/** Adapter path to write to. Default: "composition.html" */
2222
path?: string;
23+
/** Called when adapter.write() rejects. */
24+
onError?: (e: PersistErrorEvent) => void;
2325
}
2426

2527
export function createPersistQueue(
@@ -48,8 +50,9 @@ export function createPersistQueue(
4850
if (disposed) return;
4951
try {
5052
await adapter.write(path, content);
51-
} catch {
52-
// error already surfaced via persist:error on the adapter
53+
} catch (err) {
54+
const message = err instanceof Error ? err.message : String(err);
55+
opts.onError?.({ error: { message, cause: err } });
5356
}
5457
});
5558
return writeChain;

packages/sdk/src/session.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,11 +89,13 @@ describe("override-set replay on open", () => {
8989
expect(comp.serialize()).not.toContain("subtitle");
9090
});
9191

92-
it("treats property-level null as restore-base (no-op on fresh base)", async () => {
92+
it("treats property-level null as a deletion marker — removes the property from the base", async () => {
93+
// Null in the override-set is emitted only from patchRemove (explicit deletion).
94+
// On replay against a base that has the property set, it must be removed.
9395
const comp = await openComposition(BASE_HTML, {
9496
overrides: { "hf-title.style.color": null },
9597
});
96-
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBe("#fff");
98+
expect(comp.getElement("hf-title")?.inlineStyles["color"]).toBeUndefined();
9799
});
98100

99101
it("getOverrides returns the set the session was opened with", async () => {

packages/sdk/src/session.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,10 @@ class CompositionImpl implements Composition {
9595
this.persistQueueModule = module;
9696
}
9797

98+
_fireError(e: PersistErrorEvent): void {
99+
this.errorHandlers.forEach((h) => h(e));
100+
}
101+
98102
// ── Typed methods (F10 layer 1) ─────────────────────────────────────────────
99103

100104
setStyle(id: HfId, styles: Record<string, string | null>): void {
@@ -272,9 +276,13 @@ class CompositionImpl implements Composition {
272276
this.batchOrigin,
273277
this.batchOpTypes,
274278
);
275-
this.resetBatchState();
279+
// Fire handlers before resetting batch state so that if a handler
280+
// throws the patch data (batchForward/batchInverse) is still intact
281+
// for callers that inspect it on error. The event was already built
282+
// from a snapshot so handler re-entrancy does not corrupt the event.
276283
this.patchHandlers.forEach((h) => h(event));
277284
this.changeHandlers.forEach((h) => h());
285+
this.resetBatchState();
278286
} else {
279287
if (threw && this.batchInverse.length > 0) {
280288
// Roll back: the dispatches inside the batch already mutated the
@@ -427,7 +435,9 @@ export async function openComposition(
427435
session.attachHistory(history);
428436

429437
if (opts?.persist) {
430-
const pq = createPersistQueue(session, opts.persist);
438+
const pq = createPersistQueue(session, opts.persist, {
439+
onError: (e) => session._fireError(e),
440+
});
431441
session.attachPersistQueue(pq);
432442
}
433443
}

0 commit comments

Comments
 (0)