Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
62 changes: 47 additions & 15 deletions docs/attenuation-by-narrowing.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@ another — rather than by anticipation.

## The library

> **In progress:** the library described in this section has not fully landed.

Exported from `@metamask/kernel-utils`:

```ts
Expand Down Expand Up @@ -159,12 +157,12 @@ delta cannot promote an optional argument to required, and cannot change arity.
It is an error for a delta to name a method the base does not have, or a position
beyond the base's maximum arity when the base has no rest guard.

A position that lands in the rest guard conjoins onto it. Since a rest guard is
one pattern over all trailing arguments, the conjunction constrains every one of
them rather than only the position named, and several such positions conjoin onto
the same guard. `guard(B) ≤ guard(A)` still holds and the forward is still
unaltered, and the surprise runs in the safe direction: the author gets less
authority than intended, never more.
A position that lands in the rest guard still addresses its own argument. A rest
guard is one pattern over the array of trailing arguments, so the patterns at
rest positions are gathered into `M.splitArray([], [p, …])` over that array and
conjoined onto the base's rest guard. They sit in optional slots, so each
constrains its argument when present without making it required, and arguments
past the last pattern are left as the base has them.

### Default-guarded bases

Expand All @@ -185,16 +183,19 @@ This is sound on the same grounds as the general case: the synthesized guard
admits no more calls than the base's own, and the method still forwards
unaltered. Because the interface names no methods, a delta naming a method the
base does not implement is caught at call time rather than at narrowing time.
A method with several rows after a [join](#join) renders by the same rule, with
each row synthesized this way.

### Provenance and flattening

`narrow` records `{ base, delta, baseGuard }` in a `WeakMap` keyed by the exo it
returns. Caching `baseGuard` matters because narrowing a narrowing then needs no
returns, with `delta` lifted to one row per method (see [join](#join)). Caching `baseGuard` matters because narrowing a narrowing then needs no
guard fetch, so a chain of any depth costs one fetch in total.

Narrowing a narrowing **flattens**: `narrow(B, Y)` where `B` is
`{ base: A, delta: X }` records `{ base: A, delta: AND(X, Y) }`, and the returned
exo forwards directly to `A`.
exo forwards directly to `A`. When `X` has several rows for a method, as after a
[join](#join), `Y` is conjoined onto each.

```js
const b = await narrow({ name: 'B', base: a, delta: x });
Expand All @@ -221,21 +222,52 @@ admitting exactly what any of them admits.
// b: { readFile: [pathUnder(['srv', 'data'])] }
// b': { readFile: [pathUnder(['srv', 'logs'])], access: [pathUnder(['srv', 'logs'])] }
await join({ name: 'DataAndLogs', refs: [b, bPrime] });
// -> { readFile: [OR(data, logs)], access: [pathUnder(['srv', 'logs'])] }
// -> { readFile: [[data], [logs]], access: [[pathUnder(['srv', 'logs'])]] }
```

- Every ref must carry a provenance record naming the same base. A ref the
library did not mint throws.
- The unnarrowed base is a legal operand and absorbs, so the lattice has a
representable top and a fold over a list needs no special case.
- The result's method set is the union of the operands'.
- Where two operands name the same method, each argument position is disjoined.
- Where two operands name the same method, their rows are concatenated.

A recorded delta is in disjunctive normal form: each method maps to a non-empty
list of positional rows, each shaped like a `NarrowingDelta` entry, and the
method admits a call that any one row admits. The delta passed to `narrow` is the
one-row case. Disjoining position by position instead would be unsound with
respect to the operands. For

```js
const a = await narrow({ name: 'A', base: fs, delta: { copy: [data, data] } });
const b = await narrow({ name: 'B', base: fs, delta: { copy: [logs, logs] } });
```

it would give `copy: [OR(data, logs), OR(data, logs)]`, which admits
`copy(data/x, logs/y)` though neither operand does. Concatenating rows gives
`copy: [[data, data], [logs, logs]]`, which admits exactly the union.

A missing method and a hole behave differently, which is easier to read as one
rule than two: the join is a union of authority, a method absent from an operand
contributes the empty set, and a hole contributes everything. So a method only
one operand names appears at that operand's delta, while a hole on either side
leaves that position unconstrained in the result.
contributes no rows, and a hole contributes everything. So a method only one
operand names appears at that operand's rows, while a row of holes absorbs every
other row of its method. Beyond that absorption, rows are kept as they are, not
deduplicated or merged.

Narrowing a join distributes over its rows:
`(r1 ∪ … ∪ rn) ∧ x = (r1 ∧ x) ∪ … ∪ (rn ∧ x)`, so the incoming delta is conjoined
onto every row.

A method with one row renders as a positional guard, as `narrow` produces.
Positional guards have no disjunction across positions, so a method with several
rows renders as a guard with no fixed arguments whose rest guard is
`M.or(M.splitArray(required, optional, rest), …)`, one per row, each conjoined
with the base's guard at every position. A rest guard matches the trailing
arguments as one array, so with no fixed arguments it sees the whole argument
array and each row checks arity in full. A row whose base has no rest guard gets
`[]` as its rest, since `M.splitArray` would otherwise default it to `M.any()`
and admit trailing arguments. The rows survive only in the provenance record: a
receiver in another vat sees the rendered guard, not the positional form.

### Guard algebra

Expand Down
252 changes: 252 additions & 0 deletions packages/kernel-test/src/narrowing-library.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { E } from '@endo/eventual-send';
import { makeExo } from '@endo/exo';
import { M } from '@endo/patterns';
import type { Pattern } from '@endo/patterns';
import { join, narrow, pathUnder } from '@metamask/kernel-utils';
import { makeDefaultExo } from '@metamask/kernel-utils/exo';
import { describe, expect, it } from 'vitest';

// These call `narrow` and `join` directly, under the real lockdown this
// package's tests run in, with no kernel or vat.

type Store = {
read: (segments: string[]) => Promise<string>;
stat: (segments: string[]) => Promise<string>;
copy: (from: string[], to: string[]) => Promise<string>;
};

const makeStore = (): object =>
makeExo(
'Store',
M.interface('Store', {
read: M.call(M.arrayOf(M.string())).returns(M.string()),
stat: M.call(M.arrayOf(M.string())).returns(M.string()),
copy: M.call(M.arrayOf(M.string()), M.arrayOf(M.string())).returns(
M.string(),
),
}),
{
read: (segments: string[]) => `read:${segments.join('/')}`,
stat: (segments: string[]) => `stat:${segments.join('/')}`,
copy: (from: string[], to: string[]) =>
`copy:${from.join('/')}->${to.join('/')}`,
},
);

const DATA = pathUnder(['srv', 'data']);
const LOGS = pathUnder(['srv', 'logs']);
const NOT_SECRET: Pattern = M.arrayOf(M.not(M.eq('secret')));

const refusal = (method: string): RegExp => new RegExp(`"${method}"`, 'u');

describe('narrow', () => {
it('admits calls within its delta and refuses the rest', async () => {
const scoped = await narrow<Store>({
name: 'DataStore',
base: makeStore(),
delta: { read: [DATA] },
});

expect(await E(scoped).read(['srv', 'data', 'x'])).toBe('read:srv/data/x');
await expect(E(scoped).read(['etc', 'passwd'])).rejects.toThrow(
refusal('read'),
);
await expect(E(scoped).stat(['srv', 'data', 'x'])).rejects.toThrow(/stat/u);
});

describe('of a narrowing', () => {
const narrowTwice = async (): Promise<{ base: object; twice: Store }> => {
const base = makeStore();
const once = await narrow<Store>({
name: 'DataStore',
base,
delta: { read: [DATA] },
});
const twice = await narrow<Store>({
name: 'PublicDataStore',
base: once,
delta: { read: [NOT_SECRET] },
});
return { base, twice };
};

it.each([
{ segments: ['srv', 'data', 'x'], admitted: true },
{ segments: ['srv', 'data', 'secret'], admitted: false },
{ segments: ['etc', 'passwd'], admitted: false },
])(
'applies both deltas: read($segments) admitted is $admitted',
async ({ segments, admitted }) => {
const { twice } = await narrowTwice();
const call = E(twice).read(segments);

expect(
await call.then(
() => true,
() => false,
),
).toBe(admitted);
},
);

it('refuses to restore a method the first narrowing dropped', async () => {
const once = await narrow<Store>({
name: 'DataStore',
base: makeStore(),
delta: { read: [DATA] },
});

await expect(
narrow<Store>({ name: 'StatStore', base: once, delta: { stat: [] } }),
).rejects.toThrow(
'Cannot narrow method "stat": the base has no such method.',
);
});

it('records the original base', async () => {
const { base, twice } = await narrowTwice();
const joined = await join<Store>({ name: 'Joined', refs: [twice, base] });

expect(await E(joined).stat(['etc', 'passwd'])).toBe('stat:etc/passwd');
});

it('flattens a promise for a narrowing', async () => {
const base = makeStore();
const once = await narrow<Store>({
name: 'DataStore',
base,
delta: { read: [DATA] },
});
const twice = await narrow<Store>({
name: 'PublicDataStore',
base: Promise.resolve(once),
delta: { read: [NOT_SECRET] },
});

await expect(E(twice).read(['etc', 'passwd'])).rejects.toThrow(
refusal('read'),
);
expect(
await E(
await join<Store>({ name: 'Joined', refs: [twice, base] }),
).read(['etc', 'passwd']),
).toBe('read:etc/passwd');
});
});

it('is unaffected by a change to its delta, before or after it settles', async () => {
const base = makeStore();
const delta = { read: [DATA] };

const pending = narrow<Store>({ name: 'DataStore', base, delta });
expect(() => {
delta.read[0] = M.any();
}).toThrow(TypeError);
const scoped = await pending;
expect(() => {
delta.read.push(M.any());
}).toThrow(TypeError);

const joined = await join<Store>({ name: 'Rejoined', refs: [scoped] });
await expect(E(joined).read(['etc', 'passwd'])).rejects.toThrow(
refusal('read'),
);
});
});

describe('join', () => {
const joinCopiers = async (
base: object,
): Promise<{ data: Store; logs: Store; both: Store }> => {
const data = await narrow<Store>({
name: 'DataCopier',
base,
delta: { copy: [DATA, DATA] },
});
const logs = await narrow<Store>({
name: 'LogCopier',
base,
delta: { copy: [LOGS, LOGS], read: [LOGS] },
});
const both = await join<Store>({ name: 'Copier', refs: [data, logs] });
return { data, logs, both };
};

it.each([
{ from: ['srv', 'data', 'x'], to: ['srv', 'data', 'y'], admitted: true },
{ from: ['srv', 'logs', 'x'], to: ['srv', 'logs', 'y'], admitted: true },
{ from: ['srv', 'data', 'x'], to: ['srv', 'logs', 'y'], admitted: false },
{ from: ['etc', 'x'], to: ['etc', 'y'], admitted: false },
])(
'admits exactly what an operand admits: copy($from, $to) admitted is $admitted',
async ({ from, to, admitted }) => {
const { both } = await joinCopiers(makeStore());
const call = E(both).copy(from, to);

expect(
await call.then(
() => true,
() => false,
),
).toBe(admitted);
},
);

it('keeps a method only one operand names at that operand', async () => {
const { both } = await joinCopiers(makeStore());

expect(await E(both).read(['srv', 'logs', 'y'])).toBe('read:srv/logs/y');
await expect(E(both).read(['srv', 'data', 'x'])).rejects.toThrow(
refusal('read'),
);
await expect(E(both).stat(['srv', 'logs', 'y'])).rejects.toThrow(/stat/u);
});

it('admits everything the base admits when the base is a ref', async () => {
const base = makeStore();
const { data } = await joinCopiers(base);
const joined = await join<Store>({
name: 'Everything',
refs: [data, base],
});

expect(await E(joined).copy(['srv', 'data', 'x'], ['etc', 'y'])).toBe(
'copy:srv/data/x->etc/y',
);
expect(await E(joined).stat(['etc', 'passwd'])).toBe('stat:etc/passwd');
});

it('refuses refs narrowed from different bases', async () => {
const { data } = await joinCopiers(makeStore());
const { logs } = await joinCopiers(makeStore());

await expect(
join<Store>({ name: 'Mixed', refs: [data, logs] }),
).rejects.toThrow('Cannot join "Mixed": the refs do not share a base.');
});

it('refuses a ref that is neither a narrowing nor the base', async () => {
const { data } = await joinCopiers(makeStore());

await expect(
join<Store>({ name: 'Stranger', refs: [data, makeStore()] }),
).rejects.toThrow(
'Cannot join "Stranger": ref 1 was not minted by narrowing.',
);
});

it('refuses a default-guarded base as a ref', async () => {
const base = makeDefaultExo('LooseStore', {
read: (segments: string[]) => `loose:${segments.join('/')}`,
});
const scoped = await narrow<Store>({
name: 'LooseDataStore',
base,
delta: { read: [DATA] },
});

await expect(
join<Store>({ name: 'Loose', refs: [scoped, base] }),
).rejects.toThrow('the base guards methods by default');
});
});
Loading
Loading