@@ -40,19 +40,57 @@ export interface MutationResult {
4040
4141const 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 = / ^ (?: j a v a s c r i p t | v b s c r i p t ) : / i;
61+ const DANGEROUS_DATA_URI = / ^ d a t a \s * : \s * t e x t \/ h t m l / 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+
5694export 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}
0 commit comments