Skip to content

Commit 7aceff4

Browse files
Added ref verification
Signed-off-by: Steve Springett <[email protected]>
1 parent c46624c commit 7aceff4

File tree

1 file changed

+87
-0
lines changed

1 file changed

+87
-0
lines changed

tools/src/main/js/bundle-schemas.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,61 @@
33
const fs = require('fs').promises;
44
const path = require('path');
55

6+
function isObject(value) {
7+
return typeof value === 'object' && value !== null;
8+
}
9+
10+
/**
11+
* Resolve a JSON Pointer (RFC6901) against an object. Returns { ok: boolean, value?: any, error?: string }
12+
*/
13+
function resolveJsonPointer(root, pointer) {
14+
if (typeof pointer !== 'string' || pointer.length === 0) {
15+
return { ok: false, error: 'Empty JSON Pointer' };
16+
}
17+
// Allow pointers like "#/..." or "/..."; strip leading '#'
18+
let p = pointer.startsWith('#') ? pointer.slice(1) : pointer;
19+
if (p === '') return { ok: true, value: root };
20+
if (!p.startsWith('/')) {
21+
return { ok: false, error: `Pointer must start with '/': ${pointer}` };
22+
}
23+
const parts = p.split('/').slice(1).map(seg => seg.replace(/~1/g, '/').replace(/~0/g, '~'));
24+
let current = root;
25+
for (const key of parts) {
26+
if (!isObject(current) && !Array.isArray(current)) {
27+
return { ok: false, error: `Non-object encountered before end at '${key}' in ${pointer}` };
28+
}
29+
if (!(key in current)) {
30+
return { ok: false, error: `Missing key '${key}' in ${pointer}` };
31+
}
32+
current = current[key];
33+
}
34+
return { ok: true, value: current };
35+
}
36+
37+
/**
38+
* Traverse an object and collect all ref-like keyword values matching a predicate
39+
*/
40+
function collectRefKeywords(obj, keys, predicate, pathStack = []) {
41+
const result = [];
42+
if (!isObject(obj)) return result;
43+
44+
if (Array.isArray(obj)) {
45+
obj.forEach((item, idx) => {
46+
result.push(...collectRefKeywords(item, keys, predicate, pathStack.concat(`[${idx}]`)));
47+
});
48+
return result;
49+
}
50+
51+
for (const [k, v] of Object.entries(obj)) {
52+
const nextPath = pathStack.concat(k);
53+
if (keys.includes(k) && typeof v === 'string' && (!predicate || predicate(v, k))) {
54+
result.push({ ref: v, key: k, path: nextPath.join('.') });
55+
}
56+
result.push(...collectRefKeywords(v, keys, predicate, nextPath));
57+
}
58+
return result;
59+
}
60+
661
/**
762
* Recursively walks through an object and rewrites $ref paths
863
*/
@@ -162,6 +217,24 @@ async function bundleSchemas(modelsDirectory, rootSchemaPath, options = {}) {
162217

163218
console.log(`\nUsing schema version: ${schemaVersion}`);
164219
console.log(`Using keyword: ${defsKeyword}`);
220+
221+
// Pre-check: external file $ref targets must exist among loaded schemas
222+
console.log('Validating external $ref targets...');
223+
const allowedFiles = new Set([...schemaFiles, rootSchemaFilename]);
224+
for (const [name, schema] of Object.entries(schemas)) {
225+
// Only $ref can be external; $dynamicRef/$recursiveRef are JSON Pointers by spec
226+
const refs = collectRefKeywords(schema, ['$ref'], (v) => /^(.+\.schema\.json)(#.*)?$/.test(v));
227+
for (const { ref, key, path: refPath } of refs) {
228+
const m = ref.match(/^(.+\.schema\.json)(#.*)?$/);
229+
if (!m) continue;
230+
const target = m[1];
231+
const base = path.basename(target);
232+
if (!allowedFiles.has(base)) {
233+
throw new Error(`Unresolved external ${key} target file '${target}' referenced from schema '${name}' at '${refPath}'`);
234+
}
235+
}
236+
}
237+
165238
console.log('Rewriting $ref pointers...');
166239

167240
// Rewrite all $refs in all schemas
@@ -181,6 +254,20 @@ async function bundleSchemas(modelsDirectory, rootSchemaPath, options = {}) {
181254
[defsKeyword]: rewrittenDefinitions
182255
};
183256

257+
// Post-check: ensure all internal JSON Pointer refs resolve in the final bundle
258+
console.log('Validating internal ref pointers ($ref, $dynamicRef, $recursiveRef)...');
259+
const internalRefs = collectRefKeywords(
260+
finalSchema,
261+
['$ref', '$dynamicRef', '$recursiveRef'],
262+
(v) => typeof v === 'string' && v.startsWith('#')
263+
);
264+
for (const { ref, key, path: refPath } of internalRefs) {
265+
const resolved = resolveJsonPointer(finalSchema, ref);
266+
if (!resolved.ok) {
267+
throw new Error(`Unresolved internal ${key} '${ref}' at '${refPath}': ${resolved.error}`);
268+
}
269+
}
270+
184271
// Optionally validate with AJV
185272
if (options.validate) {
186273
console.log('\nValidating with AJV...');

0 commit comments

Comments
 (0)