🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@amritk/resolve-refs

Package Overview
Dependencies
Maintainers
1
Versions
12
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@amritk/resolve-refs - npm Package Compare versions

Comparing version
0.2.3
to
0.3.0
+42
dist/reference.d.ts
import type { JsonPath } from './types.js';
/**
* The reference keywords JSON Schema uses to point at another schema. `$ref`
* (all drafts) is a static pointer; `$dynamicRef` (2020-12) and `$recursiveRef`
* (2019-09) late-bind to an anchor so a recursive/extensible schema can refer to
* itself. We inline all three; the dynamic forms bind to their document-global
* anchor (see {@link resolveFragment}).
*/
export type RefKeyword = '$ref' | '$dynamicRef' | '$recursiveRef';
/** A reference carried by an object: which keyword, and its string value. */
export type Reference = {
keyword: RefKeyword;
value: string;
};
/** Returns the reference keyword `obj` carries (if any), preferring `$ref`. */
export declare const readReference: (obj: Record<string, unknown>) => Reference | undefined;
/** The resolved target of a reference: the node and the path to it within its document. */
export type ResolvedTarget = {
value: unknown;
pointer: JsonPath;
};
/**
* Resolves a reference `fragment` (the part after `#`) within `root`, per its
* `keyword`. Returns the target node and its in-document path, or `undefined`
* when nothing matches.
*
* - **JSON Pointer** (`''`, `/a/b`) — a plain pointer lookup, for any keyword.
* - **`$anchor` name** (`node`) — searches for a `$anchor`/`$dynamicAnchor` equal
* to the name. A `$dynamicRef` prefers a `$dynamicAnchor`, then falls back to a
* plain `$anchor`, so it degrades to `$ref` semantics when nothing dynamic
* matches (2020-12).
* - **`$recursiveRef`** (always `#`) — binds to the object carrying
* `$recursiveAnchor: true`, falling back to the document root when there is
* none (2019-09).
*
* Anchor search is document-global: we bind to the single matching anchor in
* `root` rather than walking the dynamic scope. For one bundled document — what a
* linter dereferences — there is exactly one anchor per name, so this agrees with
* the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not
* modelled (the common bundled case does not rely on it).
*/
export declare const resolveFragment: (root: unknown, keyword: RefKeyword, fragment: string) => ResolvedTarget | undefined;
import { getByPointer, pointerToPath } from './get-by-pointer.js';
// `$ref` is listed first so that a node carrying several reference keywords
// resolves through the static one, matching how validators treat `$ref`.
const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
/** Returns the reference keyword `obj` carries (if any), preferring `$ref`. */
export const readReference = (obj) => {
for (const keyword of REF_KEYWORDS) {
const value = obj[keyword];
if (typeof value === 'string')
return { keyword, value };
}
return undefined;
};
/** A fragment is a JSON Pointer when it is empty or begins with `/`; otherwise a plain-name anchor. */
const isPointerFragment = (fragment) => fragment === '' || fragment.startsWith('/');
/**
* Depth-first search for the first object in `root` satisfying `predicate`,
* returning it with the path to it. `seen` guards against cyclic inputs. Used to
* locate `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` targets.
*/
const search = (root, predicate) => {
const seen = new Set();
const walk = (node, pointer) => {
if (node === null || typeof node !== 'object' || seen.has(node))
return undefined;
seen.add(node);
if (!Array.isArray(node) && predicate(node))
return { value: node, pointer };
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i++) {
const found = walk(node[i], [...pointer, i]);
if (found)
return found;
}
}
else {
for (const key of Object.keys(node)) {
const found = walk(node[key], [...pointer, key]);
if (found)
return found;
}
}
return undefined;
};
return walk(root, []);
};
/**
* Resolves a reference `fragment` (the part after `#`) within `root`, per its
* `keyword`. Returns the target node and its in-document path, or `undefined`
* when nothing matches.
*
* - **JSON Pointer** (`''`, `/a/b`) — a plain pointer lookup, for any keyword.
* - **`$anchor` name** (`node`) — searches for a `$anchor`/`$dynamicAnchor` equal
* to the name. A `$dynamicRef` prefers a `$dynamicAnchor`, then falls back to a
* plain `$anchor`, so it degrades to `$ref` semantics when nothing dynamic
* matches (2020-12).
* - **`$recursiveRef`** (always `#`) — binds to the object carrying
* `$recursiveAnchor: true`, falling back to the document root when there is
* none (2019-09).
*
* Anchor search is document-global: we bind to the single matching anchor in
* `root` rather than walking the dynamic scope. For one bundled document — what a
* linter dereferences — there is exactly one anchor per name, so this agrees with
* the full dynamic-scope algorithm. Nested `$id` base-URI re-scoping is not
* modelled (the common bundled case does not rely on it).
*/
export const resolveFragment = (root, keyword, fragment) => {
if (keyword === '$recursiveRef') {
const anchored = search(root, (obj) => obj['$recursiveAnchor'] === true);
return anchored ?? { value: root, pointer: [] };
}
if (isPointerFragment(fragment)) {
const value = getByPointer(root, fragment);
return value === undefined ? undefined : { value, pointer: pointerToPath(fragment) };
}
if (keyword === '$dynamicRef') {
return (search(root, (obj) => obj['$dynamicAnchor'] === fragment) ?? search(root, (obj) => obj['$anchor'] === fragment));
}
return search(root, (obj) => obj['$anchor'] === fragment || obj['$dynamicAnchor'] === fragment);
};
+38
-23
import { readFileSync } from 'node:fs';
import { dirname, resolve as resolvePath } from 'node:path';
import { getByPointer, pointerToPath } from './get-by-pointer.js';
import { isPrivateHost } from './is-private-host.js';
import { readReference, resolveFragment } from './reference.js';
import { assignKey } from './safe-assign.js';

