diff --git a/docs/options.md b/docs/options.md index b3c9dcfb..c619d827 100644 --- a/docs/options.md +++ b/docs/options.md @@ -65,6 +65,7 @@ JSON Schema $Ref Parser comes with built-in support for HTTP and HTTPS, as well | Option(s) | Type | Description | | :---------------------------- | :------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `external` | `boolean` | Determines whether external $ref pointers will be resolved. If this option is disabled, then external $ref pointers will simply be ignored. | +| `excludedPathMatcher` | `(string, unknown?) => boolean` | A function that can exclude a path and its descendants from external reference discovery. The callback receives the root-relative JSON Pointer and the value at that path. This is useful when a document contains literal `$ref` properties that should not be downloaded. | | `file`
`http` | `object` `boolean` | These are the built-in resolvers. In addition, you can add [your own custom resolvers](plugins/resolvers.md)

To disable a resolver, just set it to `false`. | | `file.order` `http.order` | `number` | Resolvers run in a specific order, relative to other resolvers. For example, a resolver with `order: 5` will run _before_ a resolver with `order: 10`. If a resolver is unable to successfully resolve a path, then the next resolver is tried, until one succeeds or they all fail.

You can change the order in which resolvers run, which is useful if you know that most of your file references will be a certain type, or if you add [your own custom resolver](plugins/resolvers.md) that you want to run _first_. | | `file.canRead` `http.canRead` | `boolean`, `RegExp`, `string`, `array`, `function` | Determines which resolvers will be used for which files.

A regular expression can be used to match files by their full path. A string (or array of strings) can be used to match files by their file extension. Or a function can be used to perform more complex matching logic. See the [custom resolver](plugins/resolvers.md) docs for details. | @@ -73,6 +74,20 @@ JSON Schema $Ref Parser comes with built-in support for HTTP and HTTPS, as well | `http.redirects` | `number` | The maximum number of HTTP redirects to follow per file. The default is 5. To disable automatic following of redirects, set this to zero. | | `http.withCredentials` | `boolean` | Set this to `true` if you're downloading files from a CORS-enabled server that requires authentication | +### Excluded path matcher + +The `resolve`, `bundle`, and `dereference` options support an `excludedPathMatcher` callback. Each callback receives the same root-relative JSON Pointer format. The schema root is `#`, and child paths look like `#/properties/example`. Paths do not include the source filename or URL, including while crawling content loaded from an external reference. + +The optional second argument is the value at the current path. It can be used to distinguish a structural reference from a literal object that happens to contain a `$ref` property: + +```javascript +const excludedPathMatcher = (path, value) => { + return path.includes("/example/") && typeof value?.$ref === "string" && !value.$ref.startsWith("#"); +}; +``` + +Returning `true` stops that path and its descendants from being processed by that stage. Since resolution runs before bundling or dereferencing, references within a value excluded by `resolve.excludedPathMatcher` remain unresolved during the following stage for the rest of that operation. Internal references may still access properties that physically exist within the excluded value, but `$ref` properties inside it are not followed. + ## `dereference` Options The `dereference` options control how JSON Schema $Ref Parser will dereference `$ref` pointers within the JSON schema. @@ -80,7 +95,7 @@ The `dereference` options control how JSON Schema $Ref Parser will dereference ` | Option(s) | Type | Description | | :-------------------- | :--------------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `circular` | `boolean` or `"ignore"` | Determines whether [circular `$ref` pointers](README.md#circular-refs) are handled.

If set to `false`, then a `ReferenceError` will be thrown if the schema contains any circular references.

