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
178 changes: 178 additions & 0 deletions packages/kernel-test/src/narrowing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { makeSQLKernelDatabase } from '@metamask/kernel-store/sqlite/nodejs';
import { waitUntilQuiescent } from '@metamask/kernel-utils';
import { kunser } from '@metamask/ocap-kernel';
import type { Kernel, KRef, VatConfig } from '@metamask/ocap-kernel';
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, sep } from 'node:path';
import { describe, expect, it } from 'vitest';

import { getBundleSpec, makeKernel, makeTestLogger } from './utils.ts';

/**
* Every case here is marked `it.fails`, which inverts the verdict: the case is
* green while it fails and turns red once it passes. It ratchets work that lands
* across several pull requests, and the comment on each case names the one that
* removes its marker.
*
* A green `.fails` proves nothing. It passes if the case fails for any reason at
* all — a typo, an unrelated throw, a vat that never launched — so what it buys
* is a forcing function and visible progress, not coverage. Each assertion gets
* its real scrutiny in the pull request that unmarks it.
*/

const V1_ROOT: KRef = 'ko4';

/**
* Launch the narrowing vat, doing every step here so that a rejected config
* fails inside the calling case rather than in a hook, which would error the
* whole file and defeat the ratchet.
*
* @param platformConfig - Platform capabilities to grant the vat, if any.
* @returns The running kernel.
*/
const launchNarrowingVat = async (
platformConfig?: Record<string, unknown>,
): Promise<Kernel> => {
const { logger } = makeTestLogger();
const database = await makeSQLKernelDatabase({});
const kernel = await makeKernel(database, true, logger);
const vat: VatConfig = {
bundleSpec: getBundleSpec('narrowed-fs-vat'),
parameters: {},
};
if (platformConfig) {
// The fs cases configure `fs` in shapes `PlatformConfig` does not describe
// yet. The cast is what keeps this file typechecking while they are pending,
// since `it.fails` cannot absorb a `tsc` failure.
vat.platformConfig = platformConfig as NonNullable<
VatConfig['platformConfig']
>;
}
await kernel.launchSubcluster({ bootstrap: 'main', vats: { main: vat } });
await waitUntilQuiescent();
return kernel;
};

/**
* Invoke one of the vat's probes.
*
* @param kernel - The kernel to send through.
* @param method - The probe to invoke.
* @param args - The probe's arguments.
* @returns `ok:<result>` or `rejected:<message>`, per the vat.
*/
const probe = async (
kernel: Kernel,
method: string,
args: unknown[],
): Promise<unknown> => kunser(await kernel.queueMessage(V1_ROOT, method, args));

/**
* Create a readable file two directories deep, so that a narrowing of the
* directory holding it is strictly narrower than the configured root.
*
* @returns Absolute segment arrays for the tree, and the file's byte length.
*/
const makeTempTree = async (): Promise<{
dir: string;
root: string[];
inner: string[];
file: string[];
sibling: string[];
size: number;
}> => {
const contents = 'narrowed-fs\n';
const dir = await mkdtemp(join(tmpdir(), 'narrowing-'));
await mkdir(join(dir, 'inner'));
await writeFile(join(dir, 'inner', 'hello.txt'), contents);
await writeFile(join(dir, 'sibling.txt'), contents);
const root = dir.split(sep).filter(Boolean);
return {
dir,
root,
inner: [...root, 'inner'],
file: [...root, 'inner', 'hello.txt'],
sibling: [...root, 'sibling.txt'],
size: contents.length,
};
};

describe('narrowing', () => {
// Unmarks at PR-6.
it.fails('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 () => {
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 () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeNarrowed', ['stat', ['srv', 'data', 'x']]),
).toMatch(/^rejected:.*\bstat\b/u);
});

// Unmarks at PR-7.
it.fails('joins two narrowings of a common base', async () => {
const kernel = await launchNarrowingVat();
expect(await probe(kernel, 'probeJoined', [['srv', 'logs', 'y']])).toBe(
'ok:read:srv/logs/y',
);
});

// Unmarks at PR-8, which synthesizes a guard for a `makeDefaultExo` base.
it.fails('narrows a default-guarded exo', async () => {
const kernel = await launchNarrowingVat();
expect(
await probe(kernel, 'probeDefaultGuarded', [['srv', 'data', 'x']]),
).toBe('ok:loose:srv/data/x');
});

