diff --git a/changelog.d/213-reference-optional-fields.fixed.md b/changelog.d/213-reference-optional-fields.fixed.md new file mode 100644 index 000000000..9ad0f1dbf --- /dev/null +++ b/changelog.d/213-reference-optional-fields.fixed.md @@ -0,0 +1 @@ +- RFC-005 references now drop overlong optional `name`, `mimeType`, and `expiresAt` fields instead of retaining truncated testimony. Their schema limits count Unicode code points. Reference blocks and subtractive disposition remain valid; admitted display labels retain independent sanitization and truncation. diff --git a/src/mcpl/references.ts b/src/mcpl/references.ts index 03ec5fac4..acadd26ae 100644 --- a/src/mcpl/references.ts +++ b/src/mcpl/references.ts @@ -56,12 +56,20 @@ function isRecord(x: unknown): x is Record { function takeString( raw: unknown, field: keyof typeof REFERENCE_LIMITS & string, - out: { rejected: string[]; truncated: string[] }, + out: { rejected: string[] }, ): string | undefined { if (raw === undefined) return undefined; if (typeof raw !== 'string') { out.rejected.push(field); return undefined; } - const limit = REFERENCE_LIMITS[field]; - if (raw.length > limit) { out.truncated.push(field); return raw.slice(0, limit); } + // JSON Schema maxLength counts Unicode code points, not UTF-16 units. + // Stop as soon as the optional field is invalid, without allocating a copy + // of an arbitrarily large server string. Display truncation happens later. + let length = 0; + for (const _codePoint of raw) { + if (++length > REFERENCE_LIMITS[field]) { + out.rejected.push(field); + return undefined; + } + } return raw; } diff --git a/test/mcpl-reference-fields.test.ts b/test/mcpl-reference-fields.test.ts new file mode 100644 index 000000000..a54680aa7 --- /dev/null +++ b/test/mcpl-reference-fields.test.ts @@ -0,0 +1,90 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildReferenceStub, classifyBlock, isReferenceExpired, REFERENCE_LIMITS, + referenceRegistry, referenceStubOrNull, +} from '../src/mcpl/references.js'; + +const fields = ['name', 'mimeType', 'expiresAt'] as const; +const types = ['resource', 'image', 'audio'] as const; +function reference(type: string, extra: Record = {}) { + const block = { type, uri: 'https://example.test/optional-fields', disposition: 'never', ...extra }; + const parsed = classifyBlock(block); + assert.equal(parsed.kind, 'reference'); + if (parsed.kind !== 'reference') throw new Error('expected reference'); + return { block, testimony: parsed.testimony }; +} + +for (const field of fields) { + test('drops overlong optional ' + field + ' while preserving each reference block and disposition', () => { + for (const type of types) { + const { block, testimony } = reference(type, { [field]: 'x'.repeat(REFERENCE_LIMITS[field] + 1) }); + assert.equal(testimony[field], undefined, 'invalid optional metadata is absent, not repaired'); + assert.deepEqual(testimony.rejectedFields, [field]); + assert.deepEqual(testimony.truncatedFields, []); + assert.equal(testimony.uri, block.uri); + assert.equal(testimony.disposition, 'never'); + assert.ok(referenceStubOrNull(block)?.startsWith('[ref_')); + assert.ok(!referenceStubOrNull(block)?.includes(block.uri)); + } + }); + + test('admits ' + field + ' exactly at its JSON Schema code-point limit without changing it', () => { + for (const value of ['', 'x'.repeat(REFERENCE_LIMITS[field]), '🪶'.repeat(REFERENCE_LIMITS[field])]) { + const { testimony } = reference('resource', { [field]: value }); + assert.equal(testimony[field], value); + assert.deepEqual(testimony.rejectedFields, []); + assert.deepEqual(testimony.truncatedFields, []); + } + const { testimony } = reference('resource', { [field]: '🪶'.repeat(REFERENCE_LIMITS[field]) + 'x' }); + assert.equal(testimony[field], undefined); + assert.deepEqual(testimony.rejectedFields, [field]); + }); +} + +test('wrong-type optional fields are dropped while a bad required URI rejects the block', () => { + const { testimony } = reference('resource', { name: 123, mimeType: null, expiresAt: [] }); + assert.equal(testimony.name, undefined); + assert.equal(testimony.mimeType, undefined); + assert.equal(testimony.expiresAt, undefined); + assert.deepEqual(testimony.rejectedFields.sort(), ['expiresAt', 'mimeType', 'name']); + assert.equal(testimony.disposition, 'never'); + for (const uri of [undefined, null, '', 'x'.repeat(REFERENCE_LIMITS.uri + 1)]) { + assert.equal(classifyBlock({ type: 'resource', uri, disposition: 'never' }).kind, 'invalid'); + } +}); + +test('schema-admitted metadata still uses independent sanitized and bounded display labels', () => { + const name = 'A'.repeat(REFERENCE_LIMITS.name - 2) + '\u202E\n'; + const mimeType = 'B'.repeat(REFERENCE_LIMITS.mimeType); + const { testimony } = reference('resource', { name, mimeType }); + const stub = buildReferenceStub(testimony); + const record = referenceRegistry.findByUri(testimony.uri)!; + assert.equal(record.testimony.name, name, 'display bounds do not alter admitted testimony'); + assert.equal(record.testimony.mimeType, mimeType); + assert.ok(stub.includes('A'.repeat(119) + '…')); + assert.ok(stub.includes('B'.repeat(119) + '…')); + assert.ok(!stub.includes('\u202E') && !stub.includes('\n')); + assert.ok(!stub.includes(testimony.uri)); + assert.ok(stub.length < 350); +}); + +test('an admitted but unparseable expiry still fails closed; schema-invalid expiry is absent', () => { + const validShape = reference('resource', { expiresAt: 'not-a-date' }).testimony; + assert.equal(isReferenceExpired(validShape), true); + const invalidShape = reference('resource', { expiresAt: 'x'.repeat(REFERENCE_LIMITS.expiresAt + 1) }).testimony; + assert.equal(invalidShape.expiresAt, undefined); + assert.deepEqual(invalidShape.rejectedFields, ['expiresAt']); +}); + +test('large invalid labels cannot change the bounded model-visible stub', () => { + const { testimony } = reference('audio', { + name: 'x'.repeat(1024 * 1024), mimeType: 'y'.repeat(1024 * 1024), sizeBytes: 1024, + }); + assert.equal(testimony.name, undefined); + assert.equal(testimony.mimeType, undefined); + const stub = buildReferenceStub(testimony, 'server'); + assert.ok(stub.length < 150); + assert.ok(stub.includes('claimed')); + assert.ok(!stub.includes(testimony.uri)); +});