@amritk/resolve-refs
Advanced tools
@@ -70,2 +70,60 @@ /** | ||
| }; | ||
| /** | ||
| * Expands a hex IPv6 literal (no dotted-IPv4 tail — that is handled separately) | ||
| * into its eight 16-bit groups, resolving `::` zero-compression. Returns null if | ||
| * the string is not a well-formed hex IPv6 address. | ||
| */ | ||
| const expandHexIpv6 = (host) => { | ||
| const halves = host.split('::'); | ||
| if (halves.length > 2) | ||
| return null; | ||
| const toGroups = (part) => { | ||
| if (part === '') | ||
| return []; | ||
| const groups = []; | ||
| for (const g of part.split(':')) { | ||
| if (!/^[0-9a-f]{1,4}$/.test(g)) | ||
| return null; | ||
| groups.push(Number.parseInt(g, 16)); | ||
| } | ||
| return groups; | ||
| }; | ||
| const head = toGroups(halves[0]); | ||
| if (head === null) | ||
| return null; | ||
| if (halves.length === 1) | ||
| return head.length === 8 ? head : null; | ||
| const tail = toGroups(halves[1]); | ||
| if (tail === null) | ||
| return null; | ||
| const fill = 8 - head.length - tail.length; | ||
| if (fill < 0) | ||
| return null; | ||
| return [...head, ...new Array(fill).fill(0), ...tail]; | ||
| }; | ||
| /** | ||
| * When an expanded IPv6 address embeds an IPv4 address in its low 32 bits, | ||
| * returns that IPv4 as a 32-bit number; otherwise null. Covers every embedding | ||
| * the WHATWG URL parser can hand us as bare hex — the `ffff:`-only regex this | ||
| * replaced let `::7f00:1` (`::127.0.0.1`) and `::a9fe:a9fe` (`::169.254.169.254`) | ||
| * through, since those normalize away the `ffff:` marker: | ||
| * - `::/96` compatible (`::X:Y`, also covers `::`/`::1`), | ||
| * - IPv4-mapped (`::ffff:X:Y`), | ||
| * - IPv4-translated (`::ffff:0:X:Y`), | ||
| * - NAT64 (`64:ff9b::/96`). | ||
| */ | ||
| const ipv4EmbeddedInIpv6 = (g) => { | ||
| const [a, b, c, d, e, f, hi, lo] = g; | ||
| const embedded = (hi * 0x10000 + lo) >>> 0; | ||
| const zeros4 = a === 0 && b === 0 && c === 0 && d === 0; | ||
| if (zeros4 && e === 0 && f === 0) | ||
| return embedded; // ::/96 compatible | ||
| if (zeros4 && e === 0 && f === 0xffff) | ||
| return embedded; // ::ffff:X:Y mapped | ||
| if (zeros4 && e === 0xffff && f === 0) | ||
| return embedded; // ::ffff:0:X:Y translated | ||
| if (a === 0x64 && b === 0xff9b && c === 0 && d === 0 && e === 0 && f === 0) | ||
| return embedded; // 64:ff9b::/96 NAT64 | ||
| return null; | ||
| }; | ||
| export const isPrivateHost = (hostname) => { | ||
@@ -90,4 +148,4 @@ // Strip IPv6 brackets and any trailing dot(s): `localhost.` is the FQDN-root | ||
| 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. | ||
| // IPv4-mapped/compatible, dotted (`::ffff:127.0.0.1`, `::127.0.0.1`) — the URL | ||
| // parser rewrites these to hex, but a direct caller may pass the dotted form. | ||
| const dotted = /:((?:\d{1,3}\.){3}\d{1,3})$/.exec(host); | ||
@@ -99,7 +157,11 @@ if (dotted?.[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); | ||
| // Every hex IPv4-in-IPv6 embedding `new URL()` can produce (mapped, | ||
| // compatible, translated, NAT64) — via a full expansion rather than a | ||
| // single-form regex, which missed `::7f00:1` / `::a9fe:a9fe`. This also | ||
| // catches the fully-expanded loopback `0:0:0:0:0:0:0:1`. | ||
| const groups = expandHexIpv6(host); | ||
| if (groups) { | ||
| const embedded = ipv4EmbeddedInIpv6(groups); | ||
| if (embedded !== null) | ||
| return isPrivateIpv4((embedded >>> 24) & 0xff, (embedded >>> 16) & 0xff); | ||
| } | ||
@@ -106,0 +168,0 @@ return false; |
+1
-1
| { | ||
| "name": "@amritk/resolve-refs", | ||
| "version": "0.4.0", | ||
| "version": "0.4.1", | ||
| "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", |
+26
-18
@@ -100,25 +100,33 @@ # @amritk/resolve-refs | ||
| `resolveRefs` is single-pass: every unique `$ref` string is resolved exactly | ||
| once and memoized, with a sentinel that breaks cycles. The `bench/` suite isolates | ||
| what that memoization buys by pitting it against a naive inliner that re-resolves | ||
| each ref every time it is encountered — same inlined output, the cache is the only | ||
| difference. Representative numbers (Bun 1.3, Linux x64 — your hardware will | ||
| differ, run `bun run bench` yourself): | ||
| `resolveRefs` memoizes: every unique `$ref` string is resolved once per scope, | ||
| with a sentinel that breaks cycles by keeping the reference node in place. But it | ||
| is no longer *only* a memoized inliner — before resolving anything it walks the | ||
| whole document once to build a resource registry (`$id`/`$anchor` scoping), keeps | ||
| recursive cycles intact, and records a diagnostic for every external ref. The | ||
| `bench/` suite pits it against a bare naive inliner that does none of that — no | ||
| registry, no scoping, no diagnostics — and re-resolves each ref on every | ||
| encounter, so the gap is the production resolver's *total* per-call cost against | ||
| the cheapest thing that produces the same inlined shape. Both are asserted to | ||
| produce byte-identical output before either is timed. Representative numbers | ||
| (Bun 1.3, Linux x64 — your hardware will differ, run `bun run bench` yourself): | ||
| | schema | cached | naive | speedup | | ||
| |:---|---:|---:|---:| | ||
| | reuse-heavy (50 refs → 1 def) | ~35k ops/s | ~6k ops/s | **~5.8×** | | ||
| | chain (40 `$ref` → `$ref` links) | ~5k ops/s | ~0.6k ops/s | **~8.2×** | | ||
| | cyclic tree | ~122k ops/s | ~117k ops/s | ~1.05× | | ||
| | wide-distinct (60 defs, each used once) | ~3.3k ops/s | ~6.1k ops/s | ~0.54× | | ||
| | chain (40 `$ref` → `$ref` links) | ~2.6k ops/s | ~0.8k ops/s | **~3.2×** | | ||
| | reuse-heavy (50 refs → 1 def) | ~4.5k ops/s | ~8k ops/s | ~0.5× | | ||
| | cyclic tree | ~27k ops/s | ~74k ops/s | ~0.37× | | ||
| | wide-distinct (60 defs, each used once) | ~2.3k ops/s | ~7.3k ops/s | ~0.32× | | ||
| The cache earns its keep exactly where you'd expect: a `$def` referenced from | ||
| many sites, or a long indirection chain, is resolved once instead of re-walked | ||
| every time. On a document where every ref is distinct — so the cache never hits — | ||
| its bookkeeping is pure overhead and the naive walk is faster; that `wide-distinct` | ||
| row is kept in the table precisely to show the trade honestly. Real API schemas | ||
| lean heavily on shared `$def`s, which is the case the resolver is tuned for. | ||
| Memoization overtakes the naive walk only on the **chain** shape, where a long | ||
| indirection path is expensive to re-resolve and the cache collapses it to one | ||
| pass. On every other shape here the fixed cost of the up-front registry walk (paid | ||
| once per call, no matter how the refs reuse) outweighs what memoization saves, and | ||
| the bare inliner is faster — these are small schemas where that one document walk | ||
| dominates. The takeaway is practical: the resolver's per-call floor is a full | ||
| document traversal, so in a hot loop resolve a document **once** and reuse the | ||
| result rather than re-resolving. The `reuse-heavy`, `cyclic`, and `wide-distinct` | ||
| rows are kept in the table precisely to show that trade honestly rather than | ||
| cherry-picking the one shape the cache wins. | ||
| Opting into `trackOrigins` (which records where each inlined value came from) adds | ||
| roughly **5–20%** on top. Both strategies are asserted to produce byte-identical | ||
| output before either is timed. | ||
| roughly **0–20%** on top, within run-to-run noise on these small schemas. |
84888
3.86%1575
4.1%132
6.45%