From 98fc253b9bc12c3203ddc078055a469f848b943c Mon Sep 17 00:00:00 2001 From: Martin Aeschlimann Date: Tue, 1 Sep 2026 11:04:05 +0200 Subject: [PATCH] Fix schema handling to preserve resolve errors during validation and add OpenAPI overlay schema --- src/services/jsonSchemaService.ts | 12 +++- src/test/fixtures/openapi-overlay-1.X.json | 43 +++++++++++++ src/test/schema.test.ts | 71 ++++++++++++++++++++++ 3 files changed, 123 insertions(+), 3 deletions(-) create mode 100644 src/test/fixtures/openapi-overlay-1.X.json diff --git a/src/services/jsonSchemaService.ts b/src/services/jsonSchemaService.ts index 5383c52..a23476c 100644 --- a/src/services/jsonSchemaService.ts +++ b/src/services/jsonSchemaService.ts @@ -1245,9 +1245,15 @@ export class JSONSchemaService implements IJSONSchemaService { const existingHandle = this.schemasById[resolvedUri]; if (!existingHandle) { this.addSchemaHandle(resolvedUri, node); - } else { - // Update existing handle with embedded schema content - // This ensures embedded schemas take precedence over external schemas + } else if (existingHandle !== handle) { + // Update existing handle with embedded schema content. + // This ensures embedded schemas take precedence over external schemas. + // Skip when the existing handle is the handle currently being resolved + // (i.e. the root schema's own $id matches its retrieval URI): overwriting + // it here would reset its cache mid-resolution using the same object + // reference that this resolution pass is still mutating (merging $refs + // into), silently discarding any resolveErrors accumulated so far on the + // next time this handle is resolved. existingHandle.setSchemaContent(node); } newBaseUri = resolvedUri; diff --git a/src/test/fixtures/openapi-overlay-1.X.json b/src/test/fixtures/openapi-overlay-1.X.json new file mode 100644 index 0000000..edcfcfc --- /dev/null +++ b/src/test/fixtures/openapi-overlay-1.X.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://www.schemastore.org/openapi-overlay-1.X.json", + "title": "OpenAPI Overlay Document v1.X", + "type": "object", + "required": [ + "overlay" + ], + "properties": { + "overlay": { + "pattern": "^1\\.(0|1)\\.", + "type": "string" + } + }, + "allOf": [ + { + "if": { + "properties": { + "overlay": { + "pattern": "^1\\.0\\.", + "type": "string" + } + } + }, + "then": { + "$ref": "https://spec.openapis.org/overlay/1.0/schema/2026-04-01" + } + }, + { + "if": { + "properties": { + "overlay": { + "pattern": "^1\\.1\\.", + "type": "string" + } + } + }, + "then": { + "$ref": "https://spec.openapis.org/overlay/1.1/schema/2026-04-01" + } + } + ] +} \ No newline at end of file diff --git a/src/test/schema.test.ts b/src/test/schema.test.ts index 29d8636..6ee2ccf 100644 --- a/src/test/schema.test.ts +++ b/src/test/schema.test.ts @@ -4028,6 +4028,40 @@ suite('JSON Schema', () => { assert.ok(validation.some(v => messageContains(v.message, 'string')), 'Expected type mismatch from the embedded schema'); }); + test('schema whose root $id matches its own retrieval URI keeps resolve errors on repeated validation', async function () { + // Reproduces a self-registration bug: registerEmbeddedSchemas treats the root + // schema's own $id (equal to the URI it was fetched from) as an embedded schema + // to (re-)register, calling setSchemaContent on the handle currently being + // resolved. That clears the handle's cached resolved/unresolved schema using the + // same (still being mutated) object reference, so a *second* validation of a + // document using this schema would recompute from the already ref-merged object + // and silently lose the resolveErrors recorded on the first pass. + const wrapperUri = 'https://example.com/wrapper.json'; + const externalUri = 'https://example.com/unreachable.json'; + const wrapperSchema: JSONSchema = { + $id: wrapperUri, + type: 'object', + allOf: [ + { $ref: externalUri } + ] + }; + const schemaRequestService: SchemaRequestService = async (uri: string): Promise => { + if (uri === wrapperUri) { + return JSON.stringify(wrapperSchema); + } + throw new Error(`Unreachable schema: ${uri}`); + }; + const ls = getLanguageService({ schemaRequestService, workspaceContext }); + + const { textDoc: textDoc1, jsonDoc: jsonDoc1 } = toDocument(JSON.stringify({ $schema: wrapperUri })); + const firstValidation = await ls.doValidation(textDoc1, jsonDoc1, {}); + assert.ok(firstValidation.some(v => messageContains(v.message, 'Unreachable schema')), 'Expected a resolve error on the first validation'); + + const { textDoc: textDoc2, jsonDoc: jsonDoc2 } = toDocument(JSON.stringify({ $schema: wrapperUri, extra: true })); + const secondValidation = await ls.doValidation(textDoc2, jsonDoc2, {}); + assert.ok(secondValidation.some(v => messageContains(v.message, 'Unreachable schema')), 'Expected the resolve error to persist on a later validation of the same schema handle'); + }); + test('nested embedded schema with $ref between embedded schemas', async function () { // An embedded schema referencing another embedded schema within the same document const schema: JSONSchema = { @@ -4352,4 +4386,41 @@ suite('JSON Schema', () => { assert.strictEqual(embeddedErrors.length, 0, `Should have no errors for embedded schema after reconfigure, but got: ${embeddedErrors.map(v => v.message).join('; ')}`); }); }); + + test('untrusted schema error is reported', async function () { + const overlaySchemaUri = 'https://www.schemastore.org/openapi-overlay-1.X.json'; + const openApiSpecUri1 = 'https://spec.openapis.org/overlay/1.0/schema/2026-04-01'; + const openApiSpecUri2 = 'https://spec.openapis.org/overlay/1.1/schema/2026-04-01'; + + const fixturePath = path.join(__dirname, '../../../src/test/fixtures/openapi-overlay-1.X.json'); + const overlaySchemaContent = (await fs.readFile(fixturePath)).toString(); + + const schemaRequestService: SchemaRequestService = async (uri: string) => { + if (uri === overlaySchemaUri) { + return overlaySchemaContent; + } + if (uri === openApiSpecUri1 || uri === openApiSpecUri2) { + throw new Error('Untrusted schema: access denied'); + } + throw new Error(`Resource not found: ${uri}`); + }; + + const ls = getLanguageService({ workspaceContext, schemaRequestService }); + + const { textDoc, jsonDoc } = toDocument( + JSON.stringify({ $schema: overlaySchemaUri, overlay: '1.0.0' }), + undefined, + 'file:///test.json' + ); + + const validation = await ls.doValidation(textDoc, jsonDoc); + + // Should report the untrusted error, not missing overlay fields + const untrustedErrors = validation.filter(v => messageContains(v.message, 'Untrusted')); + assert.ok(untrustedErrors.length > 0, `Expected untrusted schema error but got: ${validation.map(v => v.message).join('; ')}`); + + // Should not report "overlay is required" + const overlayRequiredErrors = validation.filter(v => messageContains(v.message, 'overlay is required')); + assert.strictEqual(overlayRequiredErrors.length, 0, 'Should not report missing properties when schema cannot be loaded due to untrusted error'); + }); });