If set to `"ignore"`, then circular references will simply be ignored. No error will be thrown, but the [`$Refs.circular`](refs.md#circular) property will still be set to `true`. | -| `excludedPathMatcher` | `(string) => boolean` | A function, called for each path, which can return true to stop this path and all subpaths from being dereferenced further. This is useful in schemas where some subpaths contain literal `$ref` keys that should not be dereferenced. | +| `excludedPathMatcher` | `(string, unknown?) => boolean` | A function, called for each path, which can return true to stop this path and all subpaths from being dereferenced further. The callback receives the root-relative JSON Pointer and the value at that path. This is useful in schemas where some subpaths contain literal `$ref` keys that should not be dereferenced. | | `onCircular` | `(string) => void` | A function, called immediately after detecting a circular `$ref` with the circular `$ref` in question. | | `onDereference` | `(string, JSONSchemaObjectType, JSONSchemaObjectType, string) => void` | A function, called immediately after dereferencing, with: the resolved JSON Schema value, the `$ref` being dereferenced, the object holding the dereferenced prop, the dereferenced prop name. | | `preservedProperties` | `string[]` | An array of properties to preserve when dereferencing a `$ref` schema. Useful if you want to enforce non-standard dereferencing behavior like present in the OpenAPI 3.1 specification where `description` and `summary` properties are preserved when alongside a `$ref` pointer. | diff --git a/lib/bundle.ts b/lib/bundle.ts index be47366d..6e17274c 100644 --- a/lib/bundle.ts +++ b/lib/bundle.ts @@ -2,6 +2,7 @@ import $Ref from "./ref.js"; import Pointer from "./pointer.js"; import * as url from "./util/url.js"; import { getSchemaBasePath, getSchemaId, getSchemaIdMode } from "./util/schema-resources.js"; +import { wasExcludedDuringResolution } from "./util/resolution-exclusions.js"; import type $Refs from "./refs.js"; import type $RefParser from "./index.js"; import type { ParserOptions } from "./index.js"; @@ -110,7 +111,14 @@ function crawl = Parse const bundleOptions = (options.bundle || {}) as BundleOptions; const isExcludedPath = bundleOptions.excludedPathMatcher || (() => false); - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot) && !seen.has(obj)) { + if ( + obj && + typeof obj === "object" && + !ArrayBuffer.isView(obj) && + !wasExcludedDuringResolution($refs, obj) && + !isExcludedPath(pathFromRoot, obj) && + !seen.has(obj) + ) { // Input schemas are normally JSON trees, but callers can pass pre-circular // JavaScript objects. Tracking identities keeps those cycles intact without // recursively walking them until the call stack overflows. It also avoids @@ -155,7 +163,11 @@ function crawl = Parse for (const key of keys) { const keyPath = Pointer.join(path, key); const keyPathFromRoot = Pointer.join(pathFromRoot, key); + const value = obj[key]; + if (wasExcludedDuringResolution($refs, value) || isExcludedPath(keyPathFromRoot, value)) { + continue; + } const childLegacyIdScope = getSchemaIdMode(value, legacyIdScope); const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value) @@ -248,10 +260,24 @@ function inventory$Ref const shouldResolveOnCwd = $Ref.isExternal$Ref($ref) && options.dereference?.externalReferenceResolution === "root"; const resolutionBase = shouldResolveOnCwd ? url.cwd() : dynamicIdScope ? scopeBase : path; const $refPath = url.resolve(resolutionBase, $ref.$ref); - const pointer = $refs._resolve($refPath, pathFromRoot, options); - if (pointer === null) { + + // Walk values skipped during resolution as literal data. This lets internal pointers reach + // properties that physically exist without resolving nested references in the skipped subtree. + let pointer = $refs._resolve($refPath, pathFromRoot, options, undefined, { + shouldSkipReferenceResolution: (value) => wasExcludedDuringResolution($refs, value), + resolveFinalReference: false, + }); + if (pointer === null || pointer.referenceResolutionBlocked) { return; } + + if (!pointer.crossedResolutionExclusion) { + pointer = $refs._resolve($refPath, pathFromRoot, options); + if (pointer === null) { + return; + } + } + const parsed = Pointer.parse(pathFromRoot); const depth = parsed.length; const file = url.stripHash(pointer.path); diff --git a/lib/dereference.ts b/lib/dereference.ts index fb651f4b..3b935af9 100644 --- a/lib/dereference.ts +++ b/lib/dereference.ts @@ -2,6 +2,7 @@ import $Ref from "./ref.js"; import Pointer from "./pointer.js"; import * as url from "./util/url.js"; import { getSchemaBasePath, getSchemaIdMode } from "./util/schema-resources.js"; +import { wasExcludedDuringResolution } from "./util/resolution-exclusions.js"; import type $Refs from "./refs.js"; import type { DereferenceOptions, ParserOptions } from "./options.js"; import { type $RefParser, type JSONSchema } from "./index.js"; @@ -94,7 +95,13 @@ function crawl = Parse const isExcludedPath = derefOptions.excludedPathMatcher || (() => false); if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) { - if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) { + if ( + obj && + typeof obj === "object" && + !ArrayBuffer.isView(obj) && + !wasExcludedDuringResolution($refs, obj) && + !isExcludedPath(pathFromRoot, obj) + ) { parents.add(obj); processedObjects.add(obj); const currentScopeBase = scopeBase; @@ -123,11 +130,10 @@ function crawl = Parse const keyPath = Pointer.join(path, key); const keyPathFromRoot = Pointer.join(pathFromRoot, key); - if (isExcludedPath(keyPathFromRoot)) { + const value = obj[key]; + if (wasExcludedDuringResolution($refs, value) || isExcludedPath(keyPathFromRoot, value)) { continue; } - - const value = obj[key]; const childLegacyIdScope = getSchemaIdMode(value, legacyIdScope); const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value) @@ -300,7 +306,12 @@ function dereference$Ref wasExcludedDuringResolution($refs, value), + resolveFinalReference: false, + }); if (pointer === null) { return { @@ -309,6 +320,24 @@ function dereference$Ref = T extends object - ? { - [P in keyof T]?: DeepPartial; - } - : T; +export type DeepPartial = T extends (...args: any[]) => unknown + ? T + : T extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; export interface BundleOptions { /** * A function, called for each path, which can return true to stop this path and all * subpaths from being processed further. This is useful in schemas where some - * subpaths contain literal $ref keys that should not be changed. + * subpaths contain literal $ref keys that should not be changed. The value at the + * current path is supplied so callers can distinguish references from containers. + * + * @param path - The root-relative JSON Pointer of the current value + * @param value - The value at the current path */ - excludedPathMatcher?(path: string): boolean; + excludedPathMatcher?(path: string, value?: unknown): boolean; /** * Callback invoked during bundling. @@ -54,9 +60,13 @@ export interface DereferenceOptions { /** * A function, called for each path, which can return true to stop this path and all * subpaths from being dereferenced further. This is useful in schemas where some - * subpaths contain literal $ref keys that should not be dereferenced. + * subpaths contain literal $ref keys that should not be dereferenced. The value at + * the current path is supplied so callers can distinguish references from containers. + * + * @param path - The root-relative JSON Pointer of the current value + * @param value - The value at the current path */ - excludedPathMatcher?(path: string): boolean; + excludedPathMatcher?(path: string, value?: unknown): boolean; /** * Callback invoked during circular reference detection. @@ -125,6 +135,36 @@ export interface DereferenceOptions { cloneReferences?: boolean; } +export type ResolveOptions = { + /** + * Determines whether external $ref pointers will be resolved. If this option is disabled, then external `$ref` pointers will simply be ignored. + */ + external?: boolean; + + /** + * A function, called for each path, which can return true to stop this path and all + * subpaths from being resolved further. This is useful in schemas where some subpaths + * contain literal external $ref keys that should not be downloaded. The value at the + * current path is supplied so callers can distinguish references from containers. + * References within values excluded during resolution remain unresolved during a following + * bundle or dereference stage, including when reached through an internal $ref. + * + * @param path - The root-relative JSON Pointer of the current value + * @param value - The value at the current path + */ + excludedPathMatcher?(path: string, value?: unknown): boolean; + + file?: Partial> | boolean; + http?: HTTPResolverOptions | boolean; +} & { + [key: string]: + | Partial> + | HTTPResolverOptions + | boolean + | ((path: string, value?: unknown) => boolean) + | undefined; +}; + /** * Options that determine how JSON schemas are parsed, resolved, and dereferenced. * @@ -150,16 +190,7 @@ export interface $RefParserOptions { * * JSON Schema `$Ref` Parser comes with built-in support for HTTP and HTTPS, as well as support for local files (when running in Node.js). You can configure or disable either of these built-in resolvers. You can also add your own custom resolvers if you want. */ - resolve: { - /** - * Determines whether external $ref pointers will be resolved. If this option is disabled, then external `$ref` pointers will simply be ignored. - */ - external?: boolean; - file?: Partial> | boolean; - http?: HTTPResolverOptions | boolean; - } & { - [key: string]: Partial> | HTTPResolverOptions | boolean | undefined; - }; + resolve: ResolveOptions; /** * By default, JSON Schema $Ref Parser throws the first error it encounters. Setting `continueOnError` to `true` diff --git a/lib/pointer.ts b/lib/pointer.ts index 516946e4..0e5fe131 100644 --- a/lib/pointer.ts +++ b/lib/pointer.ts @@ -15,6 +15,13 @@ const escapedSlash = /~1/g; const escapedTilde = /~0/g; const unsafeSetTokens = new Set(["__proto__", "constructor", "prototype"]); +export interface PointerResolutionOptions { + /** Whether to follow a `$ref` at the resolved value. */ + resolveFinalReference?: boolean; + /** Returns whether references within a value should remain unresolved. */ + shouldSkipReferenceResolution?: (value: unknown) => boolean; +} + /** * This class represents a single JSON pointer and its resolved value. * @@ -68,6 +75,12 @@ class Pointer = Parser */ indirections: number; + /** Whether pointer traversal crossed into a value that was skipped during resolution. */ + crossedResolutionExclusion: boolean; + + /** Whether the target could only be reached by following a reference whose resolution was skipped. */ + referenceResolutionBlocked: boolean; + constructor($ref: $Ref, path: string, friendlyPath?: string) { this.$ref = $ref; @@ -86,6 +99,10 @@ class Pointer = Parser this.chainCircular = false; this.indirections = 0; + + this.crossedResolutionExclusion = false; + + this.referenceResolutionBlocked = false; } /** @@ -106,8 +123,9 @@ class Pointer = Parser options?: O, pathFromRoot?: string, visitedRefPaths = new Set(), - resolveFinalReference = true, + resolutionOptions: PointerResolutionOptions = {}, ) { + const { resolveFinalReference = true, shouldSkipReferenceResolution = () => false } = resolutionOptions; const tokens = Pointer.parse(this.path, this.originalPath); const found: string[] = []; @@ -117,6 +135,7 @@ class Pointer = Parser this.legacyIdScope = getSchemaIdMode(this.value, this.legacyIdScope); this.scopeBase = getSchemaBasePath(this.scopeBase, this.value, this.legacyIdScope); } + this.crossedResolutionExclusion = shouldSkipReferenceResolution(this.value); for (let i = 0; i < tokens.length; i++) { // During token walking, if the current value is an extended $ref (has sibling keys @@ -128,15 +147,22 @@ class Pointer = Parser const wasCircular = this.circular; const wasChainCircular = this.chainCircular; const isExtendedRef = $Ref.isExtended$Ref(this.value); - if (resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths)) { - // The $ref path has changed, so append the remaining tokens to the path - this.path = Pointer.join(this.path, tokens.slice(i)); - } else if (isExtendedRef) { - // resolveIf$Ref set circular=true on an extended $ref during token walking. - // Since we still have tokens to process, the object should be walked by its - // properties, not treated as a circular self-reference. - this.circular = wasCircular; - this.chainCircular = wasChainCircular; + if (!this.crossedResolutionExclusion) { + if (resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths, resolutionOptions)) { + // The $ref path has changed, so append the remaining tokens to the path + this.path = Pointer.join(this.path, tokens.slice(i)); + } else if (isExtendedRef) { + // resolveIf$Ref set circular=true on an extended $ref during token walking. + // Since we still have tokens to process, the object should be walked by its + // properties, not treated as a circular self-reference. + this.circular = wasCircular; + this.chainCircular = wasChainCircular; + } + + if (this.referenceResolutionBlocked) { + return this; + } + this.crossedResolutionExclusion ||= shouldSkipReferenceResolution(this.value); } const token = tokens[i]; @@ -166,6 +192,7 @@ class Pointer = Parser } if (didFindSubstringSlashMatch) { this.chainCircular = wasChainCircular; + this.crossedResolutionExclusion ||= shouldSkipReferenceResolution(this.value); continue; } @@ -180,6 +207,11 @@ class Pointer = Parser continue; } + if (this.crossedResolutionExclusion && $Ref.isAllowed$Ref(this.value, options)) { + this.referenceResolutionBlocked = true; + return this; + } + this.value = null; const path = this.$ref.path || ""; @@ -199,11 +231,12 @@ class Pointer = Parser this.legacyIdScope = getSchemaIdMode(this.value, this.legacyIdScope); this.scopeBase = getSchemaBasePath(this.scopeBase, this.value, this.legacyIdScope); } + this.crossedResolutionExclusion ||= shouldSkipReferenceResolution(this.value); } // Resolve the final value const finalResolutionBase = this.$ref.dynamicIdScope ? this.scopeBase : this.path; - if (resolveFinalReference) { + if (resolveFinalReference && !this.crossedResolutionExclusion) { const finalRefPath = this.value?.$ref ? url.resolve(finalResolutionBase, this.value.$ref) : undefined; const canonicalPathFromRoot = typeof pathFromRoot === "string" ? url.resolve(this.$ref.$refs._root$Ref.path!, pathFromRoot) : pathFromRoot; @@ -215,7 +248,7 @@ class Pointer = Parser ) { this.chainCircular = true; } else if (!this.value || finalRefPath) { - resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths); + resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths, resolutionOptions); } } @@ -366,6 +399,7 @@ function resolveIf$Ref options: O | undefined, pathFromRoot?: string, visitedRefPaths = new Set(), + resolutionOptions: PointerResolutionOptions = {}, ) { let pathChanged = false; let currentPathFromRoot = pathFromRoot; @@ -375,6 +409,11 @@ function resolveIf$Ref // Pure reference chains can be followed iteratively. Extended refs still use // the existing one-hop behavior because their values must be merged on return. while ($Ref.isAllowed$Ref(pointer.value, options)) { + if (resolutionOptions.shouldSkipReferenceResolution?.(pointer.value)) { + pointer.crossedResolutionExclusion = true; + return pathChanged; + } + const extended = $Ref.isExtended$Ref(pointer.value); const sourceValue = pointer.value; const parentPath = pointer.path; @@ -414,11 +453,20 @@ function resolveIf$Ref visitedRefPaths.add($refPath); addedPaths.push($refPath); - const resolved = pointer.$ref.$refs._resolve($refPath, parentPath, options, visitedRefPaths, extended); + const resolved = pointer.$ref.$refs._resolve($refPath, parentPath, options, visitedRefPaths, { + ...resolutionOptions, + resolveFinalReference: extended, + }); if (resolved === null) { return pathChanged; } + pointer.crossedResolutionExclusion ||= resolved.crossedResolutionExclusion; + pointer.referenceResolutionBlocked ||= resolved.referenceResolutionBlocked; + if (pointer.referenceResolutionBlocked) { + return pathChanged; + } + pointer.indirections += resolved.indirections + 1; pointer.chainCircular ||= resolved.circular || resolved.chainCircular; @@ -443,6 +491,10 @@ function resolveIf$Ref currentPathFromRoot = parentPath; pathChanged = true; } + + if (pointer.crossedResolutionExclusion) { + return pathChanged; + } } return pathChanged; diff --git a/lib/ref.ts b/lib/ref.ts index cb07b617..eef0d2d0 100644 --- a/lib/ref.ts +++ b/lib/ref.ts @@ -1,4 +1,4 @@ -import Pointer, { nullSymbol } from "./pointer.js"; +import Pointer, { nullSymbol, type PointerResolutionOptions } from "./pointer.js"; import type { JSONParserError, MissingPointerError, ParserError, ResolverError } from "./util/errors.js"; import { InvalidPointerError, isHandledError, normalizeError } from "./util/errors.js"; import { safePointerToPath, stripHash, getHash } from "./util/url.js"; @@ -124,7 +124,7 @@ class $Ref = ParserOpt * @param friendlyPath - The original user-specified path (used for error messages) * @param pathFromRoot - The path of `obj` from the schema root * @param visitedRefPaths - the active paths in the current reference chain - * @param resolveFinalReference - whether to follow a `$ref` at the resolved value + * @param resolutionOptions - internal controls for pointer traversal * @returns */ resolve( @@ -133,11 +133,11 @@ class $Ref = ParserOpt friendlyPath?: string, pathFromRoot?: string, visitedRefPaths?: Set, - resolveFinalReference = true, + resolutionOptions?: PointerResolutionOptions, ) { const pointer = new Pointer(this, path, friendlyPath); try { - const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolveFinalReference); + const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolutionOptions); if (resolved.value === nullSymbol) { resolved.value = null; } diff --git a/lib/refs.ts b/lib/refs.ts index 3f9e77b5..5c2faabf 100644 --- a/lib/refs.ts +++ b/lib/refs.ts @@ -2,6 +2,7 @@ import $Ref from "./ref.js"; import * as url from "./util/url.js"; import type { JSONSchema4Type, JSONSchema6Type, JSONSchema7Type } from "json-schema"; import type { ParserOptions } from "./options.js"; +import type { PointerResolutionOptions } from "./pointer.js"; import convertPathToPosix from "./util/convert-path-to-posix.js"; import type { JSONSchema } from "./index.js"; @@ -156,7 +157,7 @@ export default class $Refs, - resolveFinalReference = true, + resolutionOptions?: PointerResolutionOptions, ) { const absPath = url.resolve(this._root$Ref.path!, path); const $ref = this._getRef(absPath); @@ -174,7 +175,7 @@ export default class $Refs, options: O, ) { + resetResolutionExclusions(parser.$refs); + if (!options.resolve?.external) { // Nothing to resolve, so exit early return Promise.resolve(); @@ -36,6 +39,7 @@ function resolveExternal = ParserOptions>( obj: string | boolean | Buffer | S | undefined | null, path: string, + pathFromRoot: string, scopeBase: string, dynamicIdScope: boolean, legacyIdScope: boolean, @@ -77,25 +83,50 @@ function crawl = Parse ) { seen ||= new Set(); let promises: any = []; + const resolveOptions = (options.resolve || {}) as ResolveOptions; + const isExcludedPath = resolveOptions.excludedPathMatcher || (() => false); if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !seen.has(obj)) { + if (isExcludedPath(pathFromRoot, obj)) { + markValueExcludedDuringResolution($refs, obj); + return promises; + } + seen.add(obj); // Track previously seen objects to avoid infinite recursion const currentScopeBase = scopeBase; if ($Ref.isExternal$Ref(obj)) { - promises.push(resolve$Ref(obj, path, currentScopeBase, dynamicIdScope, $refs, options)); + promises.push(resolve$Ref(obj, path, pathFromRoot, currentScopeBase, dynamicIdScope, $refs, options)); } const keys = Object.keys(obj) as string[]; for (const key of keys) { const keyPath = Pointer.join(path, key); + const keyPathFromRoot = Pointer.join(pathFromRoot, key); + const value = obj[key as keyof typeof obj] as string | S | Buffer | undefined; + if (isExcludedPath(keyPathFromRoot, value)) { + markValueExcludedDuringResolution($refs, value); + continue; + } + const childLegacyIdScope = getSchemaIdMode(value, legacyIdScope); const childScopeBase = dynamicIdScope && value && typeof value === "object" && !ArrayBuffer.isView(value) ? getSchemaBasePath(currentScopeBase, value, childLegacyIdScope) : currentScopeBase; promises = promises.concat( - crawl(value, keyPath, childScopeBase, dynamicIdScope, childLegacyIdScope, $refs, options, seen, external), + crawl( + value, + keyPath, + keyPathFromRoot, + childScopeBase, + dynamicIdScope, + childLegacyIdScope, + $refs, + options, + seen, + external, + ), ); } } @@ -108,6 +139,7 @@ function crawl = Parse * * @param $ref - The JSON Reference to resolve * @param path - The full path of `$ref`, possibly with a JSON Pointer in the hash + * @param pathFromRoot - The logical JSON Pointer path of `$ref` from the schema root * @param $refs * @param options * @@ -118,6 +150,7 @@ function crawl = Parse async function resolve$Ref = ParserOptions>( $ref: S, path: string, + pathFromRoot: string, scopeBase: string, dynamicIdScope: boolean, $refs: $Refs, @@ -160,6 +193,7 @@ async function resolve$Ref>(); + +/** Clears values skipped during resolution in the previous operation for this $Refs instance. */ +export function resetResolutionExclusions($refs: $Refs) { + valuesExcludedDuringResolutionByRefs.delete($refs); +} + +/** Records a value and every crawlable descendant as skipped during resolution. */ +export function markValueExcludedDuringResolution($refs: $Refs, value: unknown) { + if (!isCrawlableObject(value)) { + return; + } + + let valuesExcludedDuringResolution = valuesExcludedDuringResolutionByRefs.get($refs); + if (!valuesExcludedDuringResolution) { + valuesExcludedDuringResolution = new WeakSet(); + valuesExcludedDuringResolutionByRefs.set($refs, valuesExcludedDuringResolution); + } + + const valuesToRecord: object[] = [value]; + while (valuesToRecord.length > 0) { + const currentValue = valuesToRecord.pop()!; + if (valuesExcludedDuringResolution.has(currentValue)) { + continue; + } + + valuesExcludedDuringResolution.add(currentValue); + for (const childValue of Object.values(currentValue)) { + if (isCrawlableObject(childValue)) { + valuesToRecord.push(childValue); + } + } + } +} + +/** Returns whether a value was skipped during resolution for the current operation. */ +export function wasExcludedDuringResolution($refs: $Refs, value: unknown) { + return isCrawlableObject(value) && Boolean(valuesExcludedDuringResolutionByRefs.get($refs)?.has(value)); +} + +function isCrawlableObject(value: unknown): value is object { + return value !== null && typeof value === "object" && !ArrayBuffer.isView(value); +} diff --git a/package.json b/package.json index 5384e43e..bf757b83 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "prepublishOnly": "pnpm build", "lint": "oxlint lib", "build": "rimraf dist && tsc", - "typecheck": "tsc --noEmit", + "typecheck": "tsc --noEmit && tsc --noEmit -p test/types/tsconfig.json", "format": "oxfmt \"**/*.{js,jsx,ts,tsx,har,json,css,md}\"", "test": "vitest --coverage", "test:specific": "vitest invalid", diff --git a/test/specs/ref-in-excluded-path/dereferenced.ts b/test/specs/ref-in-excluded-path/dereferenced.ts index c9bfccab..0727b323 100644 --- a/test/specs/ref-in-excluded-path/dereferenced.ts +++ b/test/specs/ref-in-excluded-path/dereferenced.ts @@ -18,7 +18,7 @@ export default { parameters: { a: { example: { - $ref: "#/literal-param-component-example", + $ref: "./literal-param-component-example-does-not-exist.yaml", }, }, b: { @@ -53,7 +53,7 @@ export default { }, { example: { - $ref: "#/literal-q1", + $ref: "./literal-q1-does-not-exist.yaml", }, in: "query", name: "q1", @@ -97,7 +97,7 @@ export default { content: { "application/json": { example: { - $ref: "#/literal-example", + $ref: "https://example.com/literal-example-that-should-not-be-downloaded.json", }, }, }, diff --git a/test/specs/ref-in-excluded-path/matcher-paths/exact.yaml b/test/specs/ref-in-excluded-path/matcher-paths/exact.yaml new file mode 100644 index 00000000..7fc5b10b --- /dev/null +++ b/test/specs/ref-in-excluded-path/matcher-paths/exact.yaml @@ -0,0 +1,2 @@ +example: + $ref: "./does-not-exist.yaml" diff --git a/test/specs/ref-in-excluded-path/matcher-paths/example/schema.yaml b/test/specs/ref-in-excluded-path/matcher-paths/example/schema.yaml new file mode 100644 index 00000000..1979a331 --- /dev/null +++ b/test/specs/ref-in-excluded-path/matcher-paths/example/schema.yaml @@ -0,0 +1,2 @@ +property: + $ref: "../resolved.yaml" diff --git a/test/specs/ref-in-excluded-path/matcher-paths/external-root.yaml b/test/specs/ref-in-excluded-path/matcher-paths/external-root.yaml new file mode 100644 index 00000000..ce1ef3df --- /dev/null +++ b/test/specs/ref-in-excluded-path/matcher-paths/external-root.yaml @@ -0,0 +1,2 @@ +wrapper: + $ref: "./external.yaml" diff --git a/test/specs/ref-in-excluded-path/matcher-paths/external.yaml b/test/specs/ref-in-excluded-path/matcher-paths/external.yaml new file mode 100644 index 00000000..7fc5b10b --- /dev/null +++ b/test/specs/ref-in-excluded-path/matcher-paths/external.yaml @@ -0,0 +1,2 @@ +example: + $ref: "./does-not-exist.yaml" diff --git a/test/specs/ref-in-excluded-path/matcher-paths/resolved.yaml b/test/specs/ref-in-excluded-path/matcher-paths/resolved.yaml new file mode 100644 index 00000000..5c21d88b --- /dev/null +++ b/test/specs/ref-in-excluded-path/matcher-paths/resolved.yaml @@ -0,0 +1 @@ +type: string diff --git a/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts b/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts index 1ba430de..f661588c 100644 --- a/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts +++ b/test/specs/ref-in-excluded-path/ref-in-excluded-path.spec.ts @@ -1,22 +1,311 @@ import { describe, it } from "vitest"; -import $RefParser from "../../../lib/index.js"; +import $RefParser, { MissingPointerError } from "../../../lib/index.js"; import path from "../../utils/path.js"; import dereferencedSchema from "./dereferenced.js"; import { expect } from "vitest"; describe("Schema with literal $refs in examples", () => { - it("should exclude the given paths from dereferencing", async () => { + const excludedPathMatcher = (schemaPath: string) => { + return /\/example(\/|$|s\/[^/]+\/value(\/|$))/.test(schemaPath); + }; + + it("should exclude the given paths from resolving and dereferencing", async () => { const parser = new $RefParser(); const schema = await parser.dereference(path.rel("test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml"), { + resolve: { + excludedPathMatcher, + }, dereference: { - excludedPathMatcher: (schemaPath: any) => { - return /\/example(\/|$|s\/[^/]+\/value(\/|$))/.test(schemaPath); - }, + excludedPathMatcher, }, }); expect(schema).to.equal(parser.schema); expect(schema).to.deep.equal(dereferencedSchema); }); + + it("should exclude the given paths from resolving and bundling", async () => { + const parser = new $RefParser(); + const schemaPath = path.rel("test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml"); + const parsedSchema = await $RefParser.parse(schemaPath); + + const schema = await parser.bundle(schemaPath, { + resolve: { + excludedPathMatcher, + }, + bundle: { + excludedPathMatcher, + }, + }); + + expect(schema).to.equal(parser.schema); + expect(schema).to.deep.equal(parsedSchema); + }); + + it("should supply the path value so callers can distinguish references", async () => { + const matcher = (schemaPath: string, value?: unknown) => { + return ( + schemaPath.includes("/example/") && + typeof value === "object" && + value !== null && + "$ref" in value && + typeof value.$ref === "string" && + !value.$ref.startsWith("#") + ); + }; + const inputSchema = { + definitions: { + user: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + }, + example: { + internal: { $ref: "#/definitions/user" }, + manager: { + $ref: "https://gateway.example.com/scim/v2/Users/789012", + value: "789012", + displayName: "Jane Manager", + }, + }, + }; + const expectedSchema = { + definitions: { + user: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + }, + example: { + internal: { + type: "object", + properties: { + id: { type: "string" }, + }, + }, + manager: { + $ref: "https://gateway.example.com/scim/v2/Users/789012", + value: "789012", + displayName: "Jane Manager", + }, + }, + }; + + const schema = await $RefParser.dereference(inputSchema, { + resolve: { excludedPathMatcher: matcher }, + dereference: { excludedPathMatcher: matcher }, + }); + + expect(schema).to.deep.equal(expectedSchema); + }); + + it("should supply root-relative paths while resolving", async () => { + const matcher = (schemaPath: string) => schemaPath === "#/example"; + const schema = await $RefParser.dereference(path.rel("test/specs/ref-in-excluded-path/matcher-paths/exact.yaml"), { + resolve: { excludedPathMatcher: matcher }, + dereference: { excludedPathMatcher: matcher }, + }); + + expect(schema).to.deep.equal({ + example: { $ref: "./does-not-exist.yaml" }, + }); + }); + + it("should not include the source file path in matcher paths", async () => { + const matcher = (schemaPath: string) => schemaPath.includes("/example/"); + const schema = await $RefParser.dereference( + path.rel("test/specs/ref-in-excluded-path/matcher-paths/example/schema.yaml"), + { + resolve: { excludedPathMatcher: matcher }, + }, + ); + + expect(schema).to.deep.equal({ + property: { type: "string" }, + }); + }); + + it("should retain the logical path while crawling an external document", async () => { + const matcher = (schemaPath: string) => schemaPath === "#/wrapper/example"; + const schema = await $RefParser.dereference( + path.rel("test/specs/ref-in-excluded-path/matcher-paths/external-root.yaml"), + { + resolve: { excludedPathMatcher: matcher }, + dereference: { excludedPathMatcher: matcher }, + }, + ); + + expect(schema).to.deep.equal({ + wrapper: { + example: { $ref: "./does-not-exist.yaml" }, + }, + }); + }); + + it("should not follow excluded references reached through internal refs while dereferencing", async () => { + const inputSchema = { + embedded: { + child: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + }, + use: { $ref: "#/embedded" }, + useChild: { $ref: "#/embedded/child" }, + useLiteral: { $ref: "#/embedded/child/literal" }, + useLiteralValue: { $ref: "#/embedded/child/literal/value" }, + useNested: { $ref: "#/embedded/child/yet-to-resolve" }, + useNestedAlias: { $ref: "#/aliasChild/yet-to-resolve" }, + aliasChild: { $ref: "#/embedded/child" }, + useExtended: { $ref: "#/embedded", description: "Reusable literal data" }, + }; + + const schema = await $RefParser.dereference(inputSchema, { + resolve: { excludedPathMatcher: (schemaPath) => schemaPath === "#/embedded" }, + }); + + expect(schema).to.deep.equal({ + embedded: { + child: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + }, + use: { + child: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + }, + useChild: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + useLiteral: { value: "raw child data" }, + useLiteralValue: "raw child data", + useNested: { $ref: "#/embedded/child/yet-to-resolve" }, + useNestedAlias: { $ref: "#/aliasChild/yet-to-resolve" }, + aliasChild: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + useExtended: { + child: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + description: "Reusable literal data", + }, + }); + }); + + it("should not process excluded references reached through internal refs while bundling", async () => { + const inputSchema = { + embedded: { + child: { + $ref: "./does-not-exist.yaml", + literal: { value: "raw child data" }, + }, + }, + use: { $ref: "#/embedded" }, + useChild: { $ref: "#/embedded/child" }, + useLiteral: { $ref: "#/embedded/child/literal" }, + useLiteralValue: { $ref: "#/embedded/child/literal/value" }, + useNested: { $ref: "#/embedded/child/yet-to-resolve" }, + useNestedAlias: { $ref: "#/aliasChild/yet-to-resolve" }, + aliasChild: { $ref: "#/embedded/child" }, + useExtended: { $ref: "#/embedded", description: "Reusable literal data" }, + }; + + const schema = await $RefParser.bundle(inputSchema, { + resolve: { excludedPathMatcher: (schemaPath) => schemaPath === "#/embedded" }, + }); + + expect(schema).to.deep.equal(inputSchema); + }); + + it("should continue resolving through intermediate refs outside resolution exclusions", async () => { + const schema = await $RefParser.dereference({ + embedded: { + child: { $ref: "#/target" }, + }, + target: { + yetToResolve: { type: "string" }, + }, + useNested: { $ref: "#/embedded/child/yetToResolve" }, + }); + + expect(schema).to.deep.equal({ + embedded: { + child: { + yetToResolve: { type: "string" }, + }, + }, + target: { + yetToResolve: { type: "string" }, + }, + useNested: { type: "string" }, + }); + }); + + it("should reject missing literal pointer targets inside values skipped during resolution", async () => { + await expect( + $RefParser.dereference( + { + embedded: { literal: {} }, + useMissing: { $ref: "#/embedded/missing" }, + }, + { + resolve: { excludedPathMatcher: (schemaPath) => schemaPath === "#/embedded" }, + }, + ), + ).rejects.toBeInstanceOf(MissingPointerError); + }); + + it("should reset resolution exclusions between parser operations", async () => { + const parser = new $RefParser(); + const embedded = { + child: { $ref: "#/target" }, + }; + + await parser.dereference( + { embedded }, + { + resolve: { excludedPathMatcher: (schemaPath) => schemaPath === "#/embedded" }, + }, + ); + + const schema = await parser.dereference({ embedded, target: { type: "string" } }); + + expect(schema).to.deep.equal({ + embedded: { + child: { type: "string" }, + }, + target: { type: "string" }, + }); + }); + + it("should record circular resolution exclusions without recursing forever", async () => { + const embedded: { + child: { $ref: string }; + self?: unknown; + } = { + child: { $ref: "./does-not-exist.yaml" }, + }; + embedded.self = embedded; + + await $RefParser.dereference( + { embedded }, + { + resolve: { excludedPathMatcher: (schemaPath) => schemaPath === "#/embedded" }, + }, + ); + + expect(embedded.child).to.deep.equal({ $ref: "./does-not-exist.yaml" }); + }); }); diff --git a/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml b/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml index 284e44d9..f178c635 100644 --- a/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml +++ b/test/specs/ref-in-excluded-path/ref-in-excluded-path.yaml @@ -14,7 +14,7 @@ paths: - name: q1 in: query example: - $ref: "#/literal-q1" + $ref: "./literal-q1-does-not-exist.yaml" - name: q2 in: query examples: @@ -37,7 +37,7 @@ paths: content: application/json: example: - $ref: "#/literal-example" + $ref: "https://example.com/literal-example-that-should-not-be-downloaded.json" components: examples: query-example: @@ -51,7 +51,7 @@ components: parameters: a: example: - $ref: "#/literal-param-component-example" + $ref: "./literal-param-component-example-does-not-exist.yaml" b: examples: example1: diff --git a/test/types/options.ts b/test/types/options.ts new file mode 100644 index 00000000..10fd3181 --- /dev/null +++ b/test/types/options.ts @@ -0,0 +1,39 @@ +import type { ParserOptions } from "../../lib/index.js"; + +const options: ParserOptions = { + resolve: { + excludedPathMatcher(path, value) { + const matcherPath: string = path; + const matcherValue: unknown = value; + + return matcherPath === "#/example" && matcherValue !== undefined; + }, + custom: { + canRead: true, + read: "custom resolver value", + }, + }, +}; + +const customResolver = options.resolve?.custom; +if (customResolver && typeof customResolver === "object") { + const canRead = customResolver.canRead; + void canRead; +} + +const invalidCustomResolver: ParserOptions = { + resolve: { + // @ts-expect-error Custom resolvers must be resolver options or a boolean. + custom: 42, + }, +}; + +const invalidMatcher: ParserOptions = { + resolve: { + // @ts-expect-error The excluded path matcher must be callable. + excludedPathMatcher: "not callable", + }, +}; + +void invalidCustomResolver; +void invalidMatcher; diff --git a/test/types/tsconfig.json b/test/types/tsconfig.json new file mode 100644 index 00000000..433ecdf6 --- /dev/null +++ b/test/types/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./**/*.ts"] +}