@@ -24,3 +24,3 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it

};
/** Splits a `$ref` into its document part and JSON-pointer part. */
/** Splits a `$ref` into its document part and its fragment (pointer or anchor name). */
const splitRef = (ref) => {

@@ -30,3 +30,3 @@ const hashIdx = ref.indexOf('#');

filePart: hashIdx === -1 ? ref : ref.slice(0, hashIdx),
pointer: hashIdx === -1 ? '' : ref.slice(hashIdx + 1),
fragment: hashIdx === -1 ? '' : ref.slice(hashIdx + 1),
};

@@ -209,11 +209,12 @@ };

const obj = node;
if (typeof obj['$ref'] === 'string') {
const { filePart } = splitRef(obj['$ref']);
const reference = readReference(obj);
if (reference) {
const { filePart } = splitRef(reference.value);
if (filePart !== '')
out.add(filePart);
}
// Recurse into every key — including a `$ref` node's siblings, which apply
// Recurse into every key — including a reference node's siblings, which apply
// alongside the referenced schema (2020-12) and may carry their own refs.
for (const key of Object.keys(obj)) {
if (key === '$ref')
if (reference && key === reference.keyword)
continue;

@@ -261,30 +262,44 @@ collectRefTargets(obj[key], out);

const obj = node;
if (typeof obj['$ref'] === 'string') {
const { filePart, pointer } = splitRef(obj['$ref']);
const reference = readReference(obj);
if (reference) {
const { keyword, value } = reference;
const { filePart, fragment } = splitRef(value);
const targetLocation = filePart === '' ? baseLocation : joinLocation(baseLocation, filePart);
const targetRoot = docCache.get(targetLocation) ?? {};
const cacheKey = `${targetLocation}#${pointer}`;
// Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can
// bind to different targets, so they must not share a cache slot.
const cacheKey = `${keyword} ${targetLocation}#${fragment}`;
let resolved;
if (refCache.has(cacheKey)) {
const cached = refCache.get(cacheKey);
resolved = cached === CYCLE ? {} : cached;
let pointer;
const cached = refCache.get(cacheKey);
if (cached === CYCLE) {
resolved = {};
pointer = [];
}
else if (cached !== undefined) {
resolved = cached.value;
pointer = cached.pointer;
}
else {
refCache.set(cacheKey, CYCLE);
const target = pointer ? getByPointer(targetRoot, pointer) : targetRoot;
resolved = resolveAt(target, targetLocation, docCache, refCache, origins);
refCache.set(cacheKey, resolved);
// `$anchor`/`$dynamicAnchor`/`$recursiveAnchor` are resolved within the
// target document; a plain pointer is a direct lookup. A fragment that
// resolves to nothing inlines as `undefined` (kept as-is for parity).
const found = resolveFragment(targetRoot, keyword, fragment);
pointer = found?.pointer ?? [];
resolved = resolveAt(found?.value, targetLocation, docCache, refCache, origins);
refCache.set(cacheKey, { value: resolved, pointer });
// 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;
// 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) });
origins.set(resolved, { location: targetLocation, pointer });
}
}
// Keywords sibling to `$ref` apply alongside the referenced schema (2020-12),
// so preserve them by combining both in an `allOf` rather than dropping them.
const siblingKeys = Object.keys(obj).filter((key) => key !== '$ref');
// Keywords sibling to a reference apply alongside the referenced schema
// (2020-12), so preserve them by combining both in an `allOf`.
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)

@@ -297,5 +312,5 @@ return resolved;

const merged = { ...siblings, allOf: [...existingAllOf, resolved] };
// Stamp the wrapper too, so origin lookups resolve for a `$ref`-with-siblings node.
// Stamp the wrapper too, so origin lookups resolve for a ref-with-siblings node.
if (origins && !origins.has(merged))
origins.set(merged, { location: targetLocation, pointer: pointerToPath(pointer) });
origins.set(merged, { location: targetLocation, pointer });
return merged;

@@ -302,0 +317,0 @@ }

@@ -1,2 +0,3 @@

import { getByPointer, pointerToPath } from './get-by-pointer.js';
import { pointerToPath } from './get-by-pointer.js';
import { readReference, resolveFragment } from './reference.js';
import { assignKey } from './safe-assign.js';

@@ -6,2 +7,4 @@ // A ref currently mid-resolution is marked with this sentinel; revisiting it

const CYCLE = Symbol('cycle');
// A ref that resolved to nothing; cached so a repeated bad ref reports once.
const MISSING = Symbol('missing');
/**

@@ -20,24 +23,41 @@ * Single-pass internal-only `$ref` resolver. Each unique ref string is resolved

const obj = node;
if (typeof obj['$ref'] === 'string') {
const ref = obj['$ref'];
const reference = readReference(obj);
if (reference) {
const { keyword, value: ref } = reference;
if (!ref.startsWith('#'))
return obj; // external ref — leave as-is
// Cache/cycle key includes the keyword: `$ref #x` and `$dynamicRef #x` can
// bind to different targets, so they must not share a cache slot.
const cacheKey = `${keyword} ${ref}`;
let target;
if (cache.has(ref)) {
const cached = cache.get(ref);
target = cached === CYCLE ? {} : cached;
let pointer;
const cached = cache.get(cacheKey);
if (cached === MISSING)
return obj;
if (cached === CYCLE) {
target = {};
pointer = [];
}
else if (cached !== undefined) {
target = cached.target;
pointer = cached.pointer;
}
else {
cache.set(ref, CYCLE);
const pointed = getByPointer(root, ref.slice(1));
if (pointed === undefined) {
// An internal pointer that resolves to nothing (a typo'd or missing
// definition) was previously inlined as literal `undefined` with no trace.
// Record it and keep the original `$ref` node so the failure is visible.
errors.push({ message: `Cannot resolve internal $ref "${ref}"`, path: pointerToPath(ref.slice(1)) });
cache.set(ref, obj);
cache.set(cacheKey, CYCLE);
const found = resolveFragment(root, keyword, ref.slice(1));
if (found === undefined) {
// A reference that resolves to nothing (a typo'd pointer or a missing
// anchor) was previously inlined as literal `undefined` with no trace.
// Record it and keep the original node so the failure is visible.
const fragment = ref.slice(1);
errors.push({
message: `Cannot resolve internal ${keyword} "${ref}"`,
path: fragment === '' || fragment.startsWith('/') ? pointerToPath(fragment) : [],
});
cache.set(cacheKey, MISSING);
return obj;
}
target = resolveInternal(pointed, root, cache, origins, errors);
cache.set(ref, target);
pointer = found.pointer;
target = resolveInternal(found.value, root, cache, origins, errors);
cache.set(cacheKey, { target, pointer });
// Stamp the inlined node with the path it was defined at (see resolveAt).

@@ -47,3 +67,3 @@ // First-write-wins so the deepest definition stamps before any outer ref that

if (origins && target !== null && typeof target === 'object' && !origins.has(target)) {
origins.set(target, { location: '', pointer: pointerToPath(ref.slice(1)) });
origins.set(target, { location: '', pointer });
}

@@ -56,3 +76,3 @@ }

// sibling-free target so each occurrence applies its own siblings.
const siblingKeys = Object.keys(obj).filter((key) => key !== '$ref');
const siblingKeys = Object.keys(obj).filter((key) => key !== keyword);
if (siblingKeys.length === 0)

@@ -68,3 +88,3 @@ return target;

if (origins && !origins.has(merged))
origins.set(merged, { location: '', pointer: pointerToPath(ref.slice(1)) });
origins.set(merged, { location: '', pointer });
return merged;

@@ -71,0 +91,0 @@ }

{
"name": "@amritk/resolve-refs",
"version": "0.2.3",
"version": "0.3.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",

@@ -8,2 +8,7 @@ # @amritk/resolve-refs

an empty object so the result is always finite.
- **Anchors + dynamic refs.** Beyond JSON-pointer `$ref`s, plain-name `$anchor`
references (`#node`), `$dynamicRef`/`$dynamicAnchor` (2020-12), and
`$recursiveRef`/`$recursiveAnchor` (2019-09) are dereferenced too. The dynamic
forms bind to their document-global anchor — the single-bundle case; nested
`$id` base-URI re-scoping is not modelled.
- **Cross-file + remote.** Relative refs resolve against the document they appear

@@ -10,0 +15,0 @@ in (a ref inside a remote doc stays remote, one inside a local file stays