-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlease.ts
More file actions
478 lines (449 loc) · 15.1 KB
/
lease.ts
File metadata and controls
478 lines (449 loc) · 15.1 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
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
import {
BudgetExhaustedError,
InvalidRequestError,
LeaseExpiredError,
LeaseSubsetViolationError,
PermissionDeniedError,
} from "@arcp/core/errors";
import {
isValidCapabilityName,
type Lease,
type LeaseConstraints,
parseBudgetAmount,
} from "@arcp/core/messages";
import type { LeaseOpContext } from "./types.js";
// ARCP v1.1 §9 — leases.
//
// A lease is an immutable record granted to a job at submission. It maps
// capability names to lists of glob patterns. Enforcement is the runtime's
// responsibility (§9.3). Validation is static; there is no lifecycle,
// extension, or revocation in v1.0.
/** Compile a single ARCP glob pattern into an anchored RegExp. */
export function compileGlob(pattern: string): RegExp {
const re = patternToRegExp(pattern);
return new RegExp(`^${re}$`);
}
/**
* Convert an ARCP glob pattern to an anchored regex body.
*
* §9.2:
* - `*` matches any single path or name segment (i.e. one segment, no `/`)
* - `**` matches zero or more segments (multi-segment wildcard)
*
* All other regex metacharacters are escaped literally.
*/
function patternToRegExp(pattern: string): string {
let out = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === undefined) break;
const step = consumePatternToken(pattern, i, out);
out = step.out;
i = step.next;
}
return out;
}
interface PatternStep {
out: string;
next: number;
}
function consumePatternToken(
pattern: string,
i: number,
out: string,
): PatternStep {
const ch = pattern[i];
if (ch === "*") return consumeStar(pattern, i, out);
const escaped = /[\\^$.|?+()[\]{}]/.test(ch ?? "")
? `\\${ch ?? ""}`
: (ch ?? "");
return { out: out + escaped, next: i + 1 };
}
function consumeStar(pattern: string, i: number, out: string): PatternStep {
if (pattern[i + 1] !== "*") {
// single-segment `*`
return { out: `${out}[^/]*`, next: i + 1 };
}
return consumeDoubleStar(pattern, i, out);
}
function consumeDoubleStar(
pattern: string,
i: number,
out: string,
): PatternStep {
const isPrefixSlash = out.endsWith("/");
const isSuffixSlash = pattern[i + 2] === "/";
const atEnd = i + 2 >= pattern.length;
// `/.../**/...` → strip trailing slash, replace with optional
// `(?:/[^/]+)*/` so zero intermediate segments is also a match.
if (isPrefixSlash && isSuffixSlash) {
return { out: `${out.slice(0, -1)}(?:/[^/]+)*/`, next: i + 3 };
}
// `/.../**` at end of pattern → strip trailing slash and accept
// zero or more segments INCLUDING the empty tail.
if (isPrefixSlash && atEnd) {
return { out: `${out.slice(0, -1)}(?:/[^/]+)*`, next: i + 2 };
}
// Bare `**` anywhere else.
return { out: `${out}.*`, next: i + 2 };
}
/**
* Match `target` against `pattern` per §9.2 glob rules. Anchored at both
* ends — no partial-string matches.
*/
export function matchGlob(pattern: string, target: string): boolean {
return compileGlob(pattern).test(target);
}
/**
* Canonicalize a target string before lease matching.
*
* §14 security requires this: the runtime MUST normalize paths and URLs
* before pattern checking. Specifically:
*
* - Resolve `.` and `..` segments.
* - Collapse repeated slashes.
* - Lower-case the scheme on URLs.
*/
export function canonicalizeTarget(target: string): string {
// URL form: only lower-case scheme; leave the rest as-is.
const urlMatch = /^([A-Za-z][A-Za-z0-9+.-]*):/.exec(target);
if (urlMatch !== null) {
const scheme = (urlMatch[1] ?? "").toLowerCase();
return `${scheme}${target.slice(urlMatch[0].length - 1)}`;
}
// Path form: split, resolve `..` / `.`, drop empty segments except for the
// leading slash that indicates absolute paths.
const isAbsolute = target.startsWith("/");
const parts = target.split("/");
const out: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") continue;
if (part === "..") {
if (out.length > 0) out.pop();
continue;
}
out.push(part);
}
return (isAbsolute ? "/" : "") + out.join("/");
}
/**
* Validate that `lease` permits an `operation` with `capability` on `target`.
*
* Throws {@link PermissionDeniedError} if no matching pattern exists.
*
* When v1.1 `ctx.constraints.expires_at` is set and elapsed, throws
* {@link LeaseExpiredError}. When v1.1 `ctx.budgetRemaining` is set and any
* currency counter has dropped to zero or below, throws
* {@link BudgetExhaustedError}. Both checks fire BEFORE the pattern match
* — they bound the lease as a whole, not any single capability.
*/
export interface ValidateLeaseOpInput {
readonly lease: Lease;
readonly capability: string;
readonly target: string;
readonly ctx?: LeaseOpContext;
}
export function validateLeaseOp(input: ValidateLeaseOpInput): void {
const { lease, capability, target, ctx = {} } = input;
checkLeaseExpiration(capability, target, ctx);
checkBudgetExhaustion(capability, target, ctx);
checkCapabilityMatch(lease, capability, target);
}
function checkLeaseExpiration(
capability: string,
target: string,
ctx: LeaseOpContext,
): void {
// v1.1 §9.5: lease expiration check (applies to every operation).
const expiresAt = ctx.constraints?.expires_at;
if (expiresAt === undefined) return;
const now = ctx.now ?? Date.now();
const expiresMs = Date.parse(expiresAt);
if (!Number.isFinite(expiresMs) || now < expiresMs) return;
throw new LeaseExpiredError(`Lease expired at ${expiresAt}`, {
details: { capability, target, expires_at: expiresAt },
});
}
function checkBudgetExhaustion(
capability: string,
target: string,
ctx: LeaseOpContext,
): void {
// v1.1 §9.6: budget exhaustion (across all currencies).
if (ctx.budgetRemaining === undefined || capability === "cost.budget") return;
for (const [currency, remaining] of ctx.budgetRemaining.entries()) {
if (remaining <= 0) {
throw new BudgetExhaustedError(`${currency} budget exhausted`, {
details: { capability, target, currency, remaining },
});
}
}
}
function checkCapabilityMatch(
lease: Lease,
capability: string,
target: string,
): void {
const patterns = lease[capability];
if (patterns === undefined || patterns.length === 0) {
throw new PermissionDeniedError(
`Capability "${capability}" is not granted by this lease`,
{ details: { capability, target } },
);
}
const canonical = canonicalizeTarget(target);
for (const pattern of patterns) {
if (matchGlob(pattern, canonical)) return;
}
throw new PermissionDeniedError(
`Lease denies "${capability}" on "${target}" (canonical "${canonical}")`,
{ details: { capability, target, canonical, patterns } },
);
}
/**
* Compute the initial per-currency budget counters from a lease's
* `cost.budget` patterns. Each pattern MUST parse as `currency:decimal`
* (v1.1 §9.6); when multiple entries share a currency, the values sum.
*/
export function initialBudgetFromLease(lease: Lease): Map<string, number> {
const out = new Map<string, number>();
const patterns = lease["cost.budget"];
if (patterns === undefined) return out;
for (const p of patterns) {
const { currency, amount } = parseBudgetAmount(p);
out.set(currency, (out.get(currency) ?? 0) + amount);
}
return out;
}
/**
* Verify that `child` is a subset of `parent` (§9.4).
*
* Conservative semantics: returns true iff every pattern in every capability
* of `child` is also matched by at least one pattern in the same capability
* of `parent`. If a capability is absent from `parent` but present in `child`,
* it's not a subset.
*
* Pattern-vs-pattern subset is approximated by "every string matching
* `child_pattern` also matches `parent_pattern`". We use a syntactic check:
* each `child_pattern` MUST match against some `parent_pattern` interpreted
* as a regex, AND the parent pattern must be at least as general (we allow
* exact equality and the cases where the parent is a strict super-pattern).
*
* A simple yet correct rule for the common cases: a child pattern `p2` is
* subset-conforming under parent pattern `p1` iff `p1` matches every prefix
* of `p2`'s segment specification. Implementations MAY validate more strictly;
* we err on rejecting ambiguous cases.
*
* v1.1 §9.4: `cost.budget` is compared as numeric per-currency totals (not
* patterns); a child's total per currency MUST NOT exceed the parent's. If
* `parentBudgetRemaining` is supplied, that "remaining" total is used
* instead of the parent's original budget — for delegation that occurs
* mid-execution.
*/
export function isLeaseSubset(
child: Lease,
parent: Lease,
parentBudgetRemaining?: ReadonlyMap<string, number>,
): boolean {
for (const cap of Object.keys(child)) {
const childPatterns = child[cap] ?? [];
if (cap === "cost.budget") {
if (!isBudgetSubset(childPatterns, parent, parentBudgetRemaining)) {
return false;
}
continue;
}
if (!isCapabilitySubset(parent[cap], childPatterns)) return false;
}
return true;
}
function isCapabilitySubset(
parentPatterns: readonly string[] | undefined,
childPatterns: readonly string[],
): boolean {
if (parentPatterns === undefined || parentPatterns.length === 0) return false;
for (const cp of childPatterns) {
if (!patternSubsumes(parentPatterns, cp)) return false;
}
return true;
}
function isBudgetSubset(
childPatterns: readonly string[],
parent: Lease,
parentBudgetRemaining: ReadonlyMap<string, number> | undefined,
): boolean {
const childTotals = sumBudgetPatterns(childPatterns);
if (childTotals === null) return false;
const parentTotals =
parentBudgetRemaining ?? sumBudgetPatterns(parent["cost.budget"] ?? []);
if (parentTotals === null) return false;
for (const [currency, total] of childTotals.entries()) {
const allowed = parentTotals.get(currency);
if (allowed === undefined || total > allowed) return false;
}
return true;
}
function sumBudgetPatterns(
patterns: readonly string[],
): Map<string, number> | null {
const m = new Map<string, number>();
for (const p of patterns) {
try {
const { currency, amount } = parseBudgetAmount(p);
m.set(currency, (m.get(currency) ?? 0) + amount);
} catch {
return null;
}
}
return m;
}
/** Is `child` subsumed by any pattern in `parents`? */
function patternSubsumes(parents: readonly string[], child: string): boolean {
for (const p of parents) {
if (p === child) return true;
// A pattern `p` subsumes `child` if `p` viewed as a regex matches the
// string `child` directly — i.e. every concrete target matching `child`
// is also covered by the broader `p`. This catches the common cases:
// parent="/a/**" subsumes child="/a/b" and child="/a/**".
// parent="*" subsumes child="x".
if (compileGlob(p).test(child)) return true;
}
return false;
}
/**
* Validate a lease at submit time:
* - every capability name must be reserved or `x-vendor.<vendor>.<name>`;
* - every pattern must be a non-empty string.
*
* Throws {@link InvalidRequestError} on malformed leases.
*/
export function validateLeaseShape(lease: Lease): void {
for (const cap of Object.keys(lease)) {
if (!isValidCapabilityName(cap)) {
throw new InvalidRequestError(
`Invalid capability name "${cap}"; must be one of the reserved namespaces or "x-vendor.<vendor>.<cap>"`,
{ details: { capability: cap } },
);
}
validateLeaseCapPatterns(cap, lease[cap] ?? []);
}
}
function validateLeaseCapPatterns(cap: string, patterns: unknown): void {
if (!Array.isArray(patterns)) {
throw new InvalidRequestError(
`Lease capability "${cap}" must map to an array of patterns`,
);
}
for (const pattern of patterns) {
validateLeasePattern(cap, pattern);
}
}
function validateLeasePattern(cap: string, pattern: unknown): void {
if (typeof pattern !== "string" || pattern.length === 0) {
throw new InvalidRequestError(
`Lease capability "${cap}" contains an empty or non-string pattern`,
);
}
// v1.1 §9.6: cost.budget patterns are amount strings, not globs.
if (cap !== "cost.budget") return;
try {
parseBudgetAmount(pattern);
} catch (error) {
throw new InvalidRequestError(
error instanceof Error ? error.message : String(error),
{ details: { capability: cap, pattern } },
);
}
}
/**
* Assert that `child` is a subset of `parent`, raising
* {@link LeaseSubsetViolationError} otherwise.
*
* v1.1: `parentBudgetRemaining` enforces §9.4 — at delegation time, the
* child's `cost.budget` MUST NOT exceed the parent's REMAINING budget.
*/
export function assertLeaseSubset(
child: Lease,
parent: Lease,
parentBudgetRemaining?: ReadonlyMap<string, number>,
): void {
if (!isLeaseSubset(child, parent, parentBudgetRemaining)) {
throw new LeaseSubsetViolationError(
"Child lease is not a subset of parent lease",
{
details: { child, parent },
},
);
}
}
/**
* v1.1 §9.4 / §9.5: assert child's `lease_constraints.expires_at` is at or
* before the parent's. A child with no `expires_at` inherits the parent's
* implicitly (the caller is responsible for that inheritance); this check
* only validates the explicit case.
*/
export function assertLeaseConstraintsSubset(
childConstraints: LeaseConstraints | undefined,
parentConstraints: LeaseConstraints | undefined,
): void {
const childExpiry = childConstraints?.expires_at;
const parentExpiry = parentConstraints?.expires_at;
if (childExpiry === undefined) return;
if (parentExpiry === undefined) return;
const c = Date.parse(childExpiry);
const p = Date.parse(parentExpiry);
if (!Number.isFinite(c) || !Number.isFinite(p)) return;
if (c > p) {
throw new LeaseSubsetViolationError(
"Child lease_constraints.expires_at exceeds parent's expires_at",
{
details: {
child_expires_at: childExpiry,
parent_expires_at: parentExpiry,
},
},
);
}
}
/**
* v1.1 §9.5: validate a submitted `lease_constraints.expires_at` value.
* MUST be ISO 8601 UTC (`Z` suffix) and MUST be in the future.
*
* Returns the parsed millisecond timestamp; throws {@link InvalidRequestError}
* on malformed/past values.
*/
export function validateLeaseConstraints(
constraints: LeaseConstraints | undefined,
now: number = Date.now(),
): number | null {
if (constraints === undefined) return null;
const expiresAt = constraints.expires_at;
if (expiresAt === undefined) return null;
if (!expiresAt.endsWith("Z")) {
throw new InvalidRequestError(
`lease_constraints.expires_at MUST be UTC (suffix "Z")`,
{ details: { expires_at: expiresAt } },
);
}
const ms = Date.parse(expiresAt);
if (!Number.isFinite(ms)) {
throw new InvalidRequestError(
`lease_constraints.expires_at is not a valid ISO 8601 timestamp`,
{ details: { expires_at: expiresAt } },
);
}
if (ms <= now) {
throw new InvalidRequestError(
`lease_constraints.expires_at MUST be in the future`,
{ details: { expires_at: expiresAt, now } },
);
}
return ms;
}
// Re-export helpers that callers may want.
export {
isReservedCapabilityName,
type Lease,
isValidCapabilityName,
} from "@arcp/core/messages";