// Unmarks at PR-9c. The config here is in today's shape, so the vat launches
// and the case fails on `fs` having no `readFile` method; 9c changes both the
// capability and this config.
it.fails('receives fs as an exo', async () => {
const { dir, file, size } = await makeTempTree();
const kernel = await launchNarrowingVat({
fs: { rootDir: dir, promises: { readFile: true } },
});
expect(await probe(kernel, 'probeFs', [file])).toBe(`ok:${size}`);
});

// Unmarks at PR-10.
it.fails('scopes fs by config', async () => {
const { root, file, size } = await makeTempTree();
const kernel = await launchNarrowingVat({
fs: { root, methods: ['readFile'] },
});
expect(await probe(kernel, 'probeFs', [file])).toBe(`ok:${size}`);
expect(await probe(kernel, 'probeFs', [['etc', 'passwd']])).toMatch(
/^rejected:/u,
);
});

// Unmarks at PR-10.
it.fails('narrows the config-scoped fs further', async () => {
const { root, inner, file, sibling, size } = await makeTempTree();
const kernel = await launchNarrowingVat({
fs: { root, methods: ['readFile'] },
});
expect(await probe(kernel, 'probeFsNarrowed', [inner, file])).toBe(
`ok:${size}`,
);
expect(await probe(kernel, 'probeFsNarrowed', [inner, sibling])).toMatch(
/^rejected:/u,
);
});
});
121 changes: 121 additions & 0 deletions packages/kernel-test/src/vats/narrowed-fs-vat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { E } from '@endo/eventual-send';
import { makeExo } from '@endo/exo';
import { M } from '@endo/patterns';
import { join, narrow, pathUnder } from '@metamask/kernel-utils';
import { makeDefaultExo } from '@metamask/kernel-utils/exo';

/**
* `stat` is named here although the delta drops it, so that a probe can try to
* call it.
*/
type Store = {
read: (segments: string[]) => Promise<string>;
stat: (segments: string[]) => Promise<string>;
};

/**
* The fs endowment's eventual shape, claimed here rather than imported from
* `@metamask/kernel-platforms`, where it does not exist yet. `it.fails` absorbs
* a runtime failure, not a type error.
*/
type FsExo = { readFile: (segments: string[]) => Promise<{ length: number }> };

declare const fs: object;

/**
* Report an invocation's outcome, so a caller can tell a refusal from a result
* without matching on a rejection.
*
* @param call - The invocation to attempt.
* @returns `ok:<result>`, or `rejected:<message>`.
*/
const probe = async (call: () => Promise<unknown>): Promise<string> => {
try {
return `ok:${String(await call())}`;
} catch (error) {
return `rejected:${(error as Error).message}`;
}
};

/**
* Build function for a vat that narrows capabilities — one it builds itself, and
* the `fs` platform endowment.
*
* Every `narrow` and `join` sits outside `probe`, so while they are stubs the
* method rejects instead of reporting `rejected:`. That is what stops a pending
* case from passing on the stub's own error.
*
* @returns The root object for the new vat.
*/
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
export function buildRootObject() {
const base = 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()),
}),
{
read: (segments: string[]) => `read:${segments.join('/')}`,
stat: (segments: string[]) => `stat:${segments.join('/')}`,
},
);

const looseBase = makeDefaultExo('LooseStore', {
read: (segments: string[]) => `loose:${segments.join('/')}`,
});

const underData = { read: [pathUnder(['srv', 'data'])] };

return makeDefaultExo('root', {
bootstrap: () => 'narrowed-fs-vat',

probeNarrowed: async (method: 'read' | 'stat', segments: string[]) => {
const scoped = await narrow<Store>({
name: 'DataStore',
base,
delta: underData,
});
return probe(async () => E(scoped)[method](segments));
},

probeJoined: async (segments: string[]) => {
const data = await narrow<Store>({
name: 'DataStore',
base,
delta: underData,
});
const logs = await narrow<Store>({
name: 'LogStore',
base,
delta: { read: [pathUnder(['srv', 'logs'])] },
});
const both = await join<Store>({
name: 'DataAndLogStore',
refs: [data, logs],
});
return probe(async () => E(both).read(segments));
},

probeDefaultGuarded: async (segments: string[]) => {
const scoped = await narrow<Store>({
name: 'LooseDataStore',
base: looseBase,
delta: underData,
});
return probe(async () => E(scoped).read(segments));
},

probeFs: async (segments: string[]) =>
probe(async () => (await E(fs as FsExo).readFile(segments)).length),

probeFsNarrowed: async (prefix: string[], segments: string[]) => {
const scoped = await narrow<FsExo>({
name: 'ScopedFs',
base: fs,
delta: { readFile: [pathUnder(prefix)] },
});
return probe(async () => (await E(scoped).readFile(segments)).length);
},
});
}
Loading