@ai-ecoverse/slicc-browsers 1.7.1 → 1.7.3
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/README.md +25 -5
- package/guard-driver.mjs +5 -1
- package/guard-runner.mjs +171 -35
- package/guard-vectors.json +2879 -345
- package/package.json +3 -3
- package/schema/guard-vectors.schema.json +89 -56
package/README.md
CHANGED
|
@@ -151,7 +151,7 @@ Every CDP guard runs the vectors in `guard-vectors.json` (schema `schema/guard-v
|
|
|
151
151
|
- `setup` and `trigger`, both lists of steps;
|
|
152
152
|
- `targets`: the protected targets it runs against;
|
|
153
153
|
- `requires?`: the capabilities it needs, the union of its steps' and observations' `requires`. A runner whose transport lacks one reports the vector as `skip` with the reason `not-supported:<capability>`;
|
|
154
|
-
- `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals);
|
|
154
|
+
- `observe?`: each observed name, as `{ "means", "dependsOn"?, "requires"?, "eitherWhen"? }`. `dependsOn` lists the refusals the observation depends on (see Refusals). `eitherWhen` is a check: when it holds on the guarded leg, that observation's expectation becomes `"either"` there (see Design-dependent outcomes);
|
|
155
155
|
- `live`: the liveness check;
|
|
156
156
|
- `control` (`origin: "ordinary"`, `via`, `expected`) and `expected`: an object of names and booleans, or text;
|
|
157
157
|
- `skip?` (`target`, `reason`): `reason` starts with a code, as in `not-applicable: …`.
|
|
@@ -194,7 +194,7 @@ Popups are observed by `Target.targetCreated` with the opener's `openerId`, or b
|
|
|
194
194
|
|
|
195
195
|
**Liveness.** A runner checks `live` through its own CDP, never through the guard path: either the protected document loaded (title, marker or URL), or the guard returned its own refusal for a refusable step. A failing `live` fails the id.
|
|
196
196
|
|
|
197
|
-
**Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. The hard negatives stay required.
|
|
197
|
+
**Design-dependent outcomes.** A key listed in `designDependent` may expect `"either"`, which `true` and `false` both match: a guard that refuses a setup step lets the real document load, and one that blocks before commit doesn't. `detached` is design-dependent on the rows whose guarded leg proves the block (the pre-action beacon plus an exact-URL block or the history form): its `eitherWhen` holds the same proof, so a guard that blocks without detaching passes, and one that neither blocks nor detaches still fails. The hard negatives stay required.
|
|
198
198
|
|
|
199
199
|
**Observability.** `observable.own: false` marks a vector the guard host's own origin can't show (headers, auth, redirects); a runner whose `own` is a static server reports it as `skip` with `not-observable`.
|
|
200
200
|
|
|
@@ -291,7 +291,7 @@ Checks include: `beacon`, `served`, `header` with
|
|
|
291
291
|
`on`, `event` with `where`, `result` with `path` and `equals`, `cookie` with `from`,
|
|
292
292
|
`compare`, `refused`, `reached`, `attached`, `delivered`, `title`, `expression`,
|
|
293
293
|
`hops` (each exact nested redirect URL), `detached`, `loaded`, `pre`, `blocked`,
|
|
294
|
-
`registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
294
|
+
`history`, `frameBlocked`, `registration`, `state` and `filtered`. The diagnostic `executed` and `live`
|
|
295
295
|
checks are not valid liveness proofs.
|
|
296
296
|
Compose checks with `all`, `any` or `not`. Predicates support nested partial
|
|
297
297
|
objects, `$contains`, `$regex`, `$some`, nonempty `$every`, and `$encoded` with
|
|
@@ -307,7 +307,26 @@ observations: a missing or failing effect fails the control. Guarded liveness
|
|
|
307
307
|
requires the exact target URL in the fixture log or a target-origin `/live`
|
|
308
308
|
heartbeat; alternatively a pre-action `/live?tag=...&step=pre&to=...` request and
|
|
309
309
|
an exact-URL block/error from the client or unguarded frame tree. An error for a
|
|
310
|
-
different URL is not evidence.
|
|
310
|
+
different URL is not evidence. `blocked` accepts either `{ url }` for one exact URL
|
|
311
|
+
or `{ anyOf: [url, ...] }` for a nonempty list of exact URLs; each URL can be a
|
|
312
|
+
string or `{ hops, to, index? }`. Both forms allow `session` and `frame`. With `frame`,
|
|
313
|
+
the unguarded observer also reads the page's out-of-process iframe targets (matched by
|
|
314
|
+
their parent frame): a site-isolated iframe isn't in the parent's frame tree, so its
|
|
315
|
+
chrome-error main frame with the exact `unreachableUrl` is the block. `history` (unguarded,
|
|
316
|
+
same URL forms) is for guards that replace the error page, such as with `about:blank#blocked`: it
|
|
317
|
+
needs the pre-action beacon, an entry with the exact URL in the observer's
|
|
318
|
+
`Page.getNavigationHistory` of the page or child, and no fixture request for that URL.
|
|
319
|
+
`frameBlocked` (unguarded) is that out-of-process chrome-error iframe target, and the
|
|
320
|
+
client must be unable to attach to it; the cross-site `C4` rows accept it as `errorFrame`.
|
|
321
|
+
A step that the guard refuses in every context, such as `Network.clearBrowserCookies`
|
|
322
|
+
in `C6.clear-cookies`, is refusable, so refusing it on both legs passes and allowing it
|
|
323
|
+
is judged on its effect. A child step that fails after its child session detached is
|
|
324
|
+
recorded as detached, not as a refusal, and the runner awaits a child's started
|
|
325
|
+
`children` steps before any later `session: "child"` step and before the trigger. Redirect
|
|
326
|
+
vectors list every nested hop URL and the final destination because Chromium
|
|
327
|
+
reports the URL intercepted by the guard. A matching block still requires the
|
|
328
|
+
pre-action beacon alongside it; an unrelated error cannot certify the chain.
|
|
329
|
+
A declared setup refusal can supply liveness only
|
|
311
330
|
when its `refusal.match` matched. Transport connectivity and command completion
|
|
312
331
|
alone never prove liveness. Cookie and storage rows seed a canary outside the
|
|
313
332
|
guard and read it back outside the guard; refused writes require unchanged
|
|
@@ -485,8 +504,9 @@ semantic-release cuts one version from `main` for all three packages (`.github/w
|
|
|
485
504
|
1. The tag `vX.Y.Z` and a GitHub release are the Go module's release. `scripts/major-guard.mjs` fails the release when the major doesn't fit the module path in `go.mod` (no suffix allows v0 and v1, `/vN` only vN). The workflow then asks `proxy.golang.org` for the new version and `pkg.go.dev` to fetch it; both only warn.
|
|
486
505
|
2. **npm:** with the repository variable `SLICC_BROWSERS_NPM_PUBLISH` set to `true`, the package is stamped with the version and published through npm trusted publishing (OIDC, with provenance). A version that is already on npm is skipped.
|
|
487
506
|
3. **crates.io:** with the repository variable `SLICC_BROWSERS_CRATES_PUBLISH` set to `true`, `Cargo.toml` is stamped with the version and `cargo publish` runs with a short-lived token from crates.io trusted publishing (`rust-lang/crates-io-auth-action`). A version that is already on crates.io is skipped.
|
|
507
|
+
4. **Cert line:** after the npm publish, the workflow runs `npm pack @ai-ecoverse/slicc-browsers@X.Y.Z` against the registry and appends `cert: @ai-ecoverse/slicc-browsers@X.Y.Z sha256=<hex> gitHead=<sha>` to the `vX.Y.Z` GitHub release and the job summary, once; the cert lane copies it from there.
|
|
488
508
|
|
|
489
|
-
A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag.
|
|
509
|
+
A run started by hand publishes what's missing without a new release: `gh workflow run release.yml --ref main` when `main`'s head has its tag, or `gh workflow run release.yml --ref main -f tag=vX.Y.Z` for any existing tag. `gh workflow run release.yml --ref main -f cert-only=true` only adds the cert line for the latest npm version.
|
|
490
510
|
|
|
491
511
|
**Go module versions.** A pushed tag is the release: `proxy.golang.org` caches it and `sum.golang.org` records its hash for good. Never move or delete a pushed tag; retract a bad version in `go.mod` and release anew. A `BREAKING CHANGE` needs a `/vN` module path first.
|
|
492
512
|
|
package/guard-driver.mjs
CHANGED
|
@@ -125,7 +125,11 @@ export function judge(vector, side, observed) {
|
|
|
125
125
|
(vector.observe?.[k]?.dependsOn ?? []).some((d) => refused.includes(d))
|
|
126
126
|
);
|
|
127
127
|
const mismatches = Object.entries(want)
|
|
128
|
-
.filter(
|
|
128
|
+
.filter(
|
|
129
|
+
([k, v]) =>
|
|
130
|
+
!skipped.includes(k) &&
|
|
131
|
+
!matches(observed?.[`${k}.either`] === true ? 'either' : v, observed?.[k])
|
|
132
|
+
)
|
|
129
133
|
.map(([k, v]) => `${k}: got ${q(observed?.[k])}, want ${q(v)}`);
|
|
130
134
|
return { pass: mismatches.length === 0, refused, skipped, mismatches };
|
|
131
135
|
}
|
package/guard-runner.mjs
CHANGED
|
@@ -108,6 +108,7 @@ class Execution {
|
|
|
108
108
|
this.answers = [];
|
|
109
109
|
this.attachSettings = new Map();
|
|
110
110
|
this.childSteps = [];
|
|
111
|
+
this.childRuns = new Map();
|
|
111
112
|
this.processed = new Set();
|
|
112
113
|
this.eventStart = this.client.events.length;
|
|
113
114
|
this.actionEventStart = this.eventStart;
|
|
@@ -245,7 +246,7 @@ class Execution {
|
|
|
245
246
|
}
|
|
246
247
|
|
|
247
248
|
async blocked(spec, observer) {
|
|
248
|
-
const
|
|
249
|
+
const urls = (spec.anyOf ?? [spec.url]).map(address);
|
|
249
250
|
const events = this.client.events.slice(this.eventStart);
|
|
250
251
|
if (observer === 'client') {
|
|
251
252
|
const requests = new Map(
|
|
@@ -259,10 +260,10 @@ class Execution {
|
|
|
259
260
|
(!spec.frame ||
|
|
260
261
|
event.params.frame.parentId ||
|
|
261
262
|
event.sessionId === this.sessions.child) &&
|
|
262
|
-
event.params.frame.unreachableUrl
|
|
263
|
+
urls.includes(event.params.frame.unreachableUrl) &&
|
|
263
264
|
event.params.frame.url.startsWith('chrome-error://')) ||
|
|
264
265
|
(event.method === 'Network.loadingFailed' &&
|
|
265
|
-
requests.get(event.params.requestId)
|
|
266
|
+
urls.includes(requests.get(event.params.requestId)) &&
|
|
266
267
|
(event.params.blockedReason || event.params.errorText === 'net::ERR_BLOCKED_BY_CLIENT'))
|
|
267
268
|
);
|
|
268
269
|
}
|
|
@@ -279,15 +280,103 @@ class Execution {
|
|
|
279
280
|
const visit = (tree) =>
|
|
280
281
|
Boolean(
|
|
281
282
|
tree &&
|
|
282
|
-
((tree.frame.unreachableUrl
|
|
283
|
+
((urls.includes(tree.frame.unreachableUrl) &&
|
|
284
|
+
tree.frame.url.startsWith('chrome-error://')) ||
|
|
283
285
|
(tree.childFrames ?? []).some(visit))
|
|
284
286
|
);
|
|
285
|
-
|
|
287
|
+
if (
|
|
286
288
|
read.ok &&
|
|
287
289
|
(spec.frame
|
|
288
290
|
? (read.result.frameTree.childFrames ?? []).some(visit)
|
|
289
291
|
: visit(read.result.frameTree))
|
|
292
|
+
)
|
|
293
|
+
return true;
|
|
294
|
+
return spec.frame ? Boolean(await this.blockedFrameTarget(urls, targetId)) : false;
|
|
295
|
+
} finally {
|
|
296
|
+
await outcome(
|
|
297
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
290
298
|
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async frameIds(targetId) {
|
|
303
|
+
const attached = await outcome(
|
|
304
|
+
this.reader.send('Target.attachToTarget', { targetId, flatten: true })
|
|
305
|
+
);
|
|
306
|
+
if (!attached.ok) return [];
|
|
307
|
+
try {
|
|
308
|
+
const read = await outcome(
|
|
309
|
+
this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
|
|
310
|
+
);
|
|
311
|
+
const ids = (tree) => [tree.frame.id, ...(tree.childFrames ?? []).flatMap(ids)];
|
|
312
|
+
return read.ok ? ids(read.result.frameTree) : [];
|
|
313
|
+
} finally {
|
|
314
|
+
await outcome(
|
|
315
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async blockedFrameTarget(urls, parentTarget) {
|
|
321
|
+
const parents = await this.frameIds(parentTarget);
|
|
322
|
+
const listed = await outcome(this.reader.send('Target.getTargets', {}));
|
|
323
|
+
if (!listed.ok) return undefined;
|
|
324
|
+
for (const info of listed.result.targetInfos) {
|
|
325
|
+
if (info.type !== 'iframe' || !parents.includes(info.parentFrameId)) continue;
|
|
326
|
+
const attached = await outcome(
|
|
327
|
+
this.reader.send('Target.attachToTarget', { targetId: info.targetId, flatten: true })
|
|
328
|
+
);
|
|
329
|
+
if (!attached.ok) continue;
|
|
330
|
+
try {
|
|
331
|
+
const read = await outcome(
|
|
332
|
+
this.reader.send('Page.getFrameTree', {}, attached.result.sessionId)
|
|
333
|
+
);
|
|
334
|
+
const frame = read.result?.frameTree?.frame;
|
|
335
|
+
if (frame?.url.startsWith('chrome-error://') && urls.includes(frame.unreachableUrl))
|
|
336
|
+
return info.targetId;
|
|
337
|
+
} finally {
|
|
338
|
+
await outcome(
|
|
339
|
+
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async frameBlocked(spec) {
|
|
347
|
+
const urls = (spec.anyOf ?? [spec.url]).map(address);
|
|
348
|
+
const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
|
|
349
|
+
if (!targetId) return false;
|
|
350
|
+
const frame = await this.blockedFrameTarget(urls, targetId);
|
|
351
|
+
if (!frame) return false;
|
|
352
|
+
const attached = await outcome(
|
|
353
|
+
this.client.send('Target.attachToTarget', { targetId: frame, flatten: true })
|
|
354
|
+
);
|
|
355
|
+
if (attached.ok)
|
|
356
|
+
await outcome(
|
|
357
|
+
this.client.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
358
|
+
);
|
|
359
|
+
return !attached.ok;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async history(spec) {
|
|
363
|
+
const urls = (spec.anyOf ?? [spec.url]).map(address);
|
|
364
|
+
const targetId = spec.session === 'child' ? this.targets.at(-1) : this.pageTarget;
|
|
365
|
+
if (!targetId) return false;
|
|
366
|
+
const pre = await Promise.all(urls.map((url) => this.attemptCheck({ pre: url })));
|
|
367
|
+
if (!pre.some(Boolean)) return false;
|
|
368
|
+
const loaded = await Promise.all(urls.map((url) => this.attemptCheck({ loaded: url })));
|
|
369
|
+
const unloaded = urls.filter((_, index) => !loaded[index]);
|
|
370
|
+
if (!unloaded.length) return false;
|
|
371
|
+
const attached = await outcome(
|
|
372
|
+
this.reader.send('Target.attachToTarget', { targetId, flatten: true })
|
|
373
|
+
);
|
|
374
|
+
if (!attached.ok) return false;
|
|
375
|
+
try {
|
|
376
|
+
const read = await outcome(
|
|
377
|
+
this.reader.send('Page.getNavigationHistory', {}, attached.result.sessionId)
|
|
378
|
+
);
|
|
379
|
+
return read.ok && read.result.entries.some((entry) => unloaded.includes(entry.url));
|
|
291
380
|
} finally {
|
|
292
381
|
await outcome(
|
|
293
382
|
this.reader.send('Target.detachFromTarget', { sessionId: attached.result.sessionId })
|
|
@@ -348,34 +437,34 @@ class Execution {
|
|
|
348
437
|
continue;
|
|
349
438
|
this.sessions.child = event.params.sessionId;
|
|
350
439
|
if (info?.targetId) this.targets.push(info.targetId);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
await outcome(
|
|
359
|
-
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
360
|
-
);
|
|
361
|
-
await this.poll(
|
|
362
|
-
() =>
|
|
363
|
-
this.client.events
|
|
364
|
-
.slice(start)
|
|
365
|
-
.some(
|
|
366
|
-
(e) =>
|
|
367
|
-
e.method === 'Page.loadEventFired' &&
|
|
368
|
-
e.sessionId === event.params.sessionId
|
|
369
|
-
),
|
|
370
|
-
1500
|
|
371
|
-
);
|
|
372
|
-
await this.execute(this.childSteps.slice(reload), event.params.sessionId);
|
|
373
|
-
}
|
|
440
|
+
const child = event.params.sessionId;
|
|
441
|
+
const started = (async () => {
|
|
442
|
+
const reload = this.childSteps.findIndex((step) => step.cdp === 'Page.reload');
|
|
443
|
+
if (reload < 0) await this.execute(this.childSteps, event.params.sessionId);
|
|
444
|
+
else {
|
|
445
|
+
await this.execute(this.childSteps.slice(0, reload), event.params.sessionId);
|
|
446
|
+
const start = this.client.events.length;
|
|
374
447
|
await outcome(
|
|
375
448
|
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
376
449
|
);
|
|
377
|
-
|
|
378
|
-
|
|
450
|
+
await this.poll(
|
|
451
|
+
() =>
|
|
452
|
+
this.client.events
|
|
453
|
+
.slice(start)
|
|
454
|
+
.some(
|
|
455
|
+
(e) =>
|
|
456
|
+
e.method === 'Page.loadEventFired' && e.sessionId === event.params.sessionId
|
|
457
|
+
),
|
|
458
|
+
1500
|
|
459
|
+
);
|
|
460
|
+
await this.execute(this.childSteps.slice(reload), event.params.sessionId);
|
|
461
|
+
}
|
|
462
|
+
await outcome(
|
|
463
|
+
this.client.send('Runtime.runIfWaitingForDebugger', {}, event.params.sessionId)
|
|
464
|
+
);
|
|
465
|
+
})().catch((error) => this.errors.push(String(error.message ?? error)));
|
|
466
|
+
this.childRuns.set(child, started);
|
|
467
|
+
this.pending.push(started);
|
|
379
468
|
}
|
|
380
469
|
}
|
|
381
470
|
await sleep(10);
|
|
@@ -409,23 +498,40 @@ class Execution {
|
|
|
409
498
|
this.calls[`answer.${p.requestId}.${event.method}`] = out;
|
|
410
499
|
}
|
|
411
500
|
|
|
501
|
+
async childrenSettled() {
|
|
502
|
+
await Promise.all([...this.childRuns.values()]);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
detached(session) {
|
|
506
|
+
return this.client.events
|
|
507
|
+
.slice(this.eventStart)
|
|
508
|
+
.some((e) => e.method === 'Target.detachedFromTarget' && e.params?.sessionId === session);
|
|
509
|
+
}
|
|
510
|
+
|
|
412
511
|
async execute(list, childSession, phase = 'child') {
|
|
512
|
+
if (phase === 'trigger') await this.childrenSettled();
|
|
413
513
|
for (const raw of list) {
|
|
414
514
|
const step = expand(raw, this.vars);
|
|
415
515
|
if (!supported(step.requires, this.options.capabilities) || step.text) continue;
|
|
516
|
+
if (!childSession && step.session === 'child') await this.childrenSettled();
|
|
416
517
|
const session = childSession ?? this.session(step.session);
|
|
417
518
|
const out = await this.operation(step, session, phase);
|
|
418
519
|
if (!out) continue;
|
|
520
|
+
const gone = Boolean(childSession) && !out.ok && this.detached(childSession);
|
|
521
|
+
const refused =
|
|
522
|
+
!gone &&
|
|
523
|
+
(step.refusable
|
|
524
|
+
? isRefusal(out, step)
|
|
525
|
+
: /refused/.test(String(out.error?.message ?? out.error)));
|
|
419
526
|
this.trace.push({
|
|
420
527
|
phase,
|
|
421
528
|
declared: Boolean(step.refusable),
|
|
422
529
|
session,
|
|
423
530
|
out,
|
|
424
|
-
refused
|
|
425
|
-
|
|
426
|
-
: /refused/.test(String(out.error?.message ?? out.error)),
|
|
531
|
+
refused,
|
|
532
|
+
...(gone ? { detached: true } : {}),
|
|
427
533
|
});
|
|
428
|
-
if (step.refusable) this.refused[step.refusal.name] =
|
|
534
|
+
if (step.refusable) this.refused[step.refusal.name] = refused;
|
|
429
535
|
if (step.as && !out.result?.pending) this.calls[step.as] = out;
|
|
430
536
|
if (step.cdp) (this.calls.$cdp ??= []).push({ method: step.cdp, ...out });
|
|
431
537
|
this.options.debug?.({ step, out, side: this.side });
|
|
@@ -624,6 +730,7 @@ class Execution {
|
|
|
624
730
|
);
|
|
625
731
|
if (c.refused || c.reached) return this.callCheck(c);
|
|
626
732
|
if (c.detached) {
|
|
733
|
+
const events = this.client.events.slice(this.eventStart, this.cleanupEventStart);
|
|
627
734
|
const session = this.sessions[c.detached];
|
|
628
735
|
return (
|
|
629
736
|
Boolean(session) &&
|
|
@@ -669,6 +776,8 @@ class Execution {
|
|
|
669
776
|
);
|
|
670
777
|
if (c.state) return this.readState(c.state);
|
|
671
778
|
if (c.blocked) return this.blocked(c.blocked, c.observer);
|
|
779
|
+
if (c.history) return this.history(c.history);
|
|
780
|
+
if (c.frameBlocked) return this.frameBlocked(c.frameBlocked);
|
|
672
781
|
if (c.pre || c.loaded) return this.attemptCheck(c);
|
|
673
782
|
}
|
|
674
783
|
|
|
@@ -784,6 +893,8 @@ class Execution {
|
|
|
784
893
|
if (!supported(observation.requires, this.options.capabilities)) continue;
|
|
785
894
|
if ((observation.dependsOn ?? []).some((dep) => this.refused[dep])) continue;
|
|
786
895
|
observed[name] = await this.check(observation.check);
|
|
896
|
+
if (observation.eitherWhen && this.side === 'guarded')
|
|
897
|
+
observed[`${name}.either`] = await this.check(observation.eitherWhen);
|
|
787
898
|
}
|
|
788
899
|
this.options.debug?.({
|
|
789
900
|
events: this.client.events.slice(this.eventStart),
|
|
@@ -824,7 +935,9 @@ const observerRequired = (check) => {
|
|
|
824
935
|
const needsObserver = (vector) =>
|
|
825
936
|
vector.control.via === 'unguarded' ||
|
|
826
937
|
observerRequired(vector.liveCheck) ||
|
|
827
|
-
Object.values(vector.observe ?? {}).some(
|
|
938
|
+
Object.values(vector.observe ?? {}).some(
|
|
939
|
+
(observation) => observerRequired(observation.check) || observerRequired(observation.eitherWhen)
|
|
940
|
+
);
|
|
828
941
|
|
|
829
942
|
const inactive = (vector, target, capabilities) =>
|
|
830
943
|
vector.skip?.target === target ||
|
|
@@ -938,6 +1051,28 @@ async function executeRow(job, opts, clients, set, slot, index) {
|
|
|
938
1051
|
return row;
|
|
939
1052
|
}
|
|
940
1053
|
|
|
1054
|
+
const invalidBlocked = (check) =>
|
|
1055
|
+
Boolean(
|
|
1056
|
+
check?.blocked &&
|
|
1057
|
+
((check.blocked.url === undefined) === (check.blocked.anyOf === undefined) ||
|
|
1058
|
+
(check.blocked.anyOf !== undefined &&
|
|
1059
|
+
(!Array.isArray(check.blocked.anyOf) || check.blocked.anyOf.length === 0)))
|
|
1060
|
+
) ||
|
|
1061
|
+
[...(check?.all ?? []), ...(check?.any ?? []), ...(check?.not ? [check.not] : [])].some(
|
|
1062
|
+
invalidBlocked
|
|
1063
|
+
);
|
|
1064
|
+
|
|
1065
|
+
function validateBlocked(vectors) {
|
|
1066
|
+
for (const vector of vectors)
|
|
1067
|
+
if (
|
|
1068
|
+
[
|
|
1069
|
+
vector.liveCheck,
|
|
1070
|
+
...Object.values(vector.observe ?? {}).flatMap((o) => [o.check, o.eitherWhen]),
|
|
1071
|
+
].some(invalidBlocked)
|
|
1072
|
+
)
|
|
1073
|
+
throw new TypeError(`${vector.id}: blocked needs exactly one url or nonempty anyOf`);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
941
1076
|
function validate(set, opts) {
|
|
942
1077
|
if (!opts.fixture?.log) throw new TypeError('fixture.log is required');
|
|
943
1078
|
for (const vector of set.vectors)
|
|
@@ -954,6 +1089,7 @@ function validate(set, opts) {
|
|
|
954
1089
|
for (const vector of set.vectors)
|
|
955
1090
|
if (vector.kind === 'cdp' && invalidLive(vector.liveCheck))
|
|
956
1091
|
throw new TypeError(`${vector.id}: liveCheck needs guarded attempt evidence`);
|
|
1092
|
+
validateBlocked(set.vectors);
|
|
957
1093
|
const variants = Array.isArray(opts.variant) ? opts.variant : [opts.variant];
|
|
958
1094
|
for (const name of variants)
|
|
959
1095
|
if (!(name in set.variants)) throw new TypeError(`Unknown variant: ${name}`);
|