-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathref-check.js
More file actions
145 lines (129 loc) · 4.86 KB
/
Copy pathref-check.js
File metadata and controls
145 lines (129 loc) · 4.86 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
'use strict'
// Conservative compile-time $ref resolvability check.
// Only throws on clearly-unresolvable refs; when in doubt, stays silent.
// Message format matches ajv: "can't resolve reference <ref> from id #"
/**
* Collect all $ref string values from a schema (recursive).
* @param {object} schema
* @param {string[]} refs
*/
function collectRefs(schema, refs) {
if (!schema || typeof schema !== 'object') return
if (Array.isArray(schema)) {
for (const item of schema) collectRefs(item, refs)
return
}
if (typeof schema.$ref === 'string') refs.push(schema.$ref)
for (const key of Object.keys(schema)) {
if (key === '$ref') continue
collectRefs(schema[key], refs)
}
}
/**
* Collect all $id and $anchor string values from a schema (recursive).
* @param {object} schema
* @param {Set<string>} ids
*/
function collectIds(schema, ids) {
if (!schema || typeof schema !== 'object') return
if (Array.isArray(schema)) {
for (const item of schema) collectIds(item, ids)
return
}
if (typeof schema.$id === 'string') ids.add(schema.$id)
if (typeof schema.$anchor === 'string') ids.add(schema.$anchor)
for (const key of Object.keys(schema)) {
if (key === '$id' || key === '$anchor') continue
collectIds(schema[key], ids)
}
}
/**
* Trailing-slash-insensitive identifier comparison. Per RFC 3986
* normalization, 'http://x.test/' and 'http://x.test' identify the same
* resource and the default resolver accepts either spelling; comparing
* literally would produce false positives. This only ADDS acceptance.
* @param {string} a
* @param {string} b
* @returns {boolean}
*/
function idMatches(a, b) {
if (a === b) return true
const stripA = a.endsWith('/') ? a.slice(0, -1) : a
const stripB = b.endsWith('/') ? b.slice(0, -1) : b
return stripA === stripB
}
/**
* @param {Set<string>} ids
* @param {string} value
* @returns {boolean}
*/
function idSetHas(ids, value) {
// The linear fallback handles trailing-slash URI variants the O(1)
// membership check misses; do not replace this with a bare ids.has().
if (ids.has(value)) return true
for (const id of ids) {
if (idMatches(id, value)) return true
}
return false
}
/**
* Check whether a $ref is clearly unresolvable. Throws with ajv-compatible
* message on the FIRST clearly-unresolvable ref found.
*
* @param {object} schema - the route schema (already merge-patch-expanded)
* @param {object} externalSchemas - the externalSchemas map from the pool
*/
function checkRefs(schema, externalSchemas) {
const refs = []
collectRefs(schema, refs)
if (refs.length === 0) return
const localIds = new Set()
collectIds(schema, localIds)
const extKeys = externalSchemas ? Object.keys(externalSchemas) : []
for (const ref of refs) {
// Local JSON-pointer refs (#/...) are always accepted.
if (ref.startsWith('#/')) continue
// Anchor-style local ref: starts with '#' but has no slash.
if (ref.startsWith('#')) {
const name = ref.slice(1) // e.g. "notExist" from "#notExist"
// Accept if we find a matching $id or $anchor in the local schema.
// Two lookups on purpose: collectIds stores $id values verbatim
// ("#notExist" matches via ref) while $anchor values are stored bare
// ("notExist" matches via name). This branch uses strict equality
// throughout: anchor names are never URI-style, so trailing-slash
// normalization does not apply here.
if (localIds.has(ref) || localIds.has(name)) continue
// Accept if any external schema key or nested $id resolves it.
if (extKeys.some(k => k === ref || k === name)) continue
if (extKeys.some(k => {
const ext = externalSchemas[k]
if (!ext || typeof ext !== 'object') return false
const extIds = new Set()
collectIds(ext, extIds)
return extIds.has(ref) || extIds.has(name)
})) continue
// Clearly unresolvable anchor.
throw new Error(`can't resolve reference ${ref} from id #`)
}
// External ref: "base" or "base#fragment"
const hashIdx = ref.indexOf('#')
const base = hashIdx === -1 ? ref : ref.slice(0, hashIdx)
if (!base) continue // bare '#' or '#/...' already handled above
// Accept if base matches any external schema key
// (trailing-slash-insensitive for URI-style ids).
if (extKeys.some(k => idMatches(k, base))) continue
// Accept if base is found as any nested $id in the local schema.
if (idSetHas(localIds, base)) continue
// Accept if base matches any nested $id in any external schema.
if (extKeys.some(k => {
const ext = externalSchemas[k]
if (!ext || typeof ext !== 'object') return false
const extIds = new Set()
collectIds(ext, extIds)
return idSetHas(extIds, base)
})) continue
// Clearly unresolvable external ref.
throw new Error(`can't resolve reference ${ref} from id #`)
}
}
module.exports = { checkRefs }