@amritk/resolve-refs
Advanced tools
@@ -0,2 +1,10 @@ | ||
| import type { JsonPath } from './types.js'; | ||
| /** | ||
| * Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading | ||
| * `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`), | ||
| * and coerces all-digit segments to numbers so array indices and object keys read | ||
| * back the same way a path consumer expects. A bare `''` or `'/'` is the empty path. | ||
| */ | ||
| export declare const pointerToPath: (pointer: string) => JsonPath; | ||
| /** | ||
| * Walks a JSON Pointer string (RFC 6901) to the value it points to within | ||
@@ -3,0 +11,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"get-by-pointer.d.ts","sourceRoot":"","sources":["../src/get-by-pointer.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,OAAO,WAAW,MAAM,KAAG,OAuB7D,CAAA"} | ||
| {"version":3,"file":"get-by-pointer.d.ts","sourceRoot":"","sources":["../src/get-by-pointer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAA;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,YAAa,MAAM,KAAG,QAe/C,CAAA;AAED;;;;;GAKG;AACH,eAAO,MAAM,YAAY,SAAU,OAAO,WAAW,MAAM,KAAG,OAuB7D,CAAA"} |
| /** | ||
| * Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading | ||
| * `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`), | ||
| * and coerces all-digit segments to numbers so array indices and object keys read | ||
| * back the same way a path consumer expects. A bare `''` or `'/'` is the empty path. | ||
| */ | ||
| export const pointerToPath = (pointer) => { | ||
| if (pointer === '' || pointer === '/') | ||
| return []; | ||
| return pointer | ||
| .replace(/^\//, '') | ||
| .split('/') | ||
| .map((segment) => { | ||
| let decoded = segment; | ||
| try { | ||
| decoded = decodeURIComponent(segment); | ||
| } | ||
| catch { | ||
| // leave invalid percent-escapes as-is | ||
| } | ||
| decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~'); | ||
| return /^\d+$/.test(decoded) ? Number(decoded) : decoded; | ||
| }); | ||
| }; | ||
| /** | ||
| * Walks a JSON Pointer string (RFC 6901) to the value it points to within | ||
@@ -3,0 +27,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are |
+3
-3
@@ -1,6 +0,6 @@ | ||
| export { getByPointer } from './get-by-pointer.js'; | ||
| export { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| export { isPrivateHost } from './is-private-host.js'; | ||
| export { resolveRefs } from './resolve-refs.js'; | ||
| export { type ResolveRefsOptions, resolveRefs } from './resolve-refs.js'; | ||
| export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file.js'; | ||
| export type { JsonPath, ResolveError, ResolveOptions, ResolveResult } from './types.js'; | ||
| export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAA;AAC/C,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AAC5C,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAChF,YAAY,EAAE,QAAQ,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,kBAAkB,CAAA;AAC9D,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AACjD,OAAO,EAAE,KAAK,kBAAkB,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAA;AACrE,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,0BAA0B,CAAA;AAChF,YAAY,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA"} |
+1
-1
@@ -1,4 +0,4 @@ | ||
| export { getByPointer } from './get-by-pointer.js'; | ||
| export { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| export { isPrivateHost } from './is-private-host.js'; | ||
| export { resolveRefs } from './resolve-refs.js'; | ||
| export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file.js'; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"is-private-host.d.ts","sourceRoot":"","sources":["../src/is-private-host.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,eAAO,MAAM,aAAa,aAAc,MAAM,KAAG,OAyBhD,CAAA"} | ||
| {"version":3,"file":"is-private-host.d.ts","sourceRoot":"","sources":["../src/is-private-host.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAoDH,eAAO,MAAM,aAAa,aAAc,MAAM,KAAG,OA+BhD,CAAA"} |
+84
-21
@@ -9,2 +9,63 @@ /** | ||
| */ | ||
| /** True for an IPv4 address (given its first two octets) in a non-public range. */ | ||
| const isPrivateIpv4 = (a, b) => { | ||
| if (a === 10 || a === 127 || a === 0) | ||
| return true; | ||
| if (a === 169 && b === 254) | ||
| return true; // link-local + cloud metadata | ||
| if (a === 172 && b >= 16 && b <= 31) | ||
| return true; | ||
| if (a === 192 && b === 168) | ||
| return true; | ||
| if (a === 100 && b >= 64 && b <= 127) | ||
| return true; // CGNAT | ||
| return false; | ||
| }; | ||
| /** Parses a single IPv4 part in decimal, octal (`0…`), or hex (`0x…`) form. */ | ||
| const parseIpv4Part = (s) => { | ||
| if (/^0x[0-9a-f]+$/i.test(s)) | ||
| return Number.parseInt(s, 16); | ||
| if (/^0[0-7]*$/.test(s)) | ||
| return Number.parseInt(s, 8); | ||
| if (/^[1-9][0-9]*$/.test(s)) | ||
| return Number.parseInt(s, 10); | ||
| return null; | ||
| }; | ||
| /** | ||
| * Resolves the first two octets of an IPv4 host written in any inet_aton form | ||
| * (dotted/decimal/octal/hex, 1–4 parts), or `null` if it is not an IPv4 literal. | ||
| * The WHATWG URL parser normalizes these to dotted-decimal before they reach us, | ||
| * but a direct caller (this is an exported guard) can pass the raw form. | ||
| */ | ||
| const ipv4Octets = (host) => { | ||
| if (!/^[0-9a-fx.]+$/i.test(host)) | ||
| return null; | ||
| const parts = host.split('.'); | ||
| if (parts.length === 0 || parts.length > 4) | ||
| return null; | ||
| const nums = []; | ||
| for (const part of parts) { | ||
| const n = parseIpv4Part(part); | ||
| if (n === null) | ||
| return null; | ||
| nums.push(n); | ||
| } | ||
| // inet_aton packing: every part but the last is one byte; the last fills the | ||
| // remaining low bytes (so `127.1` is 127.0.0.1 and `2130706433` is too). | ||
| let value = 0; | ||
| for (let i = 0; i < nums.length - 1; i++) { | ||
| const byte = nums[i]; | ||
| if (byte > 0xff) | ||
| return null; | ||
| value = value * 256 + byte; | ||
| } | ||
| const remaining = 4 - (nums.length - 1); | ||
| const last = nums[nums.length - 1]; | ||
| if (last > 256 ** remaining - 1) | ||
| return null; | ||
| value = value * 256 ** remaining + last; | ||
| if (value > 0xffffffff) | ||
| return null; | ||
| return [(value >>> 24) & 0xff, (value >>> 16) & 0xff]; | ||
| }; | ||
| export const isPrivateHost = (hostname) => { | ||
@@ -14,30 +75,32 @@ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase(); | ||
| return true; | ||
| // IPv4 (incl. IPv4-mapped IPv6 like ::ffff:127.0.0.1) | ||
| const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); | ||
| if (v4) { | ||
| const a = Number(v4[1]); | ||
| const b = Number(v4[2]); | ||
| if (a === 10 || a === 127 || a === 0) | ||
| return true; | ||
| if (a === 169 && b === 254) | ||
| return true; // link-local + metadata | ||
| if (a === 172 && b >= 16 && b <= 31) | ||
| return true; | ||
| if (a === 192 && b === 168) | ||
| return true; | ||
| if (a === 100 && b >= 64 && b <= 127) | ||
| return true; // CGNAT | ||
| return false; | ||
| } | ||
| // IPv6 | ||
| if (host.includes(':')) { | ||
| // --- IPv6 (and IPv4-mapped IPv6) --- | ||
| if (host === '::1' || host === '::') | ||
| return true; | ||
| if (host.startsWith('fe80:') || host.startsWith('fe80::')) | ||
| return true; // link-local | ||
| // fe80::/10 link-local spans fe80–febf (third nibble 8–b). | ||
| if (/^fe[89ab][0-9a-f]:/.test(host)) | ||
| return true; | ||
| if (host.startsWith('fc') || host.startsWith('fd')) | ||
| return true; // unique-local | ||
| return true; // fc00::/7 unique-local | ||
| // IPv4-mapped/compatible, dotted (`::ffff:127.0.0.1`) — the URL parser | ||
| // rewrites this to hex, but a direct caller may pass the dotted form. | ||
| const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(host); | ||
| if (dotted?.[1]) { | ||
| const oct = ipv4Octets(dotted[1]); | ||
| if (oct) | ||
| return isPrivateIpv4(oct[0], oct[1]); | ||
| } | ||
| // IPv4-mapped, hex (`::ffff:7f00:1`) — what `new URL()` produces. | ||
| const hex = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host); | ||
| if (hex?.[1] && hex[2]) { | ||
| const hi = Number.parseInt(hex[1], 16); | ||
| return isPrivateIpv4((hi >> 8) & 0xff, hi & 0xff); | ||
| } | ||
| return false; | ||
| } | ||
| // --- IPv4 (any inet_aton encoding) --- | ||
| const oct = ipv4Octets(host); | ||
| if (oct) | ||
| return isPrivateIpv4(oct[0], oct[1]); | ||
| return false; | ||
| }; |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"resolve-refs-from-file.d.ts","sourceRoot":"","sources":["../src/resolve-refs-from-file.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAgB,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AA0C1E,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAA;AAqKD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,aAAoB,MAAM,YAAW,cAAc,KAAQ,OAAO,CAAC,aAAa,CAW/G,CAAA"} | ||
| {"version":3,"file":"resolve-refs-from-file.d.ts","sourceRoot":"","sources":["../src/resolve-refs-from-file.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAA2B,cAAc,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AA+CrF,iFAAiF;AACjF,eAAO,MAAM,gBAAgB,QAAO,IAEnC,CAAA;AAwND;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,aAAoB,MAAM,YAAW,cAAc,KAAQ,OAAO,CAAC,aAAa,CAY/G,CAAA"} |
| import { readFileSync } from 'node:fs'; | ||
| import { dirname, resolve as resolvePath } from 'node:path'; | ||
| import { getByPointer } from './get-by-pointer.js'; | ||
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| import { isPrivateHost } from './is-private-host.js'; | ||
@@ -39,2 +39,6 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| const remoteCache = new Map(); | ||
| // In-flight remote loads, keyed by location. Two resolve passes that start at | ||
| // the same time and reach the same URL share a single fetch instead of racing | ||
| // two requests; whichever arrives first installs the promise, the rest await it. | ||
| const inFlight = new Map(); | ||
| /** Drops every cached remote document. Mainly useful for tests/long sessions. */ | ||
@@ -44,2 +48,35 @@ export const clearRemoteCache = () => { | ||
| }; | ||
| // Cap on redirect hops before we give up — generous enough for real services, | ||
| // low enough to stop a redirect loop from spinning forever. | ||
| const MAX_REDIRECTS = 5; | ||
| /** | ||
| * Fetches and parses a remote document, following redirects manually so the | ||
| * SSRF guard is re-applied to every hop. `fetch` follows redirects by default, | ||
| * which would let an allow-listed public URL bounce to a private/loopback | ||
| * address (e.g. the `169.254.169.254` metadata endpoint) — so we set | ||
| * `redirect: 'manual'` and re-run {@link denialReason} on each `Location`. | ||
| */ | ||
| const fetchRemote = async (location, options) => { | ||
| let current = location; | ||
| for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { | ||
| const reason = denialReason(current, options); | ||
| if (reason !== null) | ||
| throw new Error(`refusing to follow redirect (${reason}): ${current}`); | ||
| const response = await fetch(current, { redirect: 'manual' }); | ||
| if (response.status >= 300 && response.status < 400) { | ||
| const next = response.headers.get('location'); | ||
| if (!next) | ||
| throw new Error(`HTTP ${response.status} redirect with no Location header`); | ||
| current = new URL(next, current).href; | ||
| continue; | ||
| } | ||
| if (!response.ok) | ||
| throw new Error(`HTTP ${response.status} ${response.statusText}`); | ||
| const parse = options.parse ?? ((c) => JSON.parse(c)); | ||
| // Parse against the original request location so the caller's format sniffing | ||
| // (e.g. `.yaml` vs `.json`) and any relative refs key off a stable identity. | ||
| return parse(await response.text(), location); | ||
| } | ||
| throw new Error(`too many redirects (>${MAX_REDIRECTS}): ${location}`); | ||
| }; | ||
| /** Returns why a remote location may not be fetched, or `null` if it is allowed. */ | ||
@@ -88,8 +125,12 @@ const denialReason = (location, options) => { | ||
| } | ||
| // Coalesce concurrent loads of the same URL onto one in-flight request; the | ||
| // owner (first caller) clears the slot once it settles. | ||
| let pending = inFlight.get(location); | ||
| const owner = pending === undefined; | ||
| if (pending === undefined) { | ||
| pending = fetchRemote(location, options); | ||
| inFlight.set(location, pending); | ||
| } | ||
| try { | ||
| const response = await fetch(location); | ||
| if (!response.ok) | ||
| throw new Error(`HTTP ${response.status} ${response.statusText}`); | ||
| const parse = options.parse ?? ((c) => JSON.parse(c)); | ||
| const doc = parse(await response.text(), location); | ||
| const doc = await pending; | ||
| remoteCache.set(location, doc); | ||
@@ -104,2 +145,6 @@ docCache.set(location, doc); | ||
| } | ||
| finally { | ||
| if (owner) | ||
| inFlight.delete(location); | ||
| } | ||
| } | ||
@@ -169,7 +214,7 @@ try { | ||
| */ | ||
| const resolveAt = (node, baseLocation, docCache, refCache) => { | ||
| const resolveAt = (node, baseLocation, docCache, refCache, origins) => { | ||
| if (node === null || typeof node !== 'object') | ||
| return node; | ||
| if (Array.isArray(node)) { | ||
| return node.map((item) => resolveAt(item, baseLocation, docCache, refCache)); | ||
| return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins)); | ||
| } | ||
@@ -188,4 +233,13 @@ const obj = node; | ||
| const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot; | ||
| const resolved = resolveAt(target, targetLocation, docCache, refCache); | ||
| const resolved = resolveAt(target, targetLocation, docCache, refCache, origins); | ||
| refCache.set(cacheKey, resolved); | ||
| // Stamp the inlined node with where it was defined so a consumer can map a | ||
| // resolved-tree node back to its source document/path. Only objects/arrays are | ||
| // stamped (primitives can't key the map). First-write-wins: resolution recurses | ||
| // to the deepest `$ref` before returning, so the *definition* site stamps first; | ||
| // an outer ref that merely points (transitively) at the same object must not | ||
| // overwrite it with an intermediate location. | ||
| if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) { | ||
| origins.set(resolved, { location: targetLocation, pointer: pointerToPath(pointer) }); | ||
| } | ||
| return resolved; | ||
@@ -195,3 +249,3 @@ } | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache); | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins); | ||
| } | ||
@@ -213,4 +267,5 @@ return result; | ||
| await prefetch(rootLocation, docCache, options, errors); | ||
| const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map()); | ||
| return { resolved, errors }; | ||
| const origins = options.trackOrigins ? new Map() : undefined; | ||
| const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map(), origins); | ||
| return origins ? { resolved, errors, origins } : { resolved, errors }; | ||
| }; |
| import type { ResolveResult } from './types.js'; | ||
| /** Options for the in-memory resolver. */ | ||
| export type ResolveRefsOptions = { | ||
| /** | ||
| * Record a per-node origin map on the result (`origins`). For every object or | ||
| * array inlined in place of a `$ref`, the map records the in-document path it | ||
| * was defined at (the `location` is `''`, the single in-memory document). | ||
| * Defaults to `false`. | ||
| */ | ||
| trackOrigins?: boolean; | ||
| }; | ||
| /** | ||
@@ -7,3 +17,3 @@ * Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining | ||
| */ | ||
| export declare const resolveRefs: (data: unknown) => ResolveResult; | ||
| export declare const resolveRefs: (data: unknown, options?: ResolveRefsOptions) => ResolveResult; | ||
| //# sourceMappingURL=resolve-refs.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"resolve-refs.d.ts","sourceRoot":"","sources":["../src/resolve-refs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAA;AAuC5C;;;;GAIG;AACH,eAAO,MAAM,WAAW,SAAU,OAAO,KAAG,aAG3C,CAAA"} | ||
| {"version":3,"file":"resolve-refs.d.ts","sourceRoot":"","sources":["../src/resolve-refs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAa,aAAa,EAAE,MAAM,SAAS,CAAA;AAOvD,0CAA0C;AAC1C,MAAM,MAAM,kBAAkB,GAAG;IAC/B;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA;AA6CD;;;;GAIG;AACH,eAAO,MAAM,WAAW,SAAU,OAAO,YAAW,kBAAkB,KAAQ,aAI7E,CAAA"} |
+15
-8
@@ -1,2 +0,2 @@ | ||
| import { getByPointer } from './get-by-pointer.js'; | ||
| import { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
@@ -12,7 +12,7 @@ // means we have looped, so we return `{}` instead of recursing forever. | ||
| */ | ||
| const resolveInternal = (node, root, cache) => { | ||
| const resolveInternal = (node, root, cache, origins) => { | ||
| if (node === null || typeof node !== 'object') | ||
| return node; | ||
| if (Array.isArray(node)) | ||
| return node.map((item) => resolveInternal(item, root, cache)); | ||
| return node.map((item) => resolveInternal(item, root, cache, origins)); | ||
| const obj = node; | ||
@@ -28,4 +28,10 @@ if (typeof obj['$ref'] === 'string') { | ||
| cache.set(ref, CYCLE); | ||
| const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache); | ||
| const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache, origins); | ||
| cache.set(ref, resolved); | ||
| // Stamp the inlined node with the path it was defined at (see resolveAt). | ||
| // First-write-wins so the deepest definition stamps before any outer ref that | ||
| // transitively points at the same object. Primitives can't key the map. | ||
| if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) { | ||
| origins.set(resolved, { location: '', pointer: pointerToPath(ref.slice(1)) }); | ||
| } | ||
| return resolved; | ||
@@ -35,3 +41,3 @@ } | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveInternal(obj[key], root, cache); | ||
| result[key] = resolveInternal(obj[key], root, cache, origins); | ||
| } | ||
@@ -45,5 +51,6 @@ return result; | ||
| */ | ||
| export const resolveRefs = (data) => { | ||
| const resolved = resolveInternal(data, data, new Map()); | ||
| return { resolved, errors: [] }; | ||
| export const resolveRefs = (data, options = {}) => { | ||
| const origins = options.trackOrigins ? new Map() : undefined; | ||
| const resolved = resolveInternal(data, data, new Map(), origins); | ||
| return origins ? { resolved, errors: [], origins } : { resolved, errors: [] }; | ||
| }; |
+30
-0
@@ -11,2 +11,20 @@ /** | ||
| }; | ||
| /** | ||
| * Where an inlined node came from: the absolute location (file path or URL, or | ||
| * `''` for the single in-memory document) of the document it was defined in, and | ||
| * the path to it within that document. | ||
| */ | ||
| export type Origin = { | ||
| location: string; | ||
| pointer: JsonPath; | ||
| }; | ||
| /** | ||
| * Per-node origin map produced when `trackOrigins` is set: maps each object/array | ||
| * that was inlined in place of a `$ref` to where it was defined. A consumer can | ||
| * then attribute a node in the resolved tree back to its source document and path | ||
| * with a single lookup instead of re-deriving the `$ref` traversal. Keyed by node | ||
| * identity, so it relies on the resolver sharing one object per repeated `$ref` | ||
| * target (which it does). | ||
| */ | ||
| export type OriginMap = Map<object, Origin>; | ||
| /** The outcome of a resolve pass: the dereferenced document plus any errors. */ | ||
@@ -17,2 +35,7 @@ export type ResolveResult = { | ||
| errors: ResolveError[]; | ||
| /** | ||
| * Per-node origin map. Present only when `trackOrigins` was requested; each | ||
| * entry maps an inlined object/array to the document and path it came from. | ||
| */ | ||
| origins?: OriginMap; | ||
| }; | ||
@@ -55,3 +78,10 @@ /** Controls how external `$ref`s to other documents are loaded. */ | ||
| parse?: (content: string, location: string) => unknown; | ||
| /** | ||
| * Record a per-node origin map on the result (`origins`). For every object or | ||
| * array inlined in place of a `$ref`, the map records the document and in-file | ||
| * path it was defined at, so a consumer can attribute resolved-tree nodes back | ||
| * to their source without re-walking the `$ref` chain. Defaults to `false`. | ||
| */ | ||
| trackOrigins?: boolean; | ||
| }; | ||
| //# sourceMappingURL=types.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;AAE1C,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,QAAQ,CAAA;CACf,CAAA;AAED,gFAAgF;AAChF,MAAM,MAAM,aAAa,GAAG;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAA;IACjB,MAAM,EAAE,YAAY,EAAE,CAAA;CACvB,CAAA;AAED,mEAAmE;AACnE,MAAM,MAAM,cAAc,GAAG;IAC3B;;OAEG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAA;CACvD,CAAA"} | ||
| {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAA;AAE1C,iFAAiF;AACjF,MAAM,MAAM,YAAY,GAAG;IACzB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,QAAQ,CAAA;CACf,CAAA;AAED;;;;GAIG;AACH,MAAM,MAAM,MAAM,GAAG;IACnB,QAAQ,EAAE,MAAM,CAAA;IAChB,OAAO,EAAE,QAAQ,CAAA;CAClB,CAAA;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,SAAS,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;AAE3C,gFAAgF;AAChF,MAAM,MAAM,aAAa,GAAG;IAC1B,kEAAkE;IAClE,QAAQ,EAAE,OAAO,CAAA;IACjB,MAAM,EAAE,YAAY,EAAE,CAAA;IACtB;;;OAGG;IACH,OAAO,CAAC,EAAE,SAAS,CAAA;CACpB,CAAA;AAED,mEAAmE;AACnE,MAAM,MAAM,cAAc,GAAG;IAC3B;;OAEG;IACH,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB;;;;OAIG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAA;IACvB;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,OAAO,CAAA;IAC3B;;;;;;;;;;;;;;;OAeG;IACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAA;IACtD;;;;;OAKG;IACH,YAAY,CAAC,EAAE,OAAO,CAAA;CACvB,CAAA"} |
+1
-1
| { | ||
| "name": "@amritk/resolve-refs", | ||
| "version": "0.1.2", | ||
| "version": "0.2.0", | ||
| "description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.", | ||
@@ -5,0 +5,0 @@ "module": "./dist/index.js", |
@@ -0,2 +1,27 @@ | ||
| import type { JsonPath } from './types' | ||
| /** | ||
| * Parses a JSON Pointer string (RFC 6901) into a path of keys: strips the leading | ||
| * `/`, splits on `/`, decodes each segment (`%XX`, then `~1` → `/`, `~0` → `~`), | ||
| * and coerces all-digit segments to numbers so array indices and object keys read | ||
| * back the same way a path consumer expects. A bare `''` or `'/'` is the empty path. | ||
| */ | ||
| export const pointerToPath = (pointer: string): JsonPath => { | ||
| if (pointer === '' || pointer === '/') return [] | ||
| return pointer | ||
| .replace(/^\//, '') | ||
| .split('/') | ||
| .map((segment) => { | ||
| let decoded = segment | ||
| try { | ||
| decoded = decodeURIComponent(segment) | ||
| } catch { | ||
| // leave invalid percent-escapes as-is | ||
| } | ||
| decoded = decoded.replace(/~1/g, '/').replace(/~0/g, '~') | ||
| return /^\d+$/.test(decoded) ? Number(decoded) : decoded | ||
| }) | ||
| } | ||
| /** | ||
| * Walks a JSON Pointer string (RFC 6901) to the value it points to within | ||
@@ -3,0 +28,0 @@ * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are |
+3
-3
@@ -1,5 +0,5 @@ | ||
| export { getByPointer } from './get-by-pointer' | ||
| export { getByPointer, pointerToPath } from './get-by-pointer' | ||
| export { isPrivateHost } from './is-private-host' | ||
| export { resolveRefs } from './resolve-refs' | ||
| export { type ResolveRefsOptions, resolveRefs } from './resolve-refs' | ||
| export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file' | ||
| export type { JsonPath, ResolveError, ResolveOptions, ResolveResult } from './types' | ||
| export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types' |
@@ -31,2 +31,27 @@ import { describe, expect, it } from 'vitest' | ||
| it('flags the full fe80::/10 link-local range, not just fe80', () => { | ||
| expect(isPrivateHost('fe9a::1')).toBe(true) | ||
| expect(isPrivateHost('feba::1')).toBe(true) | ||
| expect(isPrivateHost('febf::1')).toBe(true) | ||
| // fec0:: is outside fe80::/10 (third nibble c), so it is not link-local. | ||
| expect(isPrivateHost('fec0::1')).toBe(false) | ||
| }) | ||
| it('flags IPv4-mapped IPv6 loopback in both dotted and hex form', () => { | ||
| expect(isPrivateHost('::ffff:127.0.0.1')).toBe(true) | ||
| // The form `new URL()` produces for ::ffff:127.0.0.1. | ||
| expect(isPrivateHost('::ffff:7f00:1')).toBe(true) | ||
| // ::ffff:169.254.169.254 (cloud metadata) → hex a9fe:a9fe. | ||
| expect(isPrivateHost('::ffff:a9fe:a9fe')).toBe(true) | ||
| // A mapped public address stays public. | ||
| expect(isPrivateHost('::ffff:8.8.8.8')).toBe(false) | ||
| }) | ||
| it('flags decimal/octal/hex IPv4 encodings (defense-in-depth)', () => { | ||
| expect(isPrivateHost('2130706433')).toBe(true) // 127.0.0.1 | ||
| expect(isPrivateHost('0177.0.0.1')).toBe(true) | ||
| expect(isPrivateHost('0x7f000001')).toBe(true) | ||
| expect(isPrivateHost('127.1')).toBe(true) | ||
| }) | ||
| it('allows public hosts', () => { | ||
@@ -36,3 +61,6 @@ expect(isPrivateHost('example.com')).toBe(false) | ||
| expect(isPrivateHost('172.32.0.1')).toBe(false) | ||
| // Hostnames made only of hex letters must not be mistaken for IPs. | ||
| expect(isPrivateHost('cafe')).toBe(false) | ||
| expect(isPrivateHost('dead.beef')).toBe(false) | ||
| }) | ||
| }) |
+73
-16
@@ -9,2 +9,53 @@ /** | ||
| */ | ||
| /** True for an IPv4 address (given its first two octets) in a non-public range. */ | ||
| const isPrivateIpv4 = (a: number, b: number): boolean => { | ||
| if (a === 10 || a === 127 || a === 0) return true | ||
| if (a === 169 && b === 254) return true // link-local + cloud metadata | ||
| if (a === 172 && b >= 16 && b <= 31) return true | ||
| if (a === 192 && b === 168) return true | ||
| if (a === 100 && b >= 64 && b <= 127) return true // CGNAT | ||
| return false | ||
| } | ||
| /** Parses a single IPv4 part in decimal, octal (`0…`), or hex (`0x…`) form. */ | ||
| const parseIpv4Part = (s: string): number | null => { | ||
| if (/^0x[0-9a-f]+$/i.test(s)) return Number.parseInt(s, 16) | ||
| if (/^0[0-7]*$/.test(s)) return Number.parseInt(s, 8) | ||
| if (/^[1-9][0-9]*$/.test(s)) return Number.parseInt(s, 10) | ||
| return null | ||
| } | ||
| /** | ||
| * Resolves the first two octets of an IPv4 host written in any inet_aton form | ||
| * (dotted/decimal/octal/hex, 1–4 parts), or `null` if it is not an IPv4 literal. | ||
| * The WHATWG URL parser normalizes these to dotted-decimal before they reach us, | ||
| * but a direct caller (this is an exported guard) can pass the raw form. | ||
| */ | ||
| const ipv4Octets = (host: string): [number, number] | null => { | ||
| if (!/^[0-9a-fx.]+$/i.test(host)) return null | ||
| const parts = host.split('.') | ||
| if (parts.length === 0 || parts.length > 4) return null | ||
| const nums: number[] = [] | ||
| for (const part of parts) { | ||
| const n = parseIpv4Part(part) | ||
| if (n === null) return null | ||
| nums.push(n) | ||
| } | ||
| // inet_aton packing: every part but the last is one byte; the last fills the | ||
| // remaining low bytes (so `127.1` is 127.0.0.1 and `2130706433` is too). | ||
| let value = 0 | ||
| for (let i = 0; i < nums.length - 1; i++) { | ||
| const byte = nums[i] as number | ||
| if (byte > 0xff) return null | ||
| value = value * 256 + byte | ||
| } | ||
| const remaining = 4 - (nums.length - 1) | ||
| const last = nums[nums.length - 1] as number | ||
| if (last > 256 ** remaining - 1) return null | ||
| value = value * 256 ** remaining + last | ||
| if (value > 0xffffffff) return null | ||
| return [(value >>> 24) & 0xff, (value >>> 16) & 0xff] | ||
| } | ||
| export const isPrivateHost = (hostname: string): boolean => { | ||
@@ -14,23 +65,29 @@ const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() | ||
| // IPv4 (incl. IPv4-mapped IPv6 like ::ffff:127.0.0.1) | ||
| const v4 = host.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) | ||
| if (v4) { | ||
| const a = Number(v4[1]) | ||
| const b = Number(v4[2]) | ||
| if (a === 10 || a === 127 || a === 0) return true | ||
| if (a === 169 && b === 254) return true // link-local + metadata | ||
| if (a === 172 && b >= 16 && b <= 31) return true | ||
| if (a === 192 && b === 168) return true | ||
| if (a === 100 && b >= 64 && b <= 127) return true // CGNAT | ||
| return false | ||
| } | ||
| // IPv6 | ||
| if (host.includes(':')) { | ||
| // --- IPv6 (and IPv4-mapped IPv6) --- | ||
| if (host === '::1' || host === '::') return true | ||
| if (host.startsWith('fe80:') || host.startsWith('fe80::')) return true // link-local | ||
| if (host.startsWith('fc') || host.startsWith('fd')) return true // unique-local | ||
| // fe80::/10 link-local spans fe80–febf (third nibble 8–b). | ||
| if (/^fe[89ab][0-9a-f]:/.test(host)) return true | ||
| if (host.startsWith('fc') || host.startsWith('fd')) return true // fc00::/7 unique-local | ||
| // IPv4-mapped/compatible, dotted (`::ffff:127.0.0.1`) — the URL parser | ||
| // rewrites this to hex, but a direct caller may pass the dotted form. | ||
| const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(host) | ||
| if (dotted?.[1]) { | ||
| const oct = ipv4Octets(dotted[1]) | ||
| if (oct) return isPrivateIpv4(oct[0], oct[1]) | ||
| } | ||
| // IPv4-mapped, hex (`::ffff:7f00:1`) — what `new URL()` produces. | ||
| const hex = /:ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/.exec(host) | ||
| if (hex?.[1] && hex[2]) { | ||
| const hi = Number.parseInt(hex[1], 16) | ||
| return isPrivateIpv4((hi >> 8) & 0xff, hi & 0xff) | ||
| } | ||
| return false | ||
| } | ||
| // --- IPv4 (any inet_aton encoding) --- | ||
| const oct = ipv4Octets(host) | ||
| if (oct) return isPrivateIpv4(oct[0], oct[1]) | ||
| return false | ||
| } |
@@ -47,2 +47,61 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| it('omits the origin map unless trackOrigins is set', async () => { | ||
| writeFileSync(join(dir, 'root.json'), JSON.stringify({ a: { $ref: '#/b' }, b: { value: 1 } })) | ||
| const result = await resolveRefsFromFile(join(dir, 'root.json')) | ||
| expect(result.origins).toBeUndefined() | ||
| }) | ||
| it('stamps inlined nodes with their origin document and in-file path', async () => { | ||
| const petPath = join(dir, 'pet.json') | ||
| const apiPath = join(dir, 'api.json') | ||
| writeFileSync(petPath, JSON.stringify({ Pet: { type: 'object', properties: { name: { type: 'string' } } } })) | ||
| writeFileSync( | ||
| apiPath, | ||
| JSON.stringify({ | ||
| components: { schemas: { Pet: { $ref: './pet.json#/Pet' }, Pet2: { $ref: './pet.json#/Pet' } } }, | ||
| widget: { type: 'object' }, | ||
| useWidget: { $ref: '#/widget' }, | ||
| }), | ||
| ) | ||
| const { resolved, origins } = await resolveRefsFromFile(apiPath, { trackOrigins: true }) | ||
| expect(origins).toBeDefined() | ||
| const tree = resolved as { | ||
| components: { schemas: { Pet: object; Pet2: object } } | ||
| useWidget: object | ||
| } | ||
| // The cross-file node is stamped with pet.json and its in-file path; both call | ||
| // sites share the one inlined object, so the stamp identifies the definition. | ||
| expect(tree.components.schemas.Pet).toBe(tree.components.schemas.Pet2) | ||
| expect(origins?.get(tree.components.schemas.Pet)).toEqual({ location: petPath, pointer: ['Pet'] }) | ||
| // An internal ref is stamped against the root document at the target path. | ||
| expect(origins?.get(tree.useWidget)).toEqual({ location: apiPath, pointer: ['widget'] }) | ||
| }) | ||
| it('keeps the definition origin when a node is reached through a chained ref (first-write-wins)', async () => { | ||
| const petPath = join(dir, 'pet.json') | ||
| const apiPath = join(dir, 'api.json') | ||
| writeFileSync(petPath, JSON.stringify({ Pet: { type: 'object' } })) | ||
| writeFileSync( | ||
| apiPath, | ||
| JSON.stringify({ | ||
| components: { schemas: { Pet: { $ref: './pet.json#/Pet' } } }, | ||
| // Resolves through the internal ref to the same pet.json object. | ||
| alias: { $ref: '#/components/schemas/Pet' }, | ||
| }), | ||
| ) | ||
| const { resolved, origins } = await resolveRefsFromFile(apiPath, { trackOrigins: true }) | ||
| const tree = resolved as { components: { schemas: { Pet: object } }; alias: object } | ||
| // `alias` resolves through to the same object; its origin stays the pet.json | ||
| // definition rather than the intermediate root-document pointer. | ||
| expect(tree.alias).toBe(tree.components.schemas.Pet) | ||
| expect(origins?.get(tree.alias)).toEqual({ location: petPath, pointer: ['Pet'] }) | ||
| }) | ||
| it('records an error and degrades to {} when a referenced file is missing', async () => { | ||
@@ -126,2 +185,64 @@ writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: './missing.json#/Nope' } })) | ||
| }) | ||
| it('refuses a redirect that lands on a private host (SSRF via redirect)', async () => { | ||
| const fetchSpy = vi | ||
| .spyOn(globalThis, 'fetch') | ||
| .mockResolvedValue( | ||
| new Response(null, { status: 302, headers: { location: 'http://169.254.169.254/latest/meta-data' } }), | ||
| ) | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } })) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), { | ||
| allowedHosts: ['api.example.com'], | ||
| }) | ||
| expect((resolved as { x: unknown }).x).toBeUndefined() | ||
| expect(errors[0]?.message).toMatch(/refusing to follow redirect/i) | ||
| // The initial host was allowed, so the first request happened — but the | ||
| // redirect target was re-checked and refused before any second request. | ||
| expect(fetchSpy).toHaveBeenCalledTimes(1) | ||
| expect(fetchSpy).toHaveBeenCalledWith('https://api.example.com/s.json', { redirect: 'manual' }) | ||
| }) | ||
| it('follows a redirect to another allowed host', async () => { | ||
| const fetchSpy = vi | ||
| .spyOn(globalThis, 'fetch') | ||
| .mockResolvedValueOnce( | ||
| new Response(null, { status: 301, headers: { location: 'https://cdn.example.com/s.json' } }), | ||
| ) | ||
| .mockResolvedValueOnce(new Response(JSON.stringify({ Foo: { type: 'string' } }), { status: 200 })) | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } })) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), { | ||
| allowedHosts: ['api.example.com', 'cdn.example.com'], | ||
| }) | ||
| expect(errors).toEqual([]) | ||
| expect(resolved).toMatchObject({ x: { type: 'string' } }) | ||
| expect(fetchSpy).toHaveBeenCalledTimes(2) | ||
| }) | ||
| it('coalesces two concurrent resolves of the same remote URL into one fetch', async () => { | ||
| let resolveFetch: ((r: Response) => void) | undefined | ||
| const fetchSpy = vi.spyOn(globalThis, 'fetch').mockImplementation( | ||
| () => | ||
| new Promise<Response>((res) => { | ||
| resolveFetch = res | ||
| }), | ||
| ) | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://api.example.com/s.json#/Foo' } })) | ||
| // Two passes start before either fetch settles — they must share one request. | ||
| const pass1 = resolveRefsFromFile(join(dir, 'api.json'), { allowedHosts: ['api.example.com'] }) | ||
| const pass2 = resolveRefsFromFile(join(dir, 'api.json'), { allowedHosts: ['api.example.com'] }) | ||
| await Promise.resolve() | ||
| resolveFetch?.(new Response(JSON.stringify({ Foo: { type: 'string' } }), { status: 200 })) | ||
| const [r1, r2] = await Promise.all([pass1, pass2]) | ||
| expect(r1.errors).toEqual([]) | ||
| expect(r2.errors).toEqual([]) | ||
| expect(r1.resolved).toMatchObject({ x: { type: 'string' } }) | ||
| expect(r2.resolved).toMatchObject({ x: { type: 'string' } }) | ||
| expect(fetchSpy).toHaveBeenCalledTimes(1) | ||
| }) | ||
| }) |
| import { readFileSync } from 'node:fs' | ||
| import { dirname, resolve as resolvePath } from 'node:path' | ||
| import { getByPointer } from './get-by-pointer' | ||
| import { getByPointer, pointerToPath } from './get-by-pointer' | ||
| import { isPrivateHost } from './is-private-host' | ||
| import type { ResolveError, ResolveOptions, ResolveResult } from './types' | ||
| import type { OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types' | ||
@@ -48,2 +48,7 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| // In-flight remote loads, keyed by location. Two resolve passes that start at | ||
| // the same time and reach the same URL share a single fetch instead of racing | ||
| // two requests; whichever arrives first installs the promise, the rest await it. | ||
| const inFlight = new Map<string, Promise<unknown>>() | ||
| /** Drops every cached remote document. Mainly useful for tests/long sessions. */ | ||
@@ -54,2 +59,36 @@ export const clearRemoteCache = (): void => { | ||
| // Cap on redirect hops before we give up — generous enough for real services, | ||
| // low enough to stop a redirect loop from spinning forever. | ||
| const MAX_REDIRECTS = 5 | ||
| /** | ||
| * Fetches and parses a remote document, following redirects manually so the | ||
| * SSRF guard is re-applied to every hop. `fetch` follows redirects by default, | ||
| * which would let an allow-listed public URL bounce to a private/loopback | ||
| * address (e.g. the `169.254.169.254` metadata endpoint) — so we set | ||
| * `redirect: 'manual'` and re-run {@link denialReason} on each `Location`. | ||
| */ | ||
| const fetchRemote = async (location: string, options: ResolveOptions): Promise<unknown> => { | ||
| let current = location | ||
| for (let hop = 0; hop <= MAX_REDIRECTS; hop++) { | ||
| const reason = denialReason(current, options) | ||
| if (reason !== null) throw new Error(`refusing to follow redirect (${reason}): ${current}`) | ||
| const response = await fetch(current, { redirect: 'manual' }) | ||
| if (response.status >= 300 && response.status < 400) { | ||
| const next = response.headers.get('location') | ||
| if (!next) throw new Error(`HTTP ${response.status} redirect with no Location header`) | ||
| current = new URL(next, current).href | ||
| continue | ||
| } | ||
| if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) | ||
| const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown) | ||
| // Parse against the original request location so the caller's format sniffing | ||
| // (e.g. `.yaml` vs `.json`) and any relative refs key off a stable identity. | ||
| return parse(await response.text(), location) | ||
| } | ||
| throw new Error(`too many redirects (>${MAX_REDIRECTS}): ${location}`) | ||
| } | ||
| /** Returns why a remote location may not be fetched, or `null` if it is allowed. */ | ||
@@ -104,7 +143,12 @@ const denialReason = (location: string, options: ResolveOptions): string | null => { | ||
| } | ||
| // Coalesce concurrent loads of the same URL onto one in-flight request; the | ||
| // owner (first caller) clears the slot once it settles. | ||
| let pending = inFlight.get(location) | ||
| const owner = pending === undefined | ||
| if (pending === undefined) { | ||
| pending = fetchRemote(location, options) | ||
| inFlight.set(location, pending) | ||
| } | ||
| try { | ||
| const response = await fetch(location) | ||
| if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`) | ||
| const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown) | ||
| const doc = parse(await response.text(), location) | ||
| const doc = await pending | ||
| remoteCache.set(location, doc) | ||
@@ -117,2 +161,4 @@ docCache.set(location, doc) | ||
| return false | ||
| } finally { | ||
| if (owner) inFlight.delete(location) | ||
| } | ||
@@ -191,6 +237,7 @@ } | ||
| refCache: Map<string, CacheValue>, | ||
| origins: OriginMap | undefined, | ||
| ): unknown => { | ||
| if (node === null || typeof node !== 'object') return node | ||
| if (Array.isArray(node)) { | ||
| return node.map((item) => resolveAt(item, baseLocation, docCache, refCache)) | ||
| return node.map((item) => resolveAt(item, baseLocation, docCache, refCache, origins)) | ||
| } | ||
@@ -210,4 +257,13 @@ const obj = node as Record<string, unknown> | ||
| const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot | ||
| const resolved = resolveAt(target, targetLocation, docCache, refCache) | ||
| const resolved = resolveAt(target, targetLocation, docCache, refCache, origins) | ||
| refCache.set(cacheKey, resolved) | ||
| // Stamp the inlined node with where it was defined so a consumer can map a | ||
| // resolved-tree node back to its source document/path. Only objects/arrays are | ||
| // stamped (primitives can't key the map). First-write-wins: resolution recurses | ||
| // to the deepest `$ref` before returning, so the *definition* site stamps first; | ||
| // an outer ref that merely points (transitively) at the same object must not | ||
| // overwrite it with an intermediate location. | ||
| if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) { | ||
| origins.set(resolved, { location: targetLocation, pointer: pointerToPath(pointer) }) | ||
| } | ||
| return resolved | ||
@@ -217,3 +273,3 @@ } | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache) | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins) | ||
| } | ||
@@ -237,4 +293,5 @@ return result | ||
| await prefetch(rootLocation, docCache, options, errors) | ||
| const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map()) | ||
| return { resolved, errors } | ||
| const origins: OriginMap | undefined = options.trackOrigins ? new Map() : undefined | ||
| const resolved = resolveAt(docCache.get(rootLocation), rootLocation, docCache, new Map(), origins) | ||
| return origins ? { resolved, errors, origins } : { resolved, errors } | ||
| } |
@@ -50,2 +50,30 @@ import { describe, expect, it } from 'vitest' | ||
| }) | ||
| it('omits the origin map unless trackOrigins is set', () => { | ||
| const result = resolveRefs({ a: { $ref: '#/$defs/x' }, $defs: { x: { type: 'string' } } }) | ||
| expect(result.origins).toBeUndefined() | ||
| }) | ||
| it('stamps each inlined node with its in-document origin path', () => { | ||
| const { resolved, origins } = resolveRefs( | ||
| { | ||
| properties: { a: { $ref: '#/$defs/x' }, b: { $ref: '#/$defs/x' } }, | ||
| $defs: { x: { type: 'object', properties: { id: { type: 'string' } } } }, | ||
| }, | ||
| { trackOrigins: true }, | ||
| ) | ||
| const tree = resolved as { properties: { a: object; b: object } } | ||
| // Repeated refs share one object, stamped once with the definition path. | ||
| expect(tree.properties.a).toBe(tree.properties.b) | ||
| expect(origins?.get(tree.properties.a)).toEqual({ location: '', pointer: ['$defs', 'x'] }) | ||
| }) | ||
| it('decodes pointer escapes and array indices in origin paths', () => { | ||
| const { resolved, origins } = resolveRefs( | ||
| { ref: { $ref: '#/a~1b/c~0d/0' }, 'a/b': { 'c~d': [{ type: 'number' }] } }, | ||
| { trackOrigins: true }, | ||
| ) | ||
| const node = (resolved as { ref: object }).ref | ||
| expect(origins?.get(node)).toEqual({ location: '', pointer: ['a/b', 'c~d', 0] }) | ||
| }) | ||
| }) |
+32
-9
@@ -1,3 +0,3 @@ | ||
| import { getByPointer } from './get-by-pointer' | ||
| import type { ResolveResult } from './types' | ||
| import { getByPointer, pointerToPath } from './get-by-pointer' | ||
| import type { OriginMap, ResolveResult } from './types' | ||
@@ -9,2 +9,13 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| /** Options for the in-memory resolver. */ | ||
| export type ResolveRefsOptions = { | ||
| /** | ||
| * Record a per-node origin map on the result (`origins`). For every object or | ||
| * array inlined in place of a `$ref`, the map records the in-document path it | ||
| * was defined at (the `location` is `''`, the single in-memory document). | ||
| * Defaults to `false`. | ||
| */ | ||
| trackOrigins?: boolean | ||
| } | ||
| /** | ||
@@ -17,5 +28,10 @@ * Single-pass internal-only `$ref` resolver. Each unique ref string is resolved | ||
| */ | ||
| const resolveInternal = (node: unknown, root: unknown, cache: Map<string, CacheValue>): unknown => { | ||
| const resolveInternal = ( | ||
| node: unknown, | ||
| root: unknown, | ||
| cache: Map<string, CacheValue>, | ||
| origins: OriginMap | undefined, | ||
| ): unknown => { | ||
| if (node === null || typeof node !== 'object') return node | ||
| if (Array.isArray(node)) return node.map((item) => resolveInternal(item, root, cache)) | ||
| if (Array.isArray(node)) return node.map((item) => resolveInternal(item, root, cache, origins)) | ||
@@ -31,4 +47,10 @@ const obj = node as Record<string, unknown> | ||
| cache.set(ref, CYCLE) | ||
| const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache) | ||
| const resolved = resolveInternal(getByPointer(root, ref.slice(1)), root, cache, origins) | ||
| cache.set(ref, resolved) | ||
| // Stamp the inlined node with the path it was defined at (see resolveAt). | ||
| // First-write-wins so the deepest definition stamps before any outer ref that | ||
| // transitively points at the same object. Primitives can't key the map. | ||
| if (origins && resolved !== null && typeof resolved === 'object' && !origins.has(resolved)) { | ||
| origins.set(resolved, { location: '', pointer: pointerToPath(ref.slice(1)) }) | ||
| } | ||
| return resolved | ||
@@ -39,3 +61,3 @@ } | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveInternal(obj[key], root, cache) | ||
| result[key] = resolveInternal(obj[key], root, cache, origins) | ||
| } | ||
@@ -50,5 +72,6 @@ return result | ||
| */ | ||
| export const resolveRefs = (data: unknown): ResolveResult => { | ||
| const resolved = resolveInternal(data, data, new Map()) | ||
| return { resolved, errors: [] } | ||
| export const resolveRefs = (data: unknown, options: ResolveRefsOptions = {}): ResolveResult => { | ||
| const origins: OriginMap | undefined = options.trackOrigins ? new Map() : undefined | ||
| const resolved = resolveInternal(data, data, new Map(), origins) | ||
| return origins ? { resolved, errors: [], origins } : { resolved, errors: [] } | ||
| } |
+32
-0
@@ -13,2 +13,22 @@ /** | ||
| /** | ||
| * Where an inlined node came from: the absolute location (file path or URL, or | ||
| * `''` for the single in-memory document) of the document it was defined in, and | ||
| * the path to it within that document. | ||
| */ | ||
| export type Origin = { | ||
| location: string | ||
| pointer: JsonPath | ||
| } | ||
| /** | ||
| * Per-node origin map produced when `trackOrigins` is set: maps each object/array | ||
| * that was inlined in place of a `$ref` to where it was defined. A consumer can | ||
| * then attribute a node in the resolved tree back to its source document and path | ||
| * with a single lookup instead of re-deriving the `$ref` traversal. Keyed by node | ||
| * identity, so it relies on the resolver sharing one object per repeated `$ref` | ||
| * target (which it does). | ||
| */ | ||
| export type OriginMap = Map<object, Origin> | ||
| /** The outcome of a resolve pass: the dereferenced document plus any errors. */ | ||
@@ -19,2 +39,7 @@ export type ResolveResult = { | ||
| errors: ResolveError[] | ||
| /** | ||
| * Per-node origin map. Present only when `trackOrigins` was requested; each | ||
| * entry maps an inlined object/array to the document and path it came from. | ||
| */ | ||
| origins?: OriginMap | ||
| } | ||
@@ -58,2 +83,9 @@ | ||
| parse?: (content: string, location: string) => unknown | ||
| /** | ||
| * Record a per-node origin map on the result (`origins`). For every object or | ||
| * array inlined in place of a `$ref`, the map records the document and in-file | ||
| * path it was defined at, so a consumer can attribute resolved-tree nodes back | ||
| * to their source without re-walking the `$ref` chain. Defaults to `false`. | ||
| */ | ||
| trackOrigins?: boolean | ||
| } |
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
74984
55.16%1524
53.01%