@amritk/resolve-refs
Advanced tools
| 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. | ||
| * `/`, splits on `/`, decodes each segment, and coerces canonical array-index | ||
| * tokens to numbers so indices and object keys read back the way a path consumer | ||
| * expects. Only RFC 6901 array indices (`0` or a non-zero-leading run of digits) | ||
| * are coerced; a numeric *object* key with a leading zero such as `"01"` is kept | ||
| * as a string, since coercing it would alias to a different key (`obj["01"]` is | ||
| * not `obj[1]`). A bare `''` or `'/'` is the empty path. | ||
| */ | ||
@@ -16,2 +19,1 @@ export declare const pointerToPath: (pointer: string) => JsonPath; | ||
| export declare const getByPointer: (root: unknown, pointer: string) => unknown; | ||
| //# sourceMappingURL=get-by-pointer.d.ts.map |
+23
-25
| /** | ||
| * Decodes a single JSON Pointer segment (RFC 6901): percent-decodes it, then | ||
| * unescapes `~1` → `/` and `~0` → `~`. Invalid percent-escapes are left as-is. | ||
| */ | ||
| const decodeSegment = (segment) => { | ||
| let decoded = segment; | ||
| try { | ||
| decoded = decodeURIComponent(segment); | ||
| } | ||
| catch { | ||
| // leave invalid percent-escapes as-is | ||
| } | ||
| return decoded.replace(/~1/g, '/').replace(/~0/g, '~'); | ||
| }; | ||
| /** | ||
| * 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. | ||
| * `/`, splits on `/`, decodes each segment, and coerces canonical array-index | ||
| * tokens to numbers so indices and object keys read back the way a path consumer | ||
| * expects. Only RFC 6901 array indices (`0` or a non-zero-leading run of digits) | ||
| * are coerced; a numeric *object* key with a leading zero such as `"01"` is kept | ||
| * as a string, since coercing it would alias to a different key (`obj["01"]` is | ||
| * not `obj[1]`). A bare `''` or `'/'` is the empty path. | ||
| */ | ||
@@ -14,11 +31,4 @@ export const pointerToPath = (pointer) => { | ||
| .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; | ||
| const decoded = decodeSegment(segment); | ||
| return /^(0|[1-9]\d*)$/.test(decoded) ? Number(decoded) : decoded; | ||
| }); | ||
@@ -35,15 +45,3 @@ }; | ||
| return root; | ||
| const segments = pointer | ||
| .replace(/^\//, '') | ||
| .split('/') | ||
| .map((segment) => { | ||
| let decoded = segment; | ||
| try { | ||
| decoded = decodeURIComponent(segment); | ||
| } | ||
| catch { | ||
| // leave invalid percent-escapes as-is | ||
| } | ||
| return decoded.replace(/~1/g, '/').replace(/~0/g, '~'); | ||
| }); | ||
| const segments = pointer.replace(/^\//, '').split('/').map(decodeSegment); | ||
| let current = root; | ||
@@ -50,0 +48,0 @@ for (const segment of segments) { |
+0
-1
@@ -6,2 +6,1 @@ export { getByPointer, pointerToPath } from './get-by-pointer.js'; | ||
| export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -10,2 +10,1 @@ /** | ||
| export declare const isPrivateHost: (hostname: string) => boolean; | ||
| //# sourceMappingURL=is-private-host.d.ts.map |
@@ -10,2 +10,1 @@ import type { ResolveOptions, ResolveResult } from './types.js'; | ||
| export declare const resolveRefsFromFile: (filename: string, options?: ResolveOptions) => Promise<ResolveResult>; | ||
| //# sourceMappingURL=resolve-refs-from-file.d.ts.map |
@@ -18,2 +18,1 @@ import type { ResolveResult } from './types.js'; | ||
| export declare const resolveRefs: (data: unknown, options?: ResolveRefsOptions) => ResolveResult; | ||
| //# sourceMappingURL=resolve-refs.d.ts.map |
+0
-1
@@ -84,2 +84,1 @@ /** | ||
| }; | ||
| //# sourceMappingURL=types.d.ts.map |
+4
-5
| { | ||
| "name": "@amritk/resolve-refs", | ||
| "version": "0.2.0", | ||
| "version": "0.2.1", | ||
| "description": "Resolve and inline JSON Schema / OpenAPI $refs — internal, cross-file, and remote — with session caching and a default-deny SSRF guard.", | ||
@@ -29,4 +29,3 @@ "module": "./dist/index.js", | ||
| "files": [ | ||
| "dist", | ||
| "src" | ||
| "dist" | ||
| ], | ||
@@ -39,7 +38,7 @@ "publishConfig": { | ||
| "types:check": "tsgo -p . --noEmit", | ||
| "test": "NODE_ENV=production vitest run --root ../.. resolve-refs" | ||
| "test": "NODE_ENV=production vitest run --root ../.. resolve-refs", | ||
| "bench": "bun run ./bench/run.ts" | ||
| }, | ||
| "exports": { | ||
| ".": { | ||
| "development": "./src/index.ts", | ||
| "default": "./dist/index.js", | ||
@@ -46,0 +45,0 @@ "types": "./dist/index.d.ts" |
| {"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"} |
| {"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"} |
| {"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"} |
| {"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"} |
| {"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"} |
| {"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"} |
| import { describe, expect, it } from 'vitest' | ||
| import { getByPointer } from './get-by-pointer' | ||
| describe('get-by-pointer', () => { | ||
| const root = { | ||
| info: { title: 'API' }, | ||
| paths: [{ get: { id: 1 } }, { post: { id: 2 } }], | ||
| 'weird/key': true, | ||
| 'tilde~key': false, | ||
| } | ||
| it('returns the root for an empty or bare-slash pointer', () => { | ||
| expect(getByPointer(root, '')).toBe(root) | ||
| expect(getByPointer(root, '/')).toBe(root) | ||
| }) | ||
| it('navigates object keys', () => { | ||
| expect(getByPointer(root, '/info/title')).toBe('API') | ||
| }) | ||
| it('navigates array indices', () => { | ||
| expect(getByPointer(root, '/paths/1/post/id')).toBe(2) | ||
| }) | ||
| it('decodes ~1 and ~0 escapes', () => { | ||
| expect(getByPointer(root, '/weird~1key')).toBe(true) | ||
| expect(getByPointer(root, '/tilde~0key')).toBe(false) | ||
| }) | ||
| it('percent-decodes URI-encoded segments', () => { | ||
| const doc = { '{volume_id}': 'found', 'weird/key': true } | ||
| expect(getByPointer(doc, '/%7Bvolume_id%7D')).toBe('found') | ||
| // %2F in a segment must not be treated as a path separator | ||
| expect(getByPointer(doc, '/weird%2Fkey')).toBe(true) | ||
| }) | ||
| it('returns undefined when a segment is missing', () => { | ||
| expect(getByPointer(root, '/info/nope')).toBeUndefined() | ||
| expect(getByPointer(root, '/info/title/tooDeep')).toBeUndefined() | ||
| }) | ||
| }) |
| 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 | ||
| * `root`. A bare `''` or `'/'` returns the root document. Segment escapes are | ||
| * decoded (`~1` → `/`, `~0` → `~`). Returns `undefined` when any segment along | ||
| * the path is missing or traverses a non-object. | ||
| */ | ||
| export const getByPointer = (root: unknown, pointer: string): unknown => { | ||
| if (pointer === '' || pointer === '/') return root | ||
| const segments = pointer | ||
| .replace(/^\//, '') | ||
| .split('/') | ||
| .map((segment) => { | ||
| let decoded = segment | ||
| try { | ||
| decoded = decodeURIComponent(segment) | ||
| } catch { | ||
| // leave invalid percent-escapes as-is | ||
| } | ||
| return decoded.replace(/~1/g, '/').replace(/~0/g, '~') | ||
| }) | ||
| let current: unknown = root | ||
| for (const segment of segments) { | ||
| if (current === null || typeof current !== 'object') return undefined | ||
| const key = Array.isArray(current) ? Number(segment) : segment | ||
| current = (current as Record<string, unknown>)[key] | ||
| } | ||
| return current | ||
| } |
| export { getByPointer, pointerToPath } from './get-by-pointer' | ||
| export { isPrivateHost } from './is-private-host' | ||
| export { type ResolveRefsOptions, resolveRefs } from './resolve-refs' | ||
| export { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file' | ||
| export type { JsonPath, Origin, OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types' |
| import { describe, expect, it } from 'vitest' | ||
| import { isPrivateHost } from './is-private-host' | ||
| describe('is-private-host', () => { | ||
| it('treats localhost and its subdomains as private', () => { | ||
| expect(isPrivateHost('localhost')).toBe(true) | ||
| expect(isPrivateHost('api.localhost')).toBe(true) | ||
| }) | ||
| it('flags loopback and RFC 1918 / CGNAT IPv4 ranges', () => { | ||
| expect(isPrivateHost('127.0.0.1')).toBe(true) | ||
| expect(isPrivateHost('10.1.2.3')).toBe(true) | ||
| expect(isPrivateHost('172.16.0.1')).toBe(true) | ||
| expect(isPrivateHost('172.31.255.255')).toBe(true) | ||
| expect(isPrivateHost('192.168.0.1')).toBe(true) | ||
| expect(isPrivateHost('100.64.0.1')).toBe(true) | ||
| }) | ||
| it('flags the cloud-metadata link-local endpoint', () => { | ||
| expect(isPrivateHost('169.254.169.254')).toBe(true) | ||
| }) | ||
| it('flags private IPv6 (loopback, link-local, unique-local)', () => { | ||
| expect(isPrivateHost('::1')).toBe(true) | ||
| expect(isPrivateHost('[::1]')).toBe(true) | ||
| expect(isPrivateHost('fe80::1')).toBe(true) | ||
| expect(isPrivateHost('fd00::1')).toBe(true) | ||
| }) | ||
| 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', () => { | ||
| expect(isPrivateHost('example.com')).toBe(false) | ||
| expect(isPrivateHost('8.8.8.8')).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) | ||
| }) | ||
| }) |
| /** | ||
| * Best-effort check for a hostname that resolves to a non-public address — | ||
| * loopback, private (RFC 1918 / ULA), link-local (incl. the `169.254.169.254` | ||
| * cloud-metadata endpoint), or otherwise host-local. This is a syntactic guard | ||
| * on the URL only; it does not perform DNS, so a public name that resolves to a | ||
| * private IP is not caught here. It exists to stop the obvious SSRF footguns | ||
| * when resolving remote `$ref`s. | ||
| */ | ||
| /** 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 => { | ||
| const host = hostname.replace(/^\[|\]$/g, '').toLowerCase() | ||
| if (host === 'localhost' || host.endsWith('.localhost')) return true | ||
| if (host.includes(':')) { | ||
| // --- IPv6 (and IPv4-mapped IPv6) --- | ||
| if (host === '::1' || host === '::') return true | ||
| // 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 | ||
| } |
| import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' | ||
| import { tmpdir } from 'node:os' | ||
| import { join } from 'node:path' | ||
| import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' | ||
| import { clearRemoteCache, resolveRefsFromFile } from './resolve-refs-from-file' | ||
| describe('resolve-refs-from-file', () => { | ||
| let dir: string | ||
| beforeEach(() => { | ||
| dir = mkdtempSync(join(tmpdir(), 'resolve-refs-')) | ||
| clearRemoteCache() | ||
| }) | ||
| afterEach(() => { | ||
| rmSync(dir, { recursive: true, force: true }) | ||
| vi.restoreAllMocks() | ||
| }) | ||
| it('inlines a cross-file $ref between local documents', async () => { | ||
| writeFileSync( | ||
| join(dir, 'pet.json'), | ||
| JSON.stringify({ Pet: { type: 'object', properties: { name: { type: 'string' } } } }), | ||
| ) | ||
| writeFileSync( | ||
| join(dir, 'api.json'), | ||
| JSON.stringify({ components: { schemas: { Pet: { $ref: './pet.json#/Pet' } } } }), | ||
| ) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json')) | ||
| expect(errors).toEqual([]) | ||
| expect(resolved).toMatchObject({ | ||
| components: { schemas: { Pet: { type: 'object', properties: { name: { type: 'string' } } } } }, | ||
| }) | ||
| }) | ||
| it('inlines an internal $ref within a single document', async () => { | ||
| writeFileSync(join(dir, 'root.json'), JSON.stringify({ a: { $ref: '#/b' }, b: { value: 1 } })) | ||
| const { resolved } = await resolveRefsFromFile(join(dir, 'root.json')) | ||
| expect(resolved).toMatchObject({ a: { value: 1 }, b: { value: 1 } }) | ||
| }) | ||
| 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 () => { | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: './missing.json#/Nope' } })) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json')) | ||
| // The missing document degrades to {}, so the pointer into it misses and the | ||
| // ref resolves to undefined — the important part is that we recorded the | ||
| // failure instead of throwing. | ||
| expect((resolved as { x: unknown }).x).toBeUndefined() | ||
| expect(errors.length).toBeGreaterThan(0) | ||
| }) | ||
| it('refuses a remote $ref to a private host by default (SSRF guard)', async () => { | ||
| const fetchSpy = vi.spyOn(globalThis, 'fetch') | ||
| writeFileSync( | ||
| join(dir, 'api.json'), | ||
| JSON.stringify({ x: { $ref: 'http://169.254.169.254/latest/meta-data#/foo' } }), | ||
| ) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json')) | ||
| // The refused document degrades to {}, so the pointer into it misses. | ||
| expect((resolved as { x: unknown }).x).toBeUndefined() | ||
| expect(errors[0]?.message).toMatch(/Refusing to resolve remote \$ref/) | ||
| // The guard is syntactic — we never even attempt the request. | ||
| expect(fetchSpy).not.toHaveBeenCalled() | ||
| }) | ||
| it('refuses any remote $ref when remote resolution is disabled', async () => { | ||
| const fetchSpy = vi.spyOn(globalThis, 'fetch') | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ x: { $ref: 'https://example.com/s.json#/Foo' } })) | ||
| const { errors } = await resolveRefsFromFile(join(dir, 'api.json'), { remote: false }) | ||
| expect(errors[0]?.message).toMatch(/remote \$ref resolution is disabled/) | ||
| expect(fetchSpy).not.toHaveBeenCalled() | ||
| }) | ||
| it('uses a custom parse callback to load non-JSON (e.g. YAML) documents', async () => { | ||
| // Real YAML that JSON.parse would reject — a custom callback handles it. | ||
| writeFileSync(join(dir, 'contact.yaml'), 'type: object\nproperties:\n name:\n type: string\n') | ||
| writeFileSync(join(dir, 'api.json'), JSON.stringify({ contact: { $ref: './contact.yaml' } })) | ||
| const parse = (content: string, location: string): unknown => { | ||
| if (/\.ya?ml$/i.test(location)) return { type: 'object', properties: { name: { type: 'string' } } } | ||
| return JSON.parse(content) as unknown | ||
| } | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), { parse }) | ||
| expect(errors).toEqual([]) | ||
| expect(resolved).toMatchObject({ contact: { type: 'object', properties: { name: { type: 'string' } } } }) | ||
| }) | ||
| it('fetches an allow-listed remote $ref and caches it for the session', async () => { | ||
| const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue( | ||
| new Response(JSON.stringify({ Foo: { type: 'string' } }), { | ||
| status: 200, | ||
| headers: { 'content-type': 'application/json' }, | ||
| }), | ||
| ) | ||
| writeFileSync( | ||
| join(dir, 'api.json'), | ||
| JSON.stringify({ | ||
| a: { $ref: 'https://api.example.com/s.json#/Foo' }, | ||
| b: { $ref: 'https://api.example.com/s.json#/Foo' }, | ||
| }), | ||
| ) | ||
| const { resolved, errors } = await resolveRefsFromFile(join(dir, 'api.json'), { | ||
| allowedHosts: ['api.example.com'], | ||
| }) | ||
| expect(errors).toEqual([]) | ||
| expect(resolved).toMatchObject({ a: { type: 'string' }, b: { type: 'string' } }) | ||
| // Both refs hit the same document, which is fetched and cached exactly once. | ||
| expect(fetchSpy).toHaveBeenCalledTimes(1) | ||
| }) | ||
| 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, pointerToPath } from './get-by-pointer' | ||
| import { isPrivateHost } from './is-private-host' | ||
| import type { OriginMap, ResolveError, ResolveOptions, ResolveResult } from './types' | ||
| // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| // means a cycle, so we return `{}` instead of recursing forever. | ||
| const CYCLE = Symbol('cycle') | ||
| type CacheValue = unknown | typeof CYCLE | ||
| // --- Document location helpers --------------------------------------------- | ||
| // | ||
| // A "location" is the absolute identity of a document: either an absolute file | ||
| // path or an absolute http(s) URL. Refs are resolved relative to the location | ||
| // of the document they appear in, so a relative ref inside a remote document | ||
| // resolves to another remote URL, and one inside a local file to another path. | ||
| const isRemote = (location: string): boolean => /^https?:\/\//i.test(location) | ||
| /** Resolves the location of `ref` (its file/URL part) relative to `base`. */ | ||
| const joinLocation = (base: string, ref: string): string => { | ||
| if (isRemote(ref)) return ref | ||
| if (isRemote(base)) return new URL(ref, base).href | ||
| return resolvePath(dirname(base), ref) | ||
| } | ||
| /** Splits a `$ref` into its document part and JSON-pointer part. */ | ||
| const splitRef = (ref: string): { filePart: string; pointer: string } => { | ||
| const hashIdx = ref.indexOf('#') | ||
| return { | ||
| filePart: hashIdx === -1 ? ref : ref.slice(0, hashIdx), | ||
| pointer: hashIdx === -1 ? '' : ref.slice(hashIdx + 1), | ||
| } | ||
| } | ||
| // --- Remote document cache ------------------------------------------------- | ||
| // | ||
| // Fetched remote documents are cached in memory for the lifetime of the | ||
| // process ("the session"). Local files are intentionally NOT cached across | ||
| // resolve passes — they can change on disk during a long-lived session (e.g. | ||
| // an editor/LSP), so each pass re-reads them. Remote documents are assumed | ||
| // stable for the session; call `clearRemoteCache()` to drop them. | ||
| const remoteCache = new Map<string, unknown>() | ||
| // 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. */ | ||
| export const clearRemoteCache = (): void => { | ||
| remoteCache.clear() | ||
| } | ||
| // 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. */ | ||
| const denialReason = (location: string, options: ResolveOptions): string | null => { | ||
| if (options.remote === false) return 'remote $ref resolution is disabled' | ||
| let url: URL | ||
| try { | ||
| url = new URL(location) | ||
| } catch { | ||
| return 'the URL is invalid' | ||
| } | ||
| const allow = options.allowedHosts | ||
| // An explicit allow-list entry is an intentional opt-in and bypasses the | ||
| // private-host guard; otherwise refuse non-public targets by default. | ||
| if (allow && allow.length > 0) { | ||
| return allow.includes(url.host) ? null : 'host is not in the allow-list' | ||
| } | ||
| if (!options.allowPrivateHosts && isPrivateHost(url.hostname)) { | ||
| return 'host resolves to a private or loopback address (set allowPrivateHosts to permit)' | ||
| } | ||
| return null | ||
| } | ||
| /** | ||
| * Loads a document into `docCache`, fetching/reading it if needed. Remote | ||
| * documents are additionally cached for the session in `remoteCache`. On | ||
| * failure an error is recorded and the location is cached as `{}` so that | ||
| * pointer lookups degrade gracefully instead of throwing. Returns whether the | ||
| * document loaded successfully. | ||
| */ | ||
| const loadDoc = async ( | ||
| location: string, | ||
| docCache: Map<string, unknown>, | ||
| options: ResolveOptions, | ||
| errors: ResolveError[], | ||
| ): Promise<boolean> => { | ||
| if (docCache.has(location)) return true | ||
| if (isRemote(location)) { | ||
| if (remoteCache.has(location)) { | ||
| docCache.set(location, remoteCache.get(location)) | ||
| return true | ||
| } | ||
| const reason = denialReason(location, options) | ||
| if (reason !== null) { | ||
| errors.push({ message: `Refusing to resolve remote $ref (${reason}): ${location}`, path: [] }) | ||
| docCache.set(location, {}) | ||
| return false | ||
| } | ||
| // 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 doc = await pending | ||
| remoteCache.set(location, doc) | ||
| docCache.set(location, doc) | ||
| return true | ||
| } catch (err) { | ||
| errors.push({ message: `Failed to fetch ${location}: ${String(err)}`, path: [] }) | ||
| docCache.set(location, {}) | ||
| return false | ||
| } finally { | ||
| if (owner) inFlight.delete(location) | ||
| } | ||
| } | ||
| try { | ||
| const parse = options.parse ?? ((c: string) => JSON.parse(c) as unknown) | ||
| docCache.set(location, parse(readFileSync(location, 'utf8'), location)) | ||
| return true | ||
| } catch (err) { | ||
| errors.push({ message: String(err), path: [] }) | ||
| docCache.set(location, {}) | ||
| return false | ||
| } | ||
| } | ||
| /** Collects the distinct document parts of every `$ref` directly under `node`. */ | ||
| const collectRefTargets = (node: unknown, out: Set<string>): Set<string> => { | ||
| if (node === null || typeof node !== 'object') return out | ||
| if (Array.isArray(node)) { | ||
| for (const item of node) collectRefTargets(item, out) | ||
| return out | ||
| } | ||
| const obj = node as Record<string, unknown> | ||
| if (typeof obj['$ref'] === 'string') { | ||
| // A `$ref` object's siblings are ignored on resolution, so we do not recurse | ||
| // into them — mirroring resolveAt's behaviour. | ||
| const { filePart } = splitRef(obj['$ref']) | ||
| if (filePart !== '') out.add(filePart) | ||
| return out | ||
| } | ||
| for (const key of Object.keys(obj)) collectRefTargets(obj[key], out) | ||
| return out | ||
| } | ||
| /** | ||
| * Walks every reachable document starting from `rootLocation`, loading each one | ||
| * so the synchronous resolve pass can look them up. This is the only async part | ||
| * of resolution: remote documents are fetched here (in dependency order) and | ||
| * cached for the session. | ||
| */ | ||
| const prefetch = async ( | ||
| rootLocation: string, | ||
| docCache: Map<string, unknown>, | ||
| options: ResolveOptions, | ||
| errors: ResolveError[], | ||
| ): Promise<void> => { | ||
| const seen = new Set<string>([rootLocation]) | ||
| const queue: string[] = [rootLocation] | ||
| while (queue.length > 0) { | ||
| const location = queue.shift() as string | ||
| for (const filePart of collectRefTargets(docCache.get(location), new Set())) { | ||
| const target = joinLocation(location, filePart) | ||
| if (seen.has(target)) continue | ||
| seen.add(target) | ||
| await loadDoc(target, docCache, options, errors) | ||
| queue.push(target) | ||
| } | ||
| } | ||
| } | ||
| /** | ||
| * Single-pass resolver that inlines internal and external (`$ref` to other | ||
| * file/URL) references. Every reachable document has already been loaded into | ||
| * `docCache` by `prefetch`, so this stays synchronous. Refs are resolved once | ||
| * (`refCache`); the CYCLE sentinel short-circuits re-entrant resolution. | ||
| * | ||
| * `baseLocation` is the location of the document `node` belongs to, used to | ||
| * resolve relative refs and `#/...` pointers within it. | ||
| */ | ||
| const resolveAt = ( | ||
| node: unknown, | ||
| baseLocation: string, | ||
| docCache: Map<string, unknown>, | ||
| 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, origins)) | ||
| } | ||
| const obj = node as Record<string, unknown> | ||
| if (typeof obj['$ref'] === 'string') { | ||
| const { filePart, pointer } = splitRef(obj['$ref']) | ||
| const targetLocation = filePart === '' ? baseLocation : joinLocation(baseLocation, filePart) | ||
| const targetRoot = docCache.get(targetLocation) ?? {} | ||
| const cacheKey = `${targetLocation}#${pointer}` | ||
| if (refCache.has(cacheKey)) { | ||
| const cached = refCache.get(cacheKey) | ||
| return cached === CYCLE ? {} : cached | ||
| } | ||
| refCache.set(cacheKey, CYCLE) | ||
| const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot | ||
| 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 | ||
| } | ||
| const result: Record<string, unknown> = {} | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveAt(obj[key], baseLocation, docCache, refCache, origins) | ||
| } | ||
| return result | ||
| } | ||
| /** | ||
| * Resolves `$ref`s in a document on disk (or at a URL), including cross-file | ||
| * and remote refs. Remote documents are fetched on the fly and cached in memory | ||
| * for the session; `options` governs whether/which remote hosts are allowed. | ||
| */ | ||
| export const resolveRefsFromFile = async (filename: string, options: ResolveOptions = {}): Promise<ResolveResult> => { | ||
| const rootLocation = isRemote(filename) ? filename : resolvePath(filename) | ||
| const errors: ResolveError[] = [] | ||
| const docCache = new Map<string, unknown>() | ||
| if (!(await loadDoc(rootLocation, docCache, options, errors))) { | ||
| return { resolved: {}, errors } | ||
| } | ||
| await prefetch(rootLocation, docCache, options, 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 } | ||
| } |
| import { describe, expect, it } from 'vitest' | ||
| import { resolveRefs } from './resolve-refs' | ||
| describe('resolve-refs', () => { | ||
| it('inlines an internal $ref', () => { | ||
| const { resolved, errors } = resolveRefs({ | ||
| type: 'object', | ||
| properties: { contact: { $ref: '#/$defs/contact' } }, | ||
| $defs: { contact: { type: 'string' } }, | ||
| }) | ||
| expect(errors).toEqual([]) | ||
| expect(resolved).toMatchObject({ properties: { contact: { type: 'string' } } }) | ||
| }) | ||
| it('breaks a self-referential cycle with an empty object', () => { | ||
| const { resolved } = resolveRefs({ | ||
| $defs: { node: { type: 'object', properties: { next: { $ref: '#/$defs/node' } } } }, | ||
| properties: { head: { $ref: '#/$defs/node' } }, | ||
| }) | ||
| const head = (resolved as { properties: { head: { properties: { next: unknown } } } }).properties.head | ||
| // The first level resolves; the recursive self-reference terminates at `{}`. | ||
| expect(head.properties.next).toEqual({}) | ||
| }) | ||
| it('breaks a mutual cycle (A → B → A) without infinite recursion', () => { | ||
| const { resolved } = resolveRefs({ | ||
| $defs: { | ||
| a: { type: 'object', properties: { b: { $ref: '#/$defs/b' } } }, | ||
| b: { type: 'object', properties: { a: { $ref: '#/$defs/a' } } }, | ||
| }, | ||
| properties: { root: { $ref: '#/$defs/a' } }, | ||
| }) | ||
| // Resolution terminates; `a` is in the cache by the time `properties.root` is | ||
| // processed, so we get the partially-resolved shape where the back-reference | ||
| // leg terminated at `{}` rather than looping forever. | ||
| const root = (resolved as { properties: { root: { type: string; properties: { b: unknown } } } }).properties.root | ||
| expect(root.type).toBe('object') | ||
| expect(root.properties.b).toEqual({}) | ||
| }) | ||
| it('leaves external (non-#) refs untouched', () => { | ||
| const { resolved } = resolveRefs({ properties: { pet: { $ref: 'pet.json#/Pet' } } }) | ||
| expect(resolved).toEqual({ properties: { pet: { $ref: 'pet.json#/Pet' } } }) | ||
| }) | ||
| 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] }) | ||
| }) | ||
| }) |
| import { getByPointer, pointerToPath } from './get-by-pointer' | ||
| import type { OriginMap, ResolveResult } from './types' | ||
| // A ref currently mid-resolution is marked with this sentinel; revisiting it | ||
| // means we have looped, so we return `{}` instead of recursing forever. | ||
| const CYCLE = Symbol('cycle') | ||
| type CacheValue = unknown | typeof CYCLE | ||
| /** 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 | ||
| } | ||
| /** | ||
| * Single-pass internal-only `$ref` resolver. Each unique ref string is resolved | ||
| * exactly once (the `cache`); revisiting a ref that is still mid-resolution | ||
| * means a cycle, so we return `{}` (the CYCLE sentinel). Non-`#` refs (external | ||
| * files, HTTP) are left untouched — callers that need those should use | ||
| * `resolveRefsFromFile`. | ||
| */ | ||
| 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, origins)) | ||
| const obj = node as Record<string, unknown> | ||
| if (typeof obj['$ref'] === 'string') { | ||
| const ref = obj['$ref'] | ||
| if (!ref.startsWith('#')) return obj // external ref — leave as-is | ||
| if (cache.has(ref)) { | ||
| const cached = cache.get(ref) | ||
| return cached === CYCLE ? {} : cached | ||
| } | ||
| cache.set(ref, CYCLE) | ||
| 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 | ||
| } | ||
| const result: Record<string, unknown> = {} | ||
| for (const key of Object.keys(obj)) { | ||
| result[key] = resolveInternal(obj[key], root, cache, origins) | ||
| } | ||
| return result | ||
| } | ||
| /** | ||
| * Resolves all internal (`#/...`) `$ref`s in an in-memory document, inlining | ||
| * each target. External and remote refs are left as-is. Cycles are broken with | ||
| * an empty object so the result is always finite. | ||
| */ | ||
| 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: [] } | ||
| } |
-88
| /** | ||
| * A path into a JSON document: object keys and array indices, in order. Errors | ||
| * report the location of the offending `$ref` with one of these. | ||
| */ | ||
| export type JsonPath = (string | number)[] | ||
| /** A single `$ref` resolution failure (a missing file, a bad URL, a refusal). */ | ||
| export type ResolveError = { | ||
| message: string | ||
| path: JsonPath | ||
| } | ||
| /** | ||
| * 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. */ | ||
| export type ResolveResult = { | ||
| /** The dereferenced document (all resolvable `$ref`s inlined). */ | ||
| resolved: unknown | ||
| 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 | ||
| } | ||
| /** Controls how external `$ref`s to other documents are loaded. */ | ||
| export type ResolveOptions = { | ||
| /** | ||
| * Whether http(s) `$ref`s may be fetched. Defaults to `true`. | ||
| */ | ||
| remote?: boolean | ||
| /** | ||
| * If non-empty, only these hosts (e.g. `api.example.com`) may be fetched for | ||
| * remote `$ref`s. An empty/undefined list allows any host (subject to | ||
| * `remote`). An explicit entry here always bypasses the private-host guard. | ||
| */ | ||
| allowedHosts?: string[] | ||
| /** | ||
| * Allow remote `$ref`s to loopback, private, link-local, and other | ||
| * non-public addresses. Defaults to `false`: such hosts are refused as a | ||
| * best-effort SSRF guard (notably the `169.254.169.254` cloud-metadata | ||
| * endpoint). An explicit `allowedHosts` entry always bypasses this guard. | ||
| */ | ||
| allowPrivateHosts?: boolean | ||
| /** | ||
| * Custom content parser. Receives the raw text of every loaded document and | ||
| * its absolute location (file path or URL). Defaults to `JSON.parse`. | ||
| * | ||
| * Pass a YAML-aware function to support `.yaml`/`.yml` documents without | ||
| * adding a dependency to this package: | ||
| * | ||
| * ```ts | ||
| * import { parse as parseYaml } from 'yaml' | ||
| * | ||
| * resolveRefsFromFile(path, { | ||
| * parse: (content, location) => | ||
| * /\.ya?ml$/i.test(location) ? parseYaml(content) : JSON.parse(content), | ||
| * }) | ||
| * ``` | ||
| */ | ||
| 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 | ||
| } |
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1
-75%1
-50%30678
-59.09%14
-53.33%617
-59.51%