Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions packages/kernel-test/src/narrowing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,24 +99,21 @@ const makeTempTree = async (): Promise<{
};

describe('narrowing', () => {
// Unmarks at PR-6.
it.fails('narrows a vat-local exo', async () => {
it('narrows a vat-local exo', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['read', ['srv', 'data', 'x']]),
).toBe('ok:read:srv/data/x');
});

// Unmarks at PR-6.
it.fails('rejects a call outside the narrowing', async () => {
it('rejects a call outside the narrowing', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['read', ['etc', 'passwd']]),
).toMatch(/^rejected:.*\bread\b/u);
});

// Unmarks at PR-6.
it.fails('drops methods absent from the delta', async () => {
it('drops methods absent from the delta', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['stat', ['srv', 'data', 'x']]),
Expand Down
3 changes: 2 additions & 1 deletion packages/kernel-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add `narrowInterfaceGuard` and the `NarrowingDelta` type, which derive the interface guard of a narrowing: each delta pattern is conjoined onto the base's guard at the argument position it addresses, arity and return guards are inherited verbatim, methods the delta does not name are dropped, and every method guard is asyncified for forwarding ([#1051](https://github.com/MetaMask/ocap-kernel/pull/1051))
- Add `pathUnder(segments)`, which builds an `@endo/patterns` pattern matching segment arrays under a prefix, with `..` excluded past the prefix so that traversal out of it is unrepresentable. Empty `segments` matches every `..`-free segment array ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049))
- Add `narrow({ name, base, delta })` and `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **Both functions throw for now** — this release fixes their signatures so callers and deltas can be written against them, and the implementations follow ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049))
- Add `narrow({ name, base, delta })`, which reads the base's interface guard over `E()` and returns an exo under the derived guard whose methods forward to the base. Narrowing a narrowing flattens: the result forwards straight to the original base under both deltas conjoined, so a chain of any depth stays one hop deep and costs one guard read. Every method of the result returns a promise ([#1052](https://github.com/MetaMask/ocap-kernel/pull/1052))
- Add `join({ name, refs })`, plus the `NarrowingDelta`, `NarrowOptions`, and `JoinOptions` types, for deriving a capability that forwards to a base under a narrower interface guard. **`join` throws for now** — this release fixes its signature so callers and deltas can be written against it, and the implementation follows ([#1049](https://github.com/MetaMask/ocap-kernel/pull/1049))
- Add `getInterfaceMethodGuards`, `getMethodPayload`, `getGuardAt`, `buildMethodGuard`, and `asyncifyMethodGuards`, plus the `MethodGuardPayload` type, for reading an `@endo/patterns` interface guard by argument position — required arguments, then optionals, then the rest guard — and reassembling it ([#1048](https://github.com/MetaMask/ocap-kernel/pull/1048))
- Add `makeGuardedFetch` and the `FetchGuard` type, which wrap a `fetch` so that a guard runs before every request it makes, redirect hops included ([#1026](https://github.com/MetaMask/ocap-kernel/pull/1026))
- `redirect: 'follow'`, in the caller's `init` or on a `Request`, is overridden so that each hop can be checked; `manual` and `error` are honoured. `baseFetch` is therefore always called with `redirect: 'manual'` and must honour it
Expand Down
48 changes: 47 additions & 1 deletion packages/kernel-utils/src/narrow-interface-guard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { describe, it, expect } from 'vitest';

import { getInterfaceMethodGuards, getMethodPayload } from './guard-algebra.ts';
import type { MethodGuardPayload } from './guard-algebra.ts';
import { narrowInterfaceGuard } from './narrow-interface-guard.ts';
import {
conjoinDeltas,
narrowInterfaceGuard,
} from './narrow-interface-guard.ts';
import type { NarrowingDelta } from './narrow-interface-guard.ts';

// One fixture reaching all three positional categories.
Expand Down Expand Up @@ -137,3 +140,46 @@ describe('narrowInterfaceGuard', () => {
expect(() => narrowFrom(makeGuard(), delta)).toThrow(message);
});
});

describe('conjoinDeltas', () => {
it('keeps only the methods the incoming delta names', () => {
const combined = conjoinDeltas({ read: [], write: [] }, { read: [] });

expect(combined).toStrictEqual({ read: [] });
});

it('does not reinstate a method the existing delta dropped', () => {
expect(() => conjoinDeltas({ read: [] }, { write: [] })).toThrow(
'Cannot narrow method "write": the base has no such method.',
);
});

it('conjoins both patterns where the two deltas overlap', () => {
const combined = conjoinDeltas({ read: [M.lte(10)] }, { read: [M.gte(5)] });
const [pattern] = combined.read!;

expect(matches(7, pattern)).toBe(true);
expect(matches(2, pattern)).toBe(false);
expect(matches(20, pattern)).toBe(false);
});

it.each([
{ side: 'the existing delta', existing: [M.lte(10)], incoming: [] },
{ side: 'the incoming delta', existing: [], incoming: [M.lte(10)] },
])('carries a pattern held only by $side', ({ existing, incoming }) => {
const combined = conjoinDeltas({ read: existing }, { read: incoming });
const [pattern] = combined.read!;

expect(matches(7, pattern)).toBe(true);
expect(matches(20, pattern)).toBe(false);
});

it('leaves a position both deltas hole as unconstrained', () => {
const combined = conjoinDeltas(
{ read: [undefined, M.lte(10)] },
{ read: [undefined] },
);

expect(combined.read![0]).toBeUndefined();
});
});
38 changes: 38 additions & 0 deletions packages/kernel-utils/src/narrow-interface-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,44 @@ export type NarrowingDelta = Record<string, (Pattern | undefined)[]>;
const conjoin = (base: Pattern, pattern: Pattern | undefined): Pattern =>
pattern === undefined ? base : M.and(base, pattern);

/**
* Conjoin two deltas, so that narrowing a narrowing is one delta against the
* original base.
*
* Keys come from `incoming` alone, since it drops what it does not name, and a
* key it names that `existing` does not is a method the narrowing being
* narrowed no longer has. At each position a hole on either side leaves the
* other in place.
*
* @param existing - The delta the base was narrowed by.
* @param incoming - The delta narrowing it further.
* @returns The combined delta.
*/
export const conjoinDeltas = (
existing: NarrowingDelta,
incoming: NarrowingDelta,
): NarrowingDelta => {
const combined: NarrowingDelta = {};
for (const [methodName, patterns] of Object.entries(incoming)) {
const inherited = existing[methodName];
if (inherited === undefined) {
throw new Error(
`Cannot narrow method "${methodName}": the base has no such method.`,
);
}
const length = Math.max(inherited.length, patterns.length);
combined[methodName] = Array.from({ length }, (_, index) => {
const left = inherited[index];
const right = patterns[index];
if (left === undefined) {
return right;
}
return conjoin(left, right);
});
}
return combined;
};

/**
* Narrow one method guard by conjoining the delta's patterns onto the
* positions they address.
Expand Down
13 changes: 4 additions & 9 deletions packages/kernel-utils/src/narrowing.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import { matches } from '@endo/patterns';
import { describe, it, expect } from 'vitest';

import { join, narrow, pathUnder } from './narrowing.ts';
import { join, pathUnder } from './narrowing.ts';

const makeBase = (): object => ({ readFile: () => 'contents' });

describe('narrow', () => {
it('is not implemented', async () => {
await expect(
narrow({ name: 'Scoped', base: makeBase(), delta: { readFile: [] } }),
).rejects.toThrow('narrow is not implemented');
});
});

// `narrow` is exercised end to end in `@ocap/kernel-test`. It cannot be
// exercised here: `E` reads `globalThis.HandledPromise` when it loads, and this
// package's tests run under `mock-endoify`, which sets that to plain `Promise`.
describe('join', () => {
it('is not implemented', async () => {
await expect(
Expand Down
110 changes: 102 additions & 8 deletions packages/kernel-utils/src/narrowing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
// `E` comes from `@endo/captp`'s re-export because this package already depends
// on captp; `@endo/eventual-send` would be lighter but is not a dependency.
import { E } from '@endo/captp';
import { GET_INTERFACE_GUARD, makeExo } from '@endo/exo';
import type { Guarded, Methods } from '@endo/exo';
import { M } from '@endo/patterns';
import type { Pattern } from '@endo/patterns';
import type { InterfaceGuard, MethodGuard, Pattern } from '@endo/patterns';

import { getInterfaceMethodGuards } from './guard-algebra.ts';
import {
conjoinDeltas,
narrowInterfaceGuard,
} from './narrow-interface-guard.ts';
import type { NarrowingDelta } from './narrow-interface-guard.ts';

/**
Expand All @@ -20,23 +29,108 @@ export type JoinOptions = {
refs: object[];
};

type GuardBearer = {
[GET_INTERFACE_GUARD]: () => InterfaceGuard | undefined;
};

type Forwardable = Record<string, (...args: unknown[]) => unknown>;

type Provenance = {
base: object;
delta: NarrowingDelta;
baseGuard: InterfaceGuard;
};

/**
* Not yet implemented; throws.
* What each minted narrowing was minted from. `join` reads it to establish that
* its operands share a base, and `narrow` reads it to flatten a chain.
*/
const provenance = new WeakMap<object, Provenance>();

/**
* Build a method that forwards one call to the target over `E()`.
*
* `E()` answers every property with a method-invoker, so the index is optional
* only to the type system. Invoking one for a method the target lacks rejects,
* which is what a caller holding no such authority must see rather than a
* silent `undefined`. The invoker cannot be hoisted out of the call: `E()`
* refuses one invoked detached from the proxy that produced it.
*
* @param target - The capability to forward to.
* @param methodName - The method to forward.
* @returns The forwarding method.
*/
const makeForwarder =
(target: object, methodName: string) =>
async (...args: unknown[]): Promise<unknown> => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return E(target as Forwardable)[methodName]!(...args);
};

/**
* Conjoin each pattern of `delta` onto the corresponding argument position of
* `base`'s interface guard, and return an exo under that derived guard whose
* methods forward to `base`.
*
* The base's guard is read over `E()` whether or not the base is local, so that
* one code path survives the base later being a cross-vat presence. Narrowing a
* narrowing needs no such read, since the first one cached what it fetched, and
* it flattens: the result forwards straight to the original base under the
* conjunction of both deltas, so a chain of any depth stays one hop deep and
* `join` reaches across the whole narrowing tree rather than between siblings
* only.
*
* Nothing checks arguments beyond the returned exo's own guard.
*
* `Narrowed` describes the resulting method set, which is derived at runtime and
* so cannot be inferred; supply it to call the result through `E()`.
* so cannot be inferred; supply it to call the result through `E()`. Every
* method of the result returns a promise, since the derived guards are
* `M.callWhen`.
*
* @param _options - The narrowing to mint.
* @param options - The narrowing to mint.
* @param options.name - The name for the derived exo and its interface guard.
* @param options.base - The capability to narrow.
* @param options.delta - The patterns to conjoin, by method and position.
* @returns The narrowing of the base.
*/
export const narrow = async <Narrowed extends Methods = Methods>(
_options: NarrowOptions,
): Promise<Guarded<Narrowed>> => {
throw new Error('narrow is not implemented');
export const narrow = async <Narrowed extends Methods = Methods>({
name,
base,
delta,
}: NarrowOptions): Promise<Guarded<Narrowed>> => {
const inherited = provenance.get(base);
const target = inherited?.base ?? base;
const combined = inherited ? conjoinDeltas(inherited.delta, delta) : delta;
const baseGuard =
inherited?.baseGuard ??
(await E(base as GuardBearer)[GET_INTERFACE_GUARD]());
if (baseGuard === undefined) {
throw new Error(
`Cannot narrow "${name}": the base has no interface guard.`,
);
}

const derivedGuard = narrowInterfaceGuard({
name,
baseGuard,
delta: combined,
});
const methods = Object.fromEntries(
Object.keys(getInterfaceMethodGuards(derivedGuard)).map((methodName) => [
methodName,
makeForwarder(target, methodName),
]),
) as unknown as Narrowed;

// The derived guard's method set is computed at runtime, so nothing ties it
// to `Narrowed` at the type level.
const narrowed = makeExo(
name,
derivedGuard as InterfaceGuard<{ [Method in keyof Narrowed]: MethodGuard }>,
methods,
);
provenance.set(narrowed, { base: target, delta: combined, baseGuard });
return narrowed;
};

/**
Expand Down
Loading