iso-ucan 1.0.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/policy.js CHANGED
@@ -1,272 +1,674 @@
1
1
  /**
2
- * Resolves a selector string against a data object, inspired by jq.
2
+ * UCAN policy language.
3
3
  *
4
- * @param {any} data The data to select from.
5
- * @param {string} selector The selector string (e.g., ".foo[0].bar").
6
- * @returns {any} The selected value, or null if the selection fails at an optional part.
7
- * @throws {Error} If the selection fails at a non-optional part.
4
+ * @see https://github.com/ucan-wg/delegation#policy
8
5
  */
9
- function resolveSelector(data, selector) {
10
- if (selector === '.') {
11
- return data
6
+
7
+ /**
8
+ * @typedef {object} SegmentBase
9
+ * @property {boolean} optional - Whether the segment ends with `?`: when it fails to resolve, the whole selector returns `null`.
10
+ * @property {string} source - The exact text of the selector this segment was parsed from, including a leading `.` and trailing `?`s.
11
+ *
12
+ * @typedef {{ kind: 'identity' }
13
+ * | { kind: 'field', key: string }
14
+ * | { kind: 'index', index: number }
15
+ * | { kind: 'slice', start: number | undefined, end: number | undefined }
16
+ * | { kind: 'iterate' }
17
+ * } SegmentData
18
+ *
19
+ * @typedef {SegmentData & SegmentBase} SelectorSegment
20
+ */
21
+
22
+ const IDENT_START = /[A-Za-z_]/
23
+ const IDENT_CHAR = /[A-Za-z0-9_]/
24
+ const INDEX = /^-?\d+$/
25
+ const SLICE = /^(-?\d+)?:(-?\d+)?$/
26
+
27
+ /**
28
+ * @param {string} selector
29
+ * @param {number} pos
30
+ * @param {string} reason
31
+ */
32
+ function selectorError(selector, pos, reason) {
33
+ return new TypeError(
34
+ `Invalid selector ${JSON.stringify(selector)} at position ${pos}: ${reason}`
35
+ )
36
+ }
37
+
38
+ /**
39
+ * @param {string} selector
40
+ * @param {number} pos
41
+ * @param {string} text
42
+ */
43
+ function parseInteger(selector, pos, text) {
44
+ const n = Number(text)
45
+ if (!Number.isSafeInteger(n)) {
46
+ throw selectorError(selector, pos, `index ${text} is out of range`)
12
47
  }
48
+ return n
49
+ }
13
50
 
14
- // This regex is a simplified parser for the selector syntax.
15
- const partsRegex = /(\.[\w_]+|\[-?\d*:-?\d*\]|\[-?\d+\]|\[\]|\?)/g
16
- const parts = selector.match(partsRegex) || []
51
+ /**
52
+ * Parse a bracket segment (`["key"]`, `[]`, `[n]`, `[a:b]`) starting at `start`
53
+ * (which must point at `[`).
54
+ *
55
+ * @param {string} selector
56
+ * @param {number} start
57
+ * @returns {{ segment: SegmentData, end: number }}
58
+ */
59
+ function parseBracket(selector, start) {
60
+ let pos = start + 1
17
61
 
18
- let currentData = data
62
+ // Unambiguous field name: ["any string"] with JSON string escapes.
63
+ if (selector[pos] === '"') {
64
+ let end = pos + 1
65
+ while (end < selector.length && selector[end] !== '"') {
66
+ end += selector[end] === '\\' ? 2 : 1
67
+ }
68
+ if (end >= selector.length) {
69
+ throw selectorError(selector, pos, 'unterminated string')
70
+ }
71
+ /** @type {unknown} */
72
+ let key
73
+ try {
74
+ key = JSON.parse(selector.slice(pos, end + 1))
75
+ } catch (error) {
76
+ throw selectorError(
77
+ selector,
78
+ pos,
79
+ `invalid string literal (${/** @type {Error} */ (error).message})`
80
+ )
81
+ }
82
+ pos = end + 1
83
+ if (selector[pos] !== ']') {
84
+ throw selectorError(selector, pos, 'expected "]" after string')
85
+ }
86
+ return {
87
+ segment: { kind: 'field', key: /** @type {string} */ (key) },
88
+ end: pos + 1,
89
+ }
90
+ }
91
+
92
+ const close = selector.indexOf(']', pos)
93
+ if (close === -1) {
94
+ throw selectorError(selector, start, 'unterminated "["')
95
+ }
96
+ const inner = selector.slice(pos, close)
19
97
 
20
- for (let part of parts) {
21
- let isOptional = false
22
- if (part.endsWith('?')) {
23
- isOptional = true
24
- part = part.slice(0, -1)
98
+ if (inner === '') {
99
+ return { segment: { kind: 'iterate' }, end: close + 1 }
100
+ }
101
+
102
+ if (INDEX.test(inner)) {
103
+ return {
104
+ segment: { kind: 'index', index: parseInteger(selector, pos, inner) },
105
+ end: close + 1,
25
106
  }
107
+ }
26
108
 
27
- try {
28
- if (currentData === undefined || currentData === null) {
29
- throw new Error(
30
- `Cannot process part "${part}" on null or undefined value.`
31
- )
32
- }
109
+ const slice = SLICE.exec(inner)
110
+ if (slice && (slice[1] !== undefined || slice[2] !== undefined)) {
111
+ return {
112
+ segment: {
113
+ kind: 'slice',
114
+ start:
115
+ slice[1] === undefined
116
+ ? undefined
117
+ : parseInteger(selector, pos, slice[1]),
118
+ end:
119
+ slice[2] === undefined
120
+ ? undefined
121
+ : parseInteger(selector, pos, slice[2]),
122
+ },
123
+ end: close + 1,
124
+ }
125
+ }
33
126
 
34
- if (part.startsWith('.')) {
35
- const key = part.substring(1)
36
- if (typeof currentData !== 'object' || Array.isArray(currentData)) {
37
- throw new Error(`Cannot access key "${key}" on non-object.`)
38
- }
39
- currentData = currentData[key]
40
- } else if (part === '[]') {
41
- if (Array.isArray(currentData)) {
42
- // No-op for arrays, the quantifier will iterate over it.
43
- } else if (typeof currentData === 'object') {
44
- currentData = Object.values(currentData)
45
- } else {
46
- throw new Error('Cannot extract values from non-collection.')
47
- }
48
- } else if (part.startsWith('[') && part.includes(':')) {
49
- // Slice
50
- if (!Array.isArray(currentData)) {
51
- throw new Error('Slice can only be applied to arrays.')
52
- }
53
- const [start, end] = part
54
- .substring(1, part.length - 1)
55
- .split(':')
56
- .map((s) => (s ? Number.parseInt(s, 10) : undefined))
57
- currentData = currentData.slice(start, end)
58
- } else if (part.startsWith('[')) {
59
- // Index
60
- if (!Array.isArray(currentData)) {
61
- throw new Error('Index can only be applied to arrays.')
62
- }
63
- const index = Number.parseInt(part.substring(1, part.length - 1), 10)
64
- currentData =
65
- currentData[index < 0 ? currentData.length + index : index]
66
- }
127
+ throw selectorError(
128
+ selector,
129
+ pos,
130
+ `invalid bracket expression "[${inner}]", expected [], [n], [a:b] or ["key"]`
131
+ )
132
+ }
67
133
 
68
- if (currentData === undefined) {
69
- throw new Error(`Path segment returned undefined: ${part}`)
134
+ /**
135
+ * Parse a UCAN policy selector into segments.
136
+ *
137
+ * Consumes the whole string and throws a `TypeError` on anything that is not
138
+ * valid selector syntax. Supported forms:
139
+ *
140
+ * - `.` identity
141
+ * - `.name` dotted field name, where name matches `[A-Za-z_][A-Za-z0-9_]*`
142
+ * - `["any string"]` unambiguous field name, with JSON string escapes
143
+ * - `[]` collection values
144
+ * - `[n]`, `[-n]` list index
145
+ * - `[a:b]`, `[a:]`, `[:b]` list slice
146
+ * - a trailing `?` on any segment (repeated `?` collapse into one)
147
+ *
148
+ * A selector MUST start with `.` and MUST NOT contain `..`.
149
+ *
150
+ * @see https://github.com/ucan-wg/delegation#selectors
151
+ *
152
+ * @param {string} selector
153
+ * @returns {SelectorSegment[]}
154
+ */
155
+ export function parseSelector(selector) {
156
+ if (typeof selector !== 'string') {
157
+ throw new TypeError(
158
+ `Invalid selector: expected a string, got ${typeof selector}`
159
+ )
160
+ }
161
+ if (selector[0] !== '.') {
162
+ throw selectorError(selector, 0, 'must start with "."')
163
+ }
164
+
165
+ /** @type {SelectorSegment[]} */
166
+ const segments = []
167
+ let pos = 0
168
+
169
+ while (pos < selector.length) {
170
+ const start = pos
171
+ const char = selector[pos]
172
+ /** @type {SegmentData} */
173
+ let segment
174
+
175
+ if (char === '.') {
176
+ const next = selector[pos + 1]
177
+ if (next === '.') {
178
+ throw selectorError(selector, pos, '".." is not allowed')
70
179
  }
71
- } catch (e) {
72
- if (isOptional) {
73
- return null
180
+ if (next !== undefined && IDENT_START.test(next)) {
181
+ pos += 2
182
+ while (pos < selector.length && IDENT_CHAR.test(selector[pos])) {
183
+ pos++
184
+ }
185
+ segment = { kind: 'field', key: selector.slice(start + 1, pos) }
186
+ } else if (next === '[') {
187
+ const parsed = parseBracket(selector, pos + 1)
188
+ segment = parsed.segment
189
+ pos = parsed.end
190
+ } else if (next === undefined || next === '?') {
191
+ segment = { kind: 'identity' }
192
+ pos++
193
+ } else {
194
+ throw selectorError(
195
+ selector,
196
+ pos + 1,
197
+ `unexpected character ${JSON.stringify(next)} after "."`
198
+ )
74
199
  }
75
- // Re-throw if not optional, to be caught by evaluateStatement
76
- throw e
200
+ } else if (char === '[') {
201
+ const parsed = parseBracket(selector, pos)
202
+ segment = parsed.segment
203
+ pos = parsed.end
204
+ } else {
205
+ throw selectorError(
206
+ selector,
207
+ pos,
208
+ `unexpected character ${JSON.stringify(char)}`
209
+ )
210
+ }
211
+
212
+ let optional = false
213
+ while (selector[pos] === '?') {
214
+ optional = true
215
+ pos++
216
+ }
217
+
218
+ segments.push(
219
+ /** @type {SelectorSegment} */ ({
220
+ ...segment,
221
+ optional,
222
+ source: selector.slice(start, pos),
223
+ })
224
+ )
225
+ }
226
+
227
+ return segments
228
+ }
229
+
230
+ /**
231
+ * Returned by {@link resolve} when a selector can't be resolved.
232
+ */
233
+ const FAIL = Symbol('fail')
234
+
235
+ /**
236
+ * Plain IPLD map (a JS object that is not an array, bytes, CID or class instance).
237
+ *
238
+ * @param {unknown} value
239
+ * @returns {value is Record<string, unknown>}
240
+ */
241
+ function isMap(value) {
242
+ if (value === null || typeof value !== 'object') return false
243
+ const proto = Object.getPrototypeOf(value)
244
+ return proto === Object.prototype || proto === null
245
+ }
246
+
247
+ /**
248
+ * @param {unknown} value
249
+ * @returns {value is unknown[] | Uint8Array}
250
+ */
251
+ function isList(value) {
252
+ return Array.isArray(value) || value instanceof Uint8Array
253
+ }
254
+
255
+ /**
256
+ * Resolve a single segment, or return {@link FAIL}.
257
+ *
258
+ * @param {unknown} value
259
+ * @param {SelectorSegment} segment
260
+ * @returns {unknown}
261
+ */
262
+ function resolveSegment(value, segment) {
263
+ switch (segment.kind) {
264
+ case 'identity':
265
+ return value
266
+ case 'field':
267
+ if (!isMap(value)) return FAIL
268
+ // Missing keys resolve to null. Only own keys count, so `__proto__`,
269
+ // `constructor` and friends are missing unless they are in the data.
270
+ return Object.hasOwn(value, segment.key) ? value[segment.key] : null
271
+ case 'index': {
272
+ if (!isList(value)) return FAIL
273
+ const index =
274
+ segment.index < 0 ? value.length + segment.index : segment.index
275
+ if (index < 0 || index >= value.length) return FAIL
276
+ return value[index]
77
277
  }
278
+ case 'slice':
279
+ if (!isList(value)) return FAIL
280
+ return value.slice(segment.start, segment.end)
281
+ case 'iterate':
282
+ if (isList(value)) return value
283
+ if (isMap(value)) return Object.values(value)
284
+ return FAIL
285
+ default:
286
+ return FAIL
78
287
  }
288
+ }
79
289
 
80
- return currentData
290
+ /**
291
+ * Resolve parsed selector segments against data.
292
+ *
293
+ * Segments are resolved left to right. When a segment fails, the selector
294
+ * returns `null` if that segment is optional, otherwise {@link FAIL}. Either
295
+ * way, resolution stops at the first failure.
296
+ *
297
+ * @param {unknown} data
298
+ * @param {SelectorSegment[]} segments
299
+ * @returns {unknown}
300
+ */
301
+ function resolve(data, segments) {
302
+ let current = data
303
+ for (const segment of segments) {
304
+ const next = resolveSegment(current, segment)
305
+ if (next === FAIL) {
306
+ return segment.optional ? null : FAIL
307
+ }
308
+ current = next
309
+ }
310
+ return current
81
311
  }
82
312
 
83
313
  /**
84
- * Performs a deep equality check between two values.
85
- * @param {any} a
86
- * @param {any} b
314
+ * Resolve a selector against data.
315
+ *
316
+ * @param {unknown} data - The data to select from.
317
+ * @param {string} selector - The selector, e.g. `.foo[0].bar`.
318
+ * @returns {{ ok: true, value: unknown } | { ok: false }} `ok: false` when the selector can't be resolved.
319
+ * @throws {TypeError} If the selector is not valid syntax.
320
+ */
321
+ export function select(data, selector) {
322
+ const value = resolve(data, parseSelector(selector))
323
+ return value === FAIL ? { ok: false } : { ok: true, value }
324
+ }
325
+
326
+ /**
327
+ * Compare two numbers that may be `number` or `bigint`.
328
+ *
329
+ * @param {unknown} a
330
+ * @param {unknown} b
331
+ * @returns {-1 | 0 | 1 | undefined} `undefined` when either side is not a number, or is NaN.
332
+ */
333
+ function compareNumbers(a, b) {
334
+ if (typeof a === 'number' && typeof b === 'number') {
335
+ if (Number.isNaN(a) || Number.isNaN(b)) return undefined
336
+ return a < b ? -1 : a > b ? 1 : 0
337
+ }
338
+ if (typeof a === 'bigint' && typeof b === 'bigint') {
339
+ return a < b ? -1 : a > b ? 1 : 0
340
+ }
341
+ if (typeof a === 'bigint' && typeof b === 'number') {
342
+ const r = compareNumbers(b, a)
343
+ return r === undefined ? undefined : /** @type {-1 | 0 | 1} */ (-r || 0)
344
+ }
345
+ if (typeof a === 'number' && typeof b === 'bigint') {
346
+ if (Number.isNaN(a)) return undefined
347
+ if (!Number.isFinite(a)) return a > 0 ? 1 : -1
348
+ if (Number.isInteger(a)) {
349
+ const ai = BigInt(a)
350
+ return ai < b ? -1 : ai > b ? 1 : 0
351
+ }
352
+ // a is not an integer, so it is never equal to b, and
353
+ // floor(a) < a < floor(a) + 1.
354
+ return BigInt(Math.floor(a)) < b ? -1 : 1
355
+ }
356
+ return undefined
357
+ }
358
+
359
+ /**
360
+ * @param {Uint8Array} a
361
+ * @param {Uint8Array} b
362
+ */
363
+ function bytesEqual(a, b) {
364
+ if (a.length !== b.length) return false
365
+ for (let i = 0; i < a.length; i++) {
366
+ if (a[i] !== b[i]) return false
367
+ }
368
+ return true
369
+ }
370
+
371
+ /**
372
+ * Deep equality between IPLD values.
373
+ *
374
+ * `number` and `bigint` are compared by numeric value, so `1 == 1n`.
375
+ *
376
+ * @param {unknown} a
377
+ * @param {unknown} b
87
378
  * @returns {boolean}
88
379
  */
89
380
  function deepEqual(a, b) {
90
381
  if (a === b) return true
91
- if (a && b && typeof a === 'object' && typeof b === 'object') {
92
- if (a.constructor !== b.constructor) return false
93
- if (Array.isArray(a)) {
94
- if (a.length !== b.length) return false
95
- for (let i = 0; i < a.length; i++) {
96
- if (!deepEqual(a[i], b[i])) return false
97
- }
98
- return true
382
+
383
+ const numeric = compareNumbers(a, b)
384
+ if (numeric !== undefined) return numeric === 0
385
+
386
+ if (a instanceof Uint8Array || b instanceof Uint8Array) {
387
+ return (
388
+ a instanceof Uint8Array && b instanceof Uint8Array && bytesEqual(a, b)
389
+ )
390
+ }
391
+
392
+ if (Array.isArray(a) || Array.isArray(b)) {
393
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
394
+ if (a.length !== b.length) return false
395
+ for (let i = 0; i < a.length; i++) {
396
+ if (!deepEqual(a[i], b[i])) return false
99
397
  }
398
+ return true
399
+ }
400
+
401
+ if (isMap(a) && isMap(b)) {
100
402
  const keys = Object.keys(a)
101
403
  if (keys.length !== Object.keys(b).length) return false
102
404
  for (const key of keys) {
103
- // biome-ignore lint/suspicious/noPrototypeBuiltins: needed
104
- if (!b.hasOwnProperty(key) || !deepEqual(a[key], b[key])) return false
405
+ if (!Object.hasOwn(b, key) || !deepEqual(a[key], b[key])) return false
105
406
  }
106
407
  return true
107
408
  }
409
+
410
+ // CIDs and other objects with an `equals` method (e.g. multiformats CID).
411
+ if (
412
+ a !== null &&
413
+ b !== null &&
414
+ typeof a === 'object' &&
415
+ typeof b === 'object' &&
416
+ 'equals' in a &&
417
+ typeof a.equals === 'function' &&
418
+ Object.getPrototypeOf(a) === Object.getPrototypeOf(b)
419
+ ) {
420
+ return Boolean(a.equals(b))
421
+ }
422
+
108
423
  return false
109
424
  }
110
425
 
111
426
  /**
112
427
  * Converts a UCAN glob pattern to a regular expression.
428
+ *
429
+ * `*` matches zero or more characters, including newlines. `\*` matches a
430
+ * literal `*`. The spec defines no other escapes, so a `\` that is not
431
+ * followed by `*` is a literal backslash. For example, the pattern `\\*` is a
432
+ * literal `\` followed by `\*`, and only matches the string `\*`.
433
+ *
434
+ * @see https://github.com/ucan-wg/delegation#glob-matching
435
+ *
113
436
  * @param {string} pattern
114
437
  * @returns {RegExp}
115
438
  */
116
- function likeRegex(pattern) {
117
- // Convert UCAN glob pattern to a regular expression.
118
- // The glob pattern has `*` as a wildcard (zero or more chars)
119
- // and `\*` as an escape for a literal asterisk.
120
- let regexString = '^'
439
+ export function likeRegex(pattern) {
440
+ let regex = '^'
121
441
  let i = 0
122
442
  while (i < pattern.length) {
123
443
  const char = pattern[i]
124
-
125
- if (char === '\\') {
126
- // Handle escape sequence
127
- if (i + 1 < pattern.length) {
128
- const nextChar = pattern[i + 1]
129
- if (nextChar === '*') {
130
- // This is an escaped asterisk `\*`. Match a literal `*`.
131
- regexString += '\\*'
132
- i += 2 // Move past both `\` and `*`
133
- } else {
134
- // This is another escape, like `\\`. Treat the `\` as a literal character.
135
- regexString += '\\\\'
136
- i++
137
- }
138
- } else {
139
- // Trailing backslash. Treat as a literal `\`.
140
- regexString += '\\\\'
141
- i++
142
- }
444
+ if (char === '\\' && pattern[i + 1] === '*') {
445
+ regex += '\\*'
446
+ i += 2
143
447
  } else if (char === '*') {
144
- // This is a wildcard `*`. Match zero or more characters.
145
- regexString += '.*'
448
+ regex += '[\\s\\S]*'
146
449
  i++
147
450
  } else {
148
- // This is a regular character. Escape it if it's a special regex metacharacter.
149
- regexString += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
451
+ regex += char.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
150
452
  i++
151
453
  }
152
454
  }
153
- regexString += '$'
154
-
155
- return new RegExp(regexString)
455
+ regex += '$'
456
+ return new RegExp(regex)
156
457
  }
157
458
 
158
459
  /**
159
- * Evaluates a single policy statement against the invocation args.
160
- *
161
- * @template Args
162
- * @param {Args} args The invocation arguments.
163
- * @param {import("./types.js").Statement<Args>} statement The policy statement.
164
- * @returns {boolean} The result of the evaluation.
460
+ * @typedef {{ op: '==', selector: SelectorSegment[], value: unknown }
461
+ * | { op: '<' | '<=' | '>' | '>=', selector: SelectorSegment[], value: number | bigint }
462
+ * | { op: 'like', selector: SelectorSegment[], regex: RegExp }
463
+ * | { op: 'not', statement: CompiledStatement }
464
+ * | { op: 'and' | 'or', statements: CompiledStatement[] }
465
+ * | { op: 'all' | 'any', selector: SelectorSegment[], statement: CompiledStatement }
466
+ * } CompiledStatement
165
467
  */
166
- function evaluateStatement(args, statement) {
167
- const [op, ...params] = statement
168
468
 
469
+ /**
470
+ * @param {unknown} statement
471
+ * @param {string} reason
472
+ */
473
+ function statementError(statement, reason) {
474
+ let text
169
475
  try {
170
- switch (op) {
171
- // Comparisons
172
- case '==': {
173
- const [selector, value] = /** @type {[string, unknown]} */ (params)
476
+ text = JSON.stringify(statement, (_, v) =>
477
+ typeof v === 'bigint' ? `${v}n` : v
478
+ )
479
+ } catch {
480
+ text = String(statement)
481
+ }
482
+ return new TypeError(`Invalid policy statement ${text}: ${reason}`)
483
+ }
174
484
 
175
- return deepEqual(resolveSelector(args, selector), value)
176
- }
177
- case '!=': {
178
- const [selector, value] = /** @type {[string, unknown]} */ (params)
179
- return !deepEqual(resolveSelector(args, selector), value)
485
+ /**
486
+ * @param {unknown[]} statement
487
+ * @param {number} arity
488
+ */
489
+ function assertArity(statement, arity) {
490
+ if (statement.length !== arity) {
491
+ throw statementError(
492
+ statement,
493
+ `"${statement[0]}" takes ${arity - 1} argument(s), got ${statement.length - 1}`
494
+ )
495
+ }
496
+ }
497
+
498
+ /**
499
+ * Check the structure of a statement and parse its selectors.
500
+ *
501
+ * @param {unknown} statement
502
+ * @returns {CompiledStatement}
503
+ * @throws {TypeError} If the statement is malformed.
504
+ */
505
+ function compileStatement(statement) {
506
+ if (!Array.isArray(statement)) {
507
+ throw statementError(statement, 'expected an array')
508
+ }
509
+ const op = statement[0]
510
+ switch (op) {
511
+ case '==':
512
+ case '!=': {
513
+ assertArity(statement, 3)
514
+ /** @type {CompiledStatement} */
515
+ const eq = {
516
+ op: '==',
517
+ selector: parseSelector(statement[1]),
518
+ value: statement[2],
180
519
  }
181
- case '>':
182
- case '>=':
183
- case '<':
184
- case '<=': {
185
- const [selector, value] = /** @type {[string, number]} */ (params)
186
- const selected = resolveSelector(args, selector)
187
- if (typeof selected !== 'number' || typeof value !== 'number')
188
- return false
189
- if (op === '>') return selected > value
190
- if (op === '>=') return selected >= value
191
- if (op === '<') return selected < value
192
- if (op === '<=') return selected <= value
193
- break
520
+ // `!=` is defined as `["not", ["==", selector, value]]`.
521
+ return op === '==' ? eq : { op: 'not', statement: eq }
522
+ }
523
+ case '<':
524
+ case '<=':
525
+ case '>':
526
+ case '>=': {
527
+ assertArity(statement, 3)
528
+ const value = statement[2]
529
+ if (
530
+ typeof value !== 'bigint' &&
531
+ (typeof value !== 'number' || !Number.isFinite(value))
532
+ ) {
533
+ throw statementError(statement, 'value must be a number')
194
534
  }
195
- // Glob Matching
196
- case 'like': {
197
- const [selector, pattern] = /** @type {[string, string]} */ (params)
198
- const selected = resolveSelector(args, selector)
199
- if (typeof selected !== 'string') return false
200
- const regex = likeRegex(pattern)
201
- return regex.test(selected)
535
+ return { op, selector: parseSelector(statement[1]), value }
536
+ }
537
+ case 'like': {
538
+ assertArity(statement, 3)
539
+ const pattern = statement[2]
540
+ if (typeof pattern !== 'string') {
541
+ throw statementError(statement, 'pattern must be a string')
202
542
  }
203
- // Connectives
204
- case 'and': {
205
- const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
206
- params
207
- )
208
- return statements.every((stmt) => evaluateStatement(args, stmt))
543
+ return {
544
+ op,
545
+ selector: parseSelector(statement[1]),
546
+ regex: likeRegex(pattern),
209
547
  }
210
- case 'or': {
211
- const [statements] = /** @type {[import("./types.js").Statement[]]} */ (
212
- params
213
- )
214
- if (statements.length === 0) return true
215
- return statements.some((stmt) => evaluateStatement(args, stmt))
548
+ }
549
+ case 'not':
550
+ assertArity(statement, 2)
551
+ return { op, statement: compileStatement(statement[1]) }
552
+ case 'and':
553
+ case 'or':
554
+ assertArity(statement, 2)
555
+ if (!Array.isArray(statement[1])) {
556
+ throw statementError(statement, 'expected an array of statements')
216
557
  }
217
- case 'not': {
218
- const [statement] = /** @type {[import("./types.js").Statement]} */ (
219
- params
220
- )
221
- return !evaluateStatement(args, statement)
558
+ return { op, statements: statement[1].map(compileStatement) }
559
+ case 'all':
560
+ case 'any':
561
+ assertArity(statement, 3)
562
+ return {
563
+ op,
564
+ selector: parseSelector(statement[1]),
565
+ statement: compileStatement(statement[2]),
222
566
  }
223
- // Quantification
224
- case 'all':
225
- case 'any': {
226
- const [selector, innerStmt] =
227
- /** @type {[string, import("./types.js").Statement]} */ (params)
228
- let collection = resolveSelector(args, selector)
229
-
230
- if (innerStmt[0] === 'any' || innerStmt[0] === 'all') {
231
- return evaluateStatement(collection, innerStmt)
232
- }
567
+ default:
568
+ throw statementError(statement, `unknown operator ${String(op)}`)
569
+ }
570
+ }
233
571
 
234
- if (
235
- collection &&
236
- typeof collection === 'object' &&
237
- !Array.isArray(collection)
238
- ) {
239
- collection = Object.values(collection)
240
- }
241
- if (!Array.isArray(collection)) return false
242
- if (collection.length === 0) return true
243
- if (op === 'all') {
244
- return collection.every((item) => {
245
- return evaluateStatement(item, innerStmt)
246
- })
247
- }
248
- // any
249
- return collection.some((item) => evaluateStatement(item, innerStmt))
572
+ /**
573
+ * Check the structure of a policy and parse its selectors.
574
+ *
575
+ * This does not check that `==` / `!=` values are IPLD values. Delegations
576
+ * are fully validated with `assertPolicy` (in `utils.js`) when created or
577
+ * decoded.
578
+ *
579
+ * @param {unknown} policy
580
+ * @returns {CompiledStatement[]}
581
+ * @throws {TypeError} If the policy is malformed.
582
+ */
583
+ export function compilePolicy(policy) {
584
+ if (!Array.isArray(policy)) {
585
+ throw new TypeError('Invalid policy: expected an array of statements')
586
+ }
587
+ return policy.map(compileStatement)
588
+ }
589
+
590
+ /**
591
+ * Evaluates a compiled statement against data.
592
+ *
593
+ * @param {unknown} data
594
+ * @param {CompiledStatement} statement
595
+ * @returns {boolean}
596
+ */
597
+ function evaluate(data, statement) {
598
+ switch (statement.op) {
599
+ case '==': {
600
+ const selected = resolve(data, statement.selector)
601
+ return selected !== FAIL && deepEqual(selected, statement.value)
602
+ }
603
+ case '<':
604
+ case '<=':
605
+ case '>':
606
+ case '>=': {
607
+ const selected = resolve(data, statement.selector)
608
+ if (selected === FAIL) return false
609
+ const cmp = compareNumbers(selected, statement.value)
610
+ if (cmp === undefined) return false
611
+ if (statement.op === '<') return cmp < 0
612
+ if (statement.op === '<=') return cmp <= 0
613
+ if (statement.op === '>') return cmp > 0
614
+ return cmp >= 0
615
+ }
616
+ case 'like': {
617
+ const selected = resolve(data, statement.selector)
618
+ return typeof selected === 'string' && statement.regex.test(selected)
619
+ }
620
+ case 'not':
621
+ return !evaluate(data, statement.statement)
622
+ case 'and':
623
+ return statement.statements.every((s) => evaluate(data, s))
624
+ case 'or':
625
+ return (
626
+ statement.statements.length === 0 ||
627
+ statement.statements.some((s) => evaluate(data, s))
628
+ )
629
+ case 'all':
630
+ case 'any': {
631
+ const selected = resolve(data, statement.selector)
632
+ /** @type {unknown[]} */
633
+ let items
634
+ if (Array.isArray(selected)) {
635
+ items = selected
636
+ } else if (isMap(selected)) {
637
+ items = Object.values(selected)
638
+ } else {
639
+ // Quantifying over a non-collection (or an unresolvable selector) is false.
640
+ return false
250
641
  }
251
- default:
252
- return false // Unknown operator
642
+ // Like `and` / `or`, an empty collection is true for both quantifiers.
643
+ if (items.length === 0) return true
644
+ return statement.op === 'all'
645
+ ? items.every((item) => evaluate(item, statement.statement))
646
+ : items.some((item) => evaluate(item, statement.statement))
253
647
  }
254
- } catch {
255
- // As per the spec, if a selector cannot be resolved, the statement MUST return false.
256
- return false
648
+ default:
649
+ return false
257
650
  }
258
- return false
259
651
  }
260
652
 
261
653
  /**
262
654
  * Validates invocation arguments against a UCAN policy.
263
655
  *
656
+ * A malformed policy (unknown operator, wrong arity, invalid selector, ...)
657
+ * never validates: the result is `false` for any `args`.
658
+ *
264
659
  * @template {unknown} Args
265
660
  * @param {Args} args The arguments of the eventual invocation.
266
661
  * @param {import("./types.js").Policy<Args>} policy An array of policy statements.
267
662
  * @returns {boolean} True if the args are valid according to the policy, false otherwise.
268
663
  */
269
664
  export function validate(args, policy) {
665
+ /** @type {CompiledStatement[]} */
666
+ let compiled
667
+ try {
668
+ compiled = compilePolicy(policy)
669
+ } catch {
670
+ return false
671
+ }
270
672
  // The top-level policy is an implicit 'and'.
271
- return policy.every((statement) => evaluateStatement(args, statement))
673
+ return compiled.every((statement) => evaluate(args, statement))
272
674
  }