From 4802696e33c7dab9093ca1d94e4d383a1607911f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20L=C3=B3pez?= <63005462+diegolopezrm@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:39:13 -0500 Subject: [PATCH 1/2] fix(dart): reject writes through a primitive instead of dropping them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DataModel.set` walks to the container holding the last segment and then branches on whether it is a map or a list. When it was neither — the path ran through a primitive, or the root itself was one — both branches were skipped and the method returned normally, having written nothing. The write was reported as applied while the value never landed, so the client held state the agent believed it had set, with no error anywhere to explain the difference. Intermediate segments already threw for this; only the final container did not. It now throws `A2uiDataError`, which is what `set` does everywhere else it cannot honour a path, and matches how web_core answers the same write. --- dart/a2ui_core/lib/src/core/data_model.dart | 9 +++++++++ dart/a2ui_core/test/data_model_test.dart | 14 ++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/dart/a2ui_core/lib/src/core/data_model.dart b/dart/a2ui_core/lib/src/core/data_model.dart index e0a88c1b91..9572ea79ee 100644 --- a/dart/a2ui_core/lib/src/core/data_model.dart +++ b/dart/a2ui_core/lib/src/core/data_model.dart @@ -132,6 +132,15 @@ class DataModel { current.add(null); } current[index] = value; + } else { + // Neither a map nor a list: the value that would contain this + // segment is a primitive, so there is nowhere to write. Silently + // dropping the write would leave the client holding state the + // server believes it applied. + throw A2uiDataError( + "Cannot set path '$path': the containing value is a primitive.", + path: path, + ); } } diff --git a/dart/a2ui_core/test/data_model_test.dart b/dart/a2ui_core/test/data_model_test.dart index 66d72090f4..82889d262d 100644 --- a/dart/a2ui_core/test/data_model_test.dart +++ b/dart/a2ui_core/test/data_model_test.dart @@ -155,6 +155,20 @@ void main() { expect(model.get('/'), isEmpty); }); + test('rejects writes through a primitive value', () { + final model = DataModel(); + model.set('/a/b', 's'); + expect(() => model.set('/a/b/c', 1), throwsA(isA())); + expect(model.get('/a/b'), 's'); + }); + + test('rejects writes when the root itself is a primitive', () { + final model = DataModel(); + model.set('/', 1); + expect(() => model.set('/a', 2), throwsA(isA())); + expect(model.get('/'), 1); + }); + test('rejects excessively large list indices to prevent OOM', () { final model = DataModel(); expect( From 203459146ce6af20dc4262feef9036cc603f97db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Diego=20L=C3=B3pez?= <63005462+diegolopezrm@users.noreply.github.com> Date: Wed, 2 Sep 2026 23:39:13 -0500 Subject: [PATCH 2/2] fix(web_core): reject writes through a primitive root, keeping the root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DataModel.set` guarded the root with `if (!this.data) this.data = {}`, which is a truthiness test, so the root was replaced whenever it held a falsy value. Setting the root to `false`, `0` or `''` and then writing any path silently discarded that root and started a fresh object. A truthy primitive root took the other path and reached `current[lastSegment] = value` on a number or string, which in a module (strict mode) throws a raw `TypeError` — outside the `A2uiError` hierarchy consumers catch, and not what the method's own documentation promises. The root is now replaced only when it is absent, and a primitive root raises `A2uiDataError`, the same answer this method already gives for a primitive at any other depth. --- .../web_core/src/v0_9/state/data-model.test.ts | 17 +++++++++++++++++ renderers/web_core/src/v0_9/state/data-model.ts | 10 +++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/renderers/web_core/src/v0_9/state/data-model.test.ts b/renderers/web_core/src/v0_9/state/data-model.test.ts index 1a72d0b7b0..a665debd8a 100644 --- a/renderers/web_core/src/v0_9/state/data-model.test.ts +++ b/renderers/web_core/src/v0_9/state/data-model.test.ts @@ -17,6 +17,7 @@ import * as assert from 'node:assert'; import {describe, it, beforeEach} from 'node:test'; import {DataModel} from './data-model.js'; +import {A2uiDataError} from '../errors.js'; describe('DataModel', () => { let model: DataModel; @@ -446,6 +447,22 @@ describe('DataModel', () => { assert.strictEqual(model.get('/items/invalid'), undefined); }); + it('rejects writes when the root itself is a primitive', () => { + const primitiveRoot = new DataModel({}); + primitiveRoot.set('/', 1); + assert.throws(() => primitiveRoot.set('/a', 2), A2uiDataError); + assert.strictEqual(primitiveRoot.get('/'), 1); + }); + + it('keeps a falsy primitive root instead of replacing it', () => { + for (const root of [false, 0, '']) { + const falsyRoot = new DataModel({}); + falsyRoot.set('/', root); + assert.throws(() => falsyRoot.set('/a', 2), A2uiDataError); + assert.strictEqual(falsyRoot.get('/'), root); + } + }); + it('rejects leading-zero array indices (RFC 6901)', () => { assert.throws(() => { model.set('/items/01', 'value'); diff --git a/renderers/web_core/src/v0_9/state/data-model.ts b/renderers/web_core/src/v0_9/state/data-model.ts index 150ac23e8c..28d057a4e6 100644 --- a/renderers/web_core/src/v0_9/state/data-model.ts +++ b/renderers/web_core/src/v0_9/state/data-model.ts @@ -116,8 +116,16 @@ export class DataModel { const segments = this.parsePath(path); const lastSegment = segments.pop()!; - if (!this.data) { + // Only an absent root is replaced with a container. A primitive root is a + // value the caller put there, and writing a path through it is the same + // error as writing through a primitive at any other depth. + if (this.data === undefined || this.data === null) { this.data = {}; + } else if (typeof this.data !== 'object') { + throw new A2uiDataError( + `Cannot set path '${path}': the data model root is a primitive value.`, + path, + ); } let current: any = this.data; for (let i = 0; i < segments.length; i++) {