Skip to content

Commit 06eea52

Browse files
committed
fix: reject missing role keys in target-v1 annotations, correct data-flow comment
Second-review nits: the writer emits `role` unconditionally (top level, ancestry entries, scrollRegion) — possibly as the empty string for typeless nodes, which stays accepted — so a MISSING role key can only come from a hand-edited/adversarial annotation and is now rejected with INVALID_ARGS instead of silently parsing as an implicit empty role, which step-4 enforcement could otherwise match against anonymous wrapper nodes. Also corrects the interaction-touch-response comment that overstated where the raw node/tree flows (finalizeTouchInteraction strips it before both session history and touch overlay telemetry).
1 parent 7951072 commit 06eea52

3 files changed

Lines changed: 72 additions & 13 deletions

File tree

src/daemon/handlers/interaction-touch-response.ts

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,14 @@ export function buildInteractionResponseData(params: {
126126
responseData.warning = warning;
127127
}
128128
// ADR 0012 decision 3: the resolved node and the record-time tree it came
129-
// from ride ONLY on `visualization` (session history / touch overlay
130-
// input), never on `responseData` (the public payload) — attaching them
131-
// here, not via `commonExtra`, is what keeps them off the wire.
132-
// `finalizeTouchInteraction` (interaction-common.ts) turns them into the
133-
// compact `targetEvidence` annotation when the session is being recorded
134-
// and strips the raw tree back out either way.
129+
// from ride ONLY on `visualization` — a hand-off channel to
130+
// `finalizeTouchInteraction` (interaction-common.ts), which turns them into
131+
// the compact `targetEvidence` annotation when the session is being
132+
// recorded and strips the raw tree back out BEFORE anything downstream
133+
// consumes the payload (session history via recordAction and touch overlay
134+
// telemetry both receive the stripped form). They are never attached to
135+
// `responseData` (the public payload) — attaching them here, not via
136+
// `commonExtra`, is what keeps them off the wire.
135137
if ('node' in result && result.node) visualization.node = result.node;
136138
if ('preActionNodes' in result && result.preActionNodes) {
137139
visualization.preActionNodes = result.preActionNodes;

src/replay/__tests__/target-identity.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,51 @@ test('parser rejects a malformed rect', () => {
321321
);
322322
});
323323

324+
// ---------------------------------------------------------------------------
325+
// Role presence: the writer emits `role` unconditionally (top level, every
326+
// ancestry entry, scrollRegion) — possibly as the empty string for a
327+
// typeless node, which stays accepted. A MISSING role key can only come from
328+
// a hand-edited/adversarial annotation and must be rejected, or step-4
329+
// enforcement could match anonymous wrapper nodes through an implicit
330+
// empty-role identity.
331+
// ---------------------------------------------------------------------------
332+
333+
test('parser rejects a missing top-level role', () => {
334+
assertInvalidArgs(
335+
() => parseTargetAnnotationV1Payload(JSON.stringify({ verification: 'verified' })),
336+
/"role" is required/,
337+
);
338+
});
339+
340+
test('parser rejects a missing role in an ancestry entry and in scrollRegion', () => {
341+
assertInvalidArgs(
342+
() =>
343+
parseTargetAnnotationV1Payload(
344+
JSON.stringify({
345+
role: 'button',
346+
ancestry: [{ label: 'Editor' }],
347+
verification: 'verified',
348+
}),
349+
),
350+
/"ancestry\[0\]\.role" is required/,
351+
);
352+
assertInvalidArgs(
353+
() =>
354+
parseTargetAnnotationV1Payload(
355+
JSON.stringify({ role: 'button', scrollRegion: { id: 'list' }, verification: 'verified' }),
356+
),
357+
/"scrollRegion\.role" is required/,
358+
);
359+
});
360+
361+
test('parser accepts an explicit empty-string role (writer-legal for typeless nodes)', () => {
362+
const parsed = parseTargetAnnotationV1Payload(
363+
JSON.stringify({ role: '', ancestry: [{ role: '' }], verification: 'verified' }),
364+
);
365+
assert.equal(parsed.role, '');
366+
assert.deepEqual(parsed.ancestry, [{ role: '' }]);
367+
});
368+
324369
// ---------------------------------------------------------------------------
325370
// Classification core (decision 3 "Replay-time verification" paths 2-6):
326371
// this is the exact function the writer's record-time self-check calls, and

src/replay/target-identity.ts

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,7 @@ export function parseTargetAnnotationV1Payload(jsonText: string): TargetAnnotati
205205
}
206206
const raw = parsed as Record<string, unknown>;
207207

208-
const role = parseRequiredRoleField(raw.role);
208+
const role = parseRequiredRoleField(raw.role, 'role');
209209
const id = parseOptionalIdentifierField(raw.id, 'id');
210210
const label = parseOptionalLabelField(raw.label, 'label');
211211
const ancestry = parseAncestryField(raw.ancestry);
@@ -228,12 +228,24 @@ export function parseTargetAnnotationV1Payload(jsonText: string): TargetAnnotati
228228
};
229229
}
230230

231-
function parseRequiredRoleField(value: unknown): string {
232-
if (value === undefined) return '';
231+
/**
232+
* `role` keys are always PRESENT in writer output — the writer emits `role`
233+
* unconditionally at the top level, in every ancestry entry, and in
234+
* `scrollRegion` (possibly as the empty string for a typeless node, which
235+
* decision 3 explicitly allows). A MISSING role key can therefore only come
236+
* from a hand-edited/adversarial annotation and is rejected: once step-4
237+
* enforcement consumes this evidence, an implicitly-empty role in
238+
* `matchesLocalIdentity` could match anonymous wrapper nodes and produce a
239+
* false verified/rebind.
240+
*/
241+
function parseRequiredRoleField(value: unknown, field: string): string {
242+
if (value === undefined) {
243+
throw new AppError('INVALID_ARGS', `target-v1 "${field}" is required.`);
244+
}
233245
if (typeof value !== 'string') {
234-
throw new AppError('INVALID_ARGS', 'target-v1 "role" must be a string.');
246+
throw new AppError('INVALID_ARGS', `target-v1 "${field}" must be a string.`);
235247
}
236-
return boundField(normalizeRoleField(value), 'role');
248+
return boundField(normalizeRoleField(value), field);
237249
}
238250

239251
function parseOptionalIdentifierField(value: unknown, field: 'id'): string | undefined {
@@ -283,7 +295,7 @@ function parseAncestryEntry(entry: unknown, index: number): TargetAncestryEntry
283295
throw new AppError('INVALID_ARGS', `target-v1 "ancestry[${index}]" must be an object.`);
284296
}
285297
const record = entry as Record<string, unknown>;
286-
const role = parseRequiredRoleField(record.role);
298+
const role = parseRequiredRoleField(record.role, `ancestry[${index}].role`);
287299
const label = parseOptionalLabelField(record.label, `ancestry[${index}].label`);
288300
return { role, ...(label !== undefined ? { label } : {}) };
289301
}
@@ -302,7 +314,7 @@ function parseScrollRegionField(value: unknown): TargetScrollRegion | undefined
302314
throw new AppError('INVALID_ARGS', 'target-v1 "scrollRegion" must be an object.');
303315
}
304316
const record = value as Record<string, unknown>;
305-
const role = parseRequiredRoleField(record.role);
317+
const role = parseRequiredRoleField(record.role, 'scrollRegion.role');
306318
const id = parseOptionalIdentifierField(record.id, 'id');
307319
const label = parseOptionalLabelField(record.label, 'scrollRegion.label');
308320
return {

0 commit comments

Comments
 (0)