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: 7 additions & 2 deletions packages/kernel-platforms/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed

- **BREAKING:** Vend the `fs` capability as an exo taking absolute path segments, replacing the `node:fs` lookalike record of functions ([#1057](https://github.com/Consensys-Incorporated/ocap-kernel/pull/1057))
- Call it as `await E(fs).readFile(['srv', 'data', 'x'])`. Methods share one flat namespace, so `promises.readFile` is now `readFile`, and a segment may not be empty, `.`, `..`, or contain a path separator.

- Call it as `await E(fs).readFile(['srv', 'data', 'x'], 'utf8')`. Methods share one flat namespace, so `promises.readFile` is now `readFile`, and a segment may not be empty, `.`, `..`, or contain a path separator.
- `readFile` requires an encoding and resolves a string. Without one Node resolves a `Buffer`, and no typed array can cross an exo boundary, so reading raw bytes is not available.
- `existsSync` and every other synchronous operation are gone. A narrowed method forwards through `E()`, so nothing synchronous can survive narrowing.
- Config is `{ root: ['srv', 'data'], methods: ['readFile'] }`, replacing `{ rootDir, promises: { readFile } }`. An empty `root` is rejected rather than denoting the whole filesystem, and a platform prefix is a leading segment, so a Windows drive is `['C:', 'srv']`.
- A trailing argument must now be Passable, so `readFile(path, { signal })` is rejected where the bare `node:fs` function accepted it.
- Every argument must be Passable, so an options record carrying an `AbortSignal` is rejected where the bare `node:fs` function accepted it.
- The capability factory is now async.

- Enforce the `fs` config's `root` and method set with a narrowing rather than a hand-rolled caveat, so a holder that narrows the capability further composes with the configured bound instead of stacking a second mechanism on it ([#1058](https://github.com/Consensys-Incorporated/ocap-kernel/pull/1058))

### Removed

Expand Down
1 change: 1 addition & 0 deletions packages/kernel-platforms/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@
"dependencies": {
"@endo/exo": "^1.5.12",
"@endo/patterns": "^1.7.0",
"@metamask/kernel-utils": "workspace:^",
"@metamask/superstruct": "^3.2.1",
"ses": "^1.14.0"
},
Expand Down
40 changes: 14 additions & 26 deletions packages/kernel-platforms/src/capabilities/fs/browser.test.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,20 @@
import { GET_INTERFACE_GUARD } from '@endo/exo';
import { describe, expect, it } from 'vitest';

import { capabilityFactory } from './browser.ts';
import type { FsConfig } from './types.ts';
import { browserFsOptions } from './browser.ts';
import { makeFsBase } from './shared.ts';

describe('fs browser capability', () => {
describe('capabilityFactory', () => {
it.each([
{ name: 'readFile', config: { root: ['root'], methods: ['readFile'] } },
{ name: 'access', config: { root: ['root'], methods: ['access'] } },
{
name: 'all operations',
config: { root: ['root'], methods: ['readFile', 'access'] },
},
] as { name: string; config: FsConfig }[])(
'throws not implemented error for $name',
({ config }) => {
expect(() => capabilityFactory(config)).toThrow(
/Capability .* is not implemented in the browser/u,
);
},
);
// The configured capability narrows this base, and `narrow` forwards over
// `E()`, which cannot run under `mock-endoify` — see `shared.test.ts`.
const makeCapability = () =>
makeFsBase(browserFsOptions) as unknown as Record<string, CallableFunction>;

it('creates capability with no operations', () => {
const config: FsConfig = { root: ['root'] };
const capability = capabilityFactory(config);

expect(capability[GET_INTERFACE_GUARD]()).toBeDefined();
});
});
it.each([{ name: 'readFile' }, { name: 'access' }])(
'rejects $name as not implemented',
async ({ name }) => {
await expect(makeCapability()[name]?.(['root', 'x'])).rejects.toThrow(
`Capability ${name} is not implemented in the browser`,
);
},
);
});
30 changes: 24 additions & 6 deletions packages/kernel-platforms/src/capabilities/fs/browser.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
import { makeFsSpecification } from './shared.ts';
import type { FsPlatformOptions, FsSpecification } from './shared.ts';
import type {
Access,
FsConfigStruct,
PathSegments,
ReadFile,
} from './types.ts';

const notImplemented = (name: string): never => {
// The operations exist so the browser's exo carries the same guards as any other
// platform's; they refuse when called rather than when constructed, since the
// capability now builds every method and narrows to the configured ones.
const notImplemented = (name: string) => async (): Promise<never> => {
throw new Error(`Capability ${name} is not implemented in the browser`);
};

export const { configStruct, capabilityFactory } = makeFsSpecification({
makeReadFile: () => notImplemented('readFile'),
makeAccess: () => notImplemented('access'),
// Exported so the tests can build the same base the specification narrows.
// Nothing here carries authority: both operations only throw.
export const browserFsOptions: FsPlatformOptions = {
makeReadFile: () => notImplemented('readFile') as unknown as ReadFile,
makeAccess: () => notImplemented('access') as unknown as Access,
makePathCaveat: () => () => undefined,
toPath: (segments) => `/${segments.join('/')}`,
});
toPath: (segments: PathSegments) => `/${segments.join('/')}`,
};

const specification: FsSpecification = makeFsSpecification(browserFsOptions);

// eslint-disable-next-line prefer-destructuring -- annotated for declaration emit
export const configStruct: FsConfigStruct = specification.configStruct;
export const { capabilityFactory } = specification;
61 changes: 33 additions & 28 deletions packages/kernel-platforms/src/capabilities/fs/nodejs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import { lstatSync, Stats } from 'node:fs';
import fs from 'node:fs/promises';
import { describe, expect, it, vi, beforeEach } from 'vitest';

import { capabilityFactory } from './nodejs.ts';
import type { FsConfig } from './types.ts';
import { makeNoSymlinksCaveat, toPath } from './nodejs.ts';
import { makeFsBase } from './shared.ts';

/* eslint-disable n/no-sync */

Expand Down Expand Up @@ -37,19 +37,21 @@ describe('fs nodejs capability', () => {
createMockLstatSync(false);
});

describe('capabilityFactory', () => {
describe('fs base', () => {
describe.each([
{
operation: 'readFile',
mockFn: fs.readFile,
mockReturn: 'file content',
additionalArg: { encoding: 'utf8' },
additionalMockReturn: Buffer.from('file content'),
requiredArgs: ['utf8'],
additionalArg: 'utf8',
additionalMockReturn: 'file content',
},
{
operation: 'access',
mockFn: fs.access,
mockReturn: undefined,
requiredArgs: [] as unknown[],
additionalArg: 0o644,
additionalMockReturn: undefined,
},
Expand All @@ -59,43 +61,46 @@ describe('fs nodejs capability', () => {
operation,
mockFn,
mockReturn,
requiredArgs,
additionalArg,
additionalMockReturn,
}) => {
type TestCapability = Record<string, CallableFunction>;

const makeCapability = (): TestCapability => {
const config: FsConfig = {
root: ['root'],
methods: [operation],
};
return capabilityFactory(config) as unknown as TestCapability;
};
// Built from the module's own `toPath` and symlink caveat. The
// configured capability narrows this base, and `narrow` forwards over
// `E()`, which cannot run under `mock-endoify` — see `shared.test.ts`.
const makeCapability = (): TestCapability =>
makeFsBase({
makeReadFile: () => fs.readFile,
makeAccess: () => fs.access,
makePathCaveat: makeNoSymlinksCaveat,
toPath,
}) as unknown as TestCapability;

it('joins segments into a path for the underlying operation', async () => {
vi.mocked(mockFn).mockResolvedValue(mockReturn as never);

const result = await makeCapability()[operation]?.([
'root',
'file.txt',
]);
const result = await makeCapability()[operation]?.(
['root', 'file.txt'],
...requiredArgs,
);

expect(mockFn).toHaveBeenCalledWith('/root/file.txt');
expect(mockFn).toHaveBeenCalledWith(
'/root/file.txt',
...requiredArgs,
);
expect(result).toBe(mockReturn);
});

it('throws error for a path outside the root', async () => {
await expect(
makeCapability()[operation]?.(['outside', 'file.txt']),
).rejects.toThrow('is outside allowed root');
expect(mockFn).not.toHaveBeenCalled();
});

it('throws error for a symlink', async () => {
createMockLstatSync(true);

await expect(
makeCapability()[operation]?.(['root', 'file.txt']),
makeCapability()[operation]?.(
['root', 'file.txt'],
...requiredArgs,
),
).rejects.toThrow('Symlinks are prohibited: /root/file.txt');
expect(mockFn).not.toHaveBeenCalled();
});
Expand All @@ -104,9 +109,9 @@ describe('fs nodejs capability', () => {
{ name: 'parent segments', segments: ['root', '..', '..', 'etc'] },
{ name: 'an embedded traversal', segments: ['root', '../../etc'] },
])('throws error for $name', async ({ segments }) => {
await expect(makeCapability()[operation]?.(segments)).rejects.toThrow(
'contains an invalid segment',
);
await expect(
makeCapability()[operation]?.(segments, ...requiredArgs),
).rejects.toThrow('contains an invalid segment');
expect(mockFn).not.toHaveBeenCalled();
});

Expand Down
36 changes: 13 additions & 23 deletions packages/kernel-platforms/src/capabilities/fs/nodejs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ import { lstatSync } from 'node:fs';
import fs from 'node:fs/promises';
import { resolve, sep } from 'node:path';

import { makeFsSpecification, makeRootCaveat } from './shared.ts';
import type { PathSegments, SegmentsCaveat } from './types.ts';
import { makeFsSpecification } from './shared.ts';
import type { FsSpecification } from './shared.ts';
import type { FsConfigStruct, PathSegments, SegmentsCaveat } from './types.ts';

/**
* Joins absolute segments into a Node.js path.
Expand All @@ -15,43 +16,32 @@ import type { PathSegments, SegmentsCaveat } from './types.ts';
* @param segments - The segments to join
* @returns The corresponding absolute path
*/
const toPath = (segments: PathSegments): string => resolve(sep, ...segments);
export const toPath = (segments: PathSegments): string =>
resolve(sep, ...segments);

/**
* Node.js specific symlink caveat factory using node:fs
*
* @returns A caveat function that validates segments against symlinks
*/
const makeNoSymlinksCaveat = (): SegmentsCaveat => {
return (segments: PathSegments): void => {
export const makeNoSymlinksCaveat = (): SegmentsCaveat => {
return harden((segments: PathSegments): void => {
const path = toPath(segments);
// eslint-disable-next-line n/no-sync
const stats = lstatSync(path);
if (stats.isSymbolicLink()) {
throw new Error(`Symlinks are prohibited: ${path}`);
}
};
};

/**
* Node.js specific path caveat factory
*
* @param root - The root the segments must extend
* @returns A caveat function that validates segments against configured constraints
*/
const makeNodejsPathCaveat = (root: PathSegments): SegmentsCaveat => {
const withinRoot = makeRootCaveat(root);
const noSymlinks = makeNoSymlinksCaveat();

return harden((segments: PathSegments) => {
withinRoot(segments);
noSymlinks(segments);
});
};

export const { configStruct, capabilityFactory } = makeFsSpecification({
const specification: FsSpecification = makeFsSpecification({
makeReadFile: () => fs.readFile,
makeAccess: () => fs.access,
makePathCaveat: makeNodejsPathCaveat,
makePathCaveat: makeNoSymlinksCaveat,
toPath,
});

// eslint-disable-next-line prefer-destructuring -- annotated for declaration emit
export const configStruct: FsConfigStruct = specification.configStruct;
export const { capabilityFactory } = specification;
Loading
Loading