Skip to content

Commit 167145e

Browse files
Copilotstreamich
andcommitted
feat: allow injecting custom process implementation for better testability
Co-authored-by: streamich <[email protected]> Agent-Logs-Url: https://github.com/streamich/memfs/sessions/d6fc2bdc-cd95-483f-a59b-5fbc959a43f2
1 parent d003eec commit 167145e

9 files changed

Lines changed: 237 additions & 23 deletions

File tree

packages/fs-core/src/Node.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,9 +49,11 @@ export class Node {
4949
// Path to another node, if this is a symlink.
5050
symlink: string;
5151

52-
constructor(ino: number, mode: number = 0o666) {
52+
constructor(ino: number, mode: number = 0o666, uid: number = getuid(), gid: number = getgid()) {
5353
this.mode = mode;
5454
this.ino = ino;
55+
this._uid = uid;
56+
this._gid = gid;
5557
}
5658

5759
public set ctime(ctime: Date) {

packages/fs-core/src/Superblock.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Node } from './Node';
33
import { Link } from './Link';
44
import { File } from './File';
55
import { Buffer } from '@jsonjoy.com/fs-node-builtins/lib/internal/buffer';
6-
import process from './process';
6+
import defaultProcess, { type IProcess } from './process';
77
import { constants } from '@jsonjoy.com/fs-node-utils';
88
import { ERRSTR, FLAGS, MODE } from '@jsonjoy.com/fs-node-utils';
99
import {
@@ -34,14 +34,14 @@ const { O_RDONLY, O_WRONLY, O_RDWR, O_CREAT, O_EXCL, O_TRUNC, O_APPEND, O_DIRECT
3434
* @see https://lxr.linux.no/linux+v3.11.2/include/linux/fs.h#L1242
3535
*/
3636
export class Superblock {
37-
static fromJSON(json: DirectoryJSON, cwd?: string): Superblock {
38-
const vol = new Superblock();
37+
static fromJSON(json: DirectoryJSON, cwd?: string, opts?: { process?: IProcess }): Superblock {
38+
const vol = new Superblock(opts);
3939
vol.fromJSON(json, cwd);
4040
return vol;
4141
}
4242

43-
static fromNestedJSON(json: NestedDirectoryJSON, cwd?: string): Superblock {
44-
const vol = new Superblock();
43+
static fromNestedJSON(json: NestedDirectoryJSON, cwd?: string, opts?: { process?: IProcess }): Superblock {
44+
const vol = new Superblock(opts);
4545
vol.fromNestedJSON(json, cwd);
4646
return vol;
4747
}
@@ -84,7 +84,11 @@ export class Superblock {
8484
// Current number of open files.
8585
openFiles = 0;
8686

87-
constructor(props = {}) {
87+
/** The `process`-like object used by this filesystem instance. */
88+
readonly process: IProcess;
89+
90+
constructor(opts: { process?: IProcess } = {}) {
91+
this.process = opts.process ?? defaultProcess;
8892
const root = this.createLink();
8993
root.setNode(this.createNode(constants.S_IFDIR | 0o777));
9094

@@ -144,7 +148,9 @@ export class Superblock {
144148
}
145149

146150
createNode(mode: number): Node {
147-
const node = new Node(this.newInoNumber(), mode);
151+
const uid = this.process.getuid?.() ?? 0;
152+
const gid = this.process.getgid?.() ?? 0;
153+
const node = new Node(this.newInoNumber(), mode, uid, gid);
148154
this.inodes[node.ino] = node;
149155
return node;
150156
}
@@ -205,11 +211,13 @@ export class Superblock {
205211

206212
let curr: Link | null = this.root;
207213
let i = 0;
214+
const uid = this.process.getuid?.() ?? 0;
215+
const gid = this.process.getgid?.() ?? 0;
208216
while (i < steps.length) {
209217
let node: Node = curr.getNode();
210218
// Check access permissions if current link is a directory
211219
if (node.isDirectory()) {
212-
if (checkAccess && !node.canExecute()) {
220+
if (checkAccess && !node.canExecute(uid, gid)) {
213221
return Err(createStatError(ERROR_CODE.EACCES, funcName, filename));
214222
}
215223
} else {
@@ -247,7 +255,7 @@ export class Superblock {
247255
if (checkExistence && !node.isDirectory() && i < steps.length - 1) {
248256
// On Windows, use ENOENT for consistency with Node.js behavior
249257
// On other platforms, use ENOTDIR which is more semantically correct
250-
const errorCode = process.platform === 'win32' ? ERROR_CODE.ENOENT : ERROR_CODE.ENOTDIR;
258+
const errorCode = this.process.platform === 'win32' ? ERROR_CODE.ENOENT : ERROR_CODE.ENOTDIR;
251259
return Err(createStatError(errorCode, funcName, filename));
252260
}
253261

@@ -404,8 +412,7 @@ export class Superblock {
404412
return json;
405413
}
406414

407-
// TODO: `cwd` should probably not invoke `process.cwd()`.
408-
fromJSON(json: DirectoryJSON, cwd: string = process.cwd()) {
415+
fromJSON(json: DirectoryJSON, cwd: string = this.process.cwd()) {
409416
for (let filename in json) {
410417
const data = json[filename];
411418
filename = resolve(filename, cwd);
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Superblock } from '../Superblock';
2+
import type { IProcess } from '../process';
3+
4+
const makeProcess = (overrides: Partial<IProcess> = {}): IProcess => ({
5+
cwd: () => '/',
6+
platform: 'linux',
7+
emitWarning: () => {},
8+
env: {},
9+
...overrides,
10+
});
11+
12+
describe('Superblock with custom process', () => {
13+
describe('fromJSON / fromNestedJSON', () => {
14+
it('uses custom cwd() when no cwd argument is given', () => {
15+
const customProcess = makeProcess({ cwd: () => '/custom' });
16+
const sb = Superblock.fromJSON({ 'file.txt': 'hello' }, undefined, { process: customProcess });
17+
const link = sb.getResolvedLink('/custom/file.txt');
18+
expect(link).not.toBeNull();
19+
expect(link!.getNode().getString()).toBe('hello');
20+
});
21+
22+
it('uses provided cwd argument instead of process.cwd()', () => {
23+
const customProcess = makeProcess({ cwd: () => '/ignored' });
24+
const sb = Superblock.fromJSON({ 'file.txt': 'hi' }, '/explicit', { process: customProcess });
25+
const link = sb.getResolvedLink('/explicit/file.txt');
26+
expect(link).not.toBeNull();
27+
});
28+
29+
it('fromNestedJSON uses custom cwd()', () => {
30+
const customProcess = makeProcess({ cwd: () => '/nested' });
31+
const sb = Superblock.fromNestedJSON({ 'a/b.txt': 'data' }, undefined, { process: customProcess });
32+
const link = sb.getResolvedLink('/nested/a/b.txt');
33+
expect(link).not.toBeNull();
34+
});
35+
});
36+
37+
describe('createNode', () => {
38+
it('uses custom getuid() and getgid() for new nodes', () => {
39+
const customProcess = makeProcess({ getuid: () => 1234, getgid: () => 5678 });
40+
const sb = new Superblock({ process: customProcess });
41+
const node = sb.createNode(0o644);
42+
expect(node.uid).toBe(1234);
43+
expect(node.gid).toBe(5678);
44+
});
45+
46+
it('defaults uid/gid to 0 when getuid/getgid are not defined', () => {
47+
const customProcess = makeProcess();
48+
const sb = new Superblock({ process: customProcess });
49+
const node = sb.createNode(0o644);
50+
expect(node.uid).toBe(0);
51+
expect(node.gid).toBe(0);
52+
});
53+
});
54+
55+
describe('walk (platform-dependent error codes)', () => {
56+
it('returns ENOTDIR on non-win32 platform when traversing through a file', () => {
57+
const customProcess = makeProcess({ platform: 'linux' });
58+
const sb = Superblock.fromJSON({ '/dir/file.txt': 'content' }, '/', { process: customProcess });
59+
const result = sb.walk('/dir/file.txt/child', false, true, false);
60+
expect(result.ok).toBe(false);
61+
if (!result.ok) expect(result.err.code).toBe('ENOTDIR');
62+
});
63+
64+
it('returns ENOENT on win32 platform when traversing through a file', () => {
65+
const customProcess = makeProcess({ platform: 'win32' });
66+
const sb = Superblock.fromJSON({ '/dir/file.txt': 'content' }, '/', { process: customProcess });
67+
const result = sb.walk('/dir/file.txt/child', false, true, false);
68+
expect(result.ok).toBe(false);
69+
if (!result.ok) expect(result.err.code).toBe('ENOENT');
70+
});
71+
});
72+
73+
describe('process property', () => {
74+
it('exposes the stored process object', () => {
75+
const customProcess = makeProcess({ platform: 'win32' });
76+
const sb = new Superblock({ process: customProcess });
77+
expect(sb.process).toBe(customProcess);
78+
});
79+
80+
it('defaults to the global process when no process option is given', () => {
81+
const sb = new Superblock();
82+
expect(typeof sb.process.cwd).toBe('function');
83+
expect(typeof sb.process.platform).toBe('string');
84+
});
85+
});
86+
});

packages/fs-core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export { Node, type NodeEvent } from './Node';
66
export { Link, type LinkEvent } from './Link';
77
export { File } from './File';
88
export { Superblock } from './Superblock';
9+
export type { IProcess } from './process';
910
export {
1011
dataToBuffer,
1112
filenameToSteps,

packages/fs-core/src/process.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ export interface IProcess {
66
cwd(): string;
77
platform: string;
88
emitWarning: (message: string, type: string) => void;
9-
env: {};
9+
env: Record<string, string | undefined>;
1010
}
1111

1212
/**
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { Volume } from '../volume';
2+
import type { IProcess } from '@jsonjoy.com/fs-core';
3+
4+
const makeProcess = (overrides: Partial<IProcess> = {}): IProcess => ({
5+
cwd: () => '/',
6+
platform: 'linux',
7+
emitWarning: () => {},
8+
env: {},
9+
...overrides,
10+
});
11+
12+
describe('Volume with custom process', () => {
13+
describe('Volume.fromJSON', () => {
14+
it('uses custom cwd() from process when no cwd is given', () => {
15+
const customProcess = makeProcess({ cwd: () => '/app' });
16+
const vol = Volume.fromJSON({ 'data.txt': 'hello' }, undefined, { process: customProcess });
17+
expect(vol.readFileSync('/app/data.txt', 'utf8')).toBe('hello');
18+
});
19+
20+
it('uses explicit cwd over process.cwd()', () => {
21+
const customProcess = makeProcess({ cwd: () => '/ignored' });
22+
const vol = Volume.fromJSON({ 'data.txt': 'hi' }, '/explicit', { process: customProcess });
23+
expect(vol.readFileSync('/explicit/data.txt', 'utf8')).toBe('hi');
24+
});
25+
});
26+
27+
describe('Volume.fromNestedJSON', () => {
28+
it('uses custom cwd() from process when no cwd is given', () => {
29+
const customProcess = makeProcess({ cwd: () => '/nested' });
30+
const vol = Volume.fromNestedJSON({ 'sub/file.txt': 'content' }, undefined, { process: customProcess });
31+
expect(vol.readFileSync('/nested/sub/file.txt', 'utf8')).toBe('content');
32+
});
33+
});
34+
35+
describe('custom getuid / getgid', () => {
36+
it('stores uid and gid from custom process on created files', () => {
37+
const customProcess = makeProcess({ getuid: () => 42, getgid: () => 99 });
38+
const vol = Volume.fromJSON({ '/file.txt': 'data' }, '/', { process: customProcess });
39+
const stat = vol.statSync('/file.txt');
40+
expect(stat.uid).toBe(42);
41+
expect(stat.gid).toBe(99);
42+
});
43+
});
44+
});

packages/fs-node/src/volume.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
validateFd,
2424
Ok,
2525
Result,
26+
type IProcess,
2627
} from '@jsonjoy.com/fs-core';
2728
import { isWin } from '@jsonjoy.com/fs-core/lib/util';
2829
import Stats from './Stats';
@@ -176,11 +177,14 @@ function validateGid(gid: number) {
176177
* `Volume` represents a file system.
177178
*/
178179
export class Volume implements FsCallbackApi, FsSynchronousApi {
179-
public static readonly fromJSON = (json: DirectoryJSON, cwd?: string): Volume =>
180-
new Volume(Superblock.fromJSON(json, cwd));
181-
182-
public static readonly fromNestedJSON = (json: NestedDirectoryJSON, cwd?: string): Volume =>
183-
new Volume(Superblock.fromNestedJSON(json, cwd));
180+
public static readonly fromJSON = (json: DirectoryJSON, cwd?: string, opts?: { process?: IProcess }): Volume =>
181+
new Volume(Superblock.fromJSON(json, cwd, opts));
182+
183+
public static readonly fromNestedJSON = (
184+
json: NestedDirectoryJSON,
185+
cwd?: string,
186+
opts?: { process?: IProcess },
187+
): Volume => new Volume(Superblock.fromNestedJSON(json, cwd, opts));
184188

185189
StatWatcher: new () => StatWatcher;
186190
ReadStream: new (...args) => misc.IReadStream;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { memfs } from '../index';
2+
import type { IProcess } from '../index';
3+
4+
const makeProcess = (overrides: Partial<IProcess> = {}): IProcess => ({
5+
cwd: () => '/',
6+
platform: 'linux',
7+
emitWarning: () => {},
8+
env: {},
9+
...overrides,
10+
});
11+
12+
describe('memfs() with custom process', () => {
13+
it('accepts a string as second argument (backward compat)', () => {
14+
const { fs } = memfs({ 'file.txt': 'hello' }, '/app');
15+
expect(fs.readFileSync('/app/file.txt', 'utf8')).toBe('hello');
16+
});
17+
18+
it('accepts an object with cwd as second argument', () => {
19+
const { fs } = memfs({ 'file.txt': 'hello' }, { cwd: '/app' });
20+
expect(fs.readFileSync('/app/file.txt', 'utf8')).toBe('hello');
21+
});
22+
23+
it('uses process.cwd() from options when no cwd is specified', () => {
24+
const customProcess = makeProcess({ cwd: () => '/from-process' });
25+
const { fs } = memfs({ 'file.txt': 'hi' }, { process: customProcess });
26+
expect(fs.readFileSync('/from-process/file.txt', 'utf8')).toBe('hi');
27+
});
28+
29+
it('uses cwd from options, ignoring process.cwd()', () => {
30+
const customProcess = makeProcess({ cwd: () => '/ignored' });
31+
const { fs } = memfs({ 'file.txt': 'hi' }, { cwd: '/explicit', process: customProcess });
32+
expect(fs.readFileSync('/explicit/file.txt', 'utf8')).toBe('hi');
33+
});
34+
35+
it('uses custom getuid and getgid from process', () => {
36+
const customProcess = makeProcess({ getuid: () => 777, getgid: () => 888 });
37+
const { fs } = memfs({ '/file.txt': 'data' }, { process: customProcess });
38+
const stat = fs.statSync('/file.txt');
39+
expect(stat.uid).toBe(777);
40+
expect(stat.gid).toBe(888);
41+
});
42+
43+
it('defaults to / cwd when no options are given', () => {
44+
const { fs } = memfs({ '/file.txt': 'data' });
45+
expect(fs.readFileSync('/file.txt', 'utf8')).toBe('data');
46+
});
47+
48+
it('exports IProcess type', () => {
49+
// This is a type-level test - just verifying the export compiles
50+
const p: IProcess = makeProcess();
51+
expect(typeof p.cwd).toBe('function');
52+
});
53+
});

packages/memfs/src/index.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,15 @@ import {
99
fsCallbackApiList,
1010
} from '@jsonjoy.com/fs-node';
1111
import type { IWriteStream } from '@jsonjoy.com/fs-node';
12-
import { DirectoryJSON, NestedDirectoryJSON } from '@jsonjoy.com/fs-core';
12+
import { DirectoryJSON, NestedDirectoryJSON, type IProcess } from '@jsonjoy.com/fs-core';
1313
import { constants } from '@jsonjoy.com/fs-node-utils';
1414
import type { FsPromisesApi } from '@jsonjoy.com/fs-node-utils';
1515
import type * as misc from '@jsonjoy.com/fs-node-utils/lib/types/misc';
1616

1717
const { F_OK, R_OK, W_OK, X_OK } = constants;
1818

1919
export { DirectoryJSON, NestedDirectoryJSON, Volume };
20+
export type { IProcess };
2021

2122
// Default volume.
2223
export const vol = new Volume();
@@ -68,18 +69,34 @@ export function createFsFromVolume(vol: Volume): IFs {
6869

6970
export const fs: IFs = createFsFromVolume(vol);
7071

72+
/** Options for creating a memfs instance. */
73+
export interface MemfsOptions {
74+
/** Custom working directory for resolving relative paths. Defaults to `'/'`. */
75+
cwd?: string;
76+
/** Custom `process`-like object for controlling platform, uid, gid, and cwd behavior. */
77+
process?: IProcess;
78+
}
79+
7180
/**
7281
* Creates a new file system instance.
7382
*
7483
* @param json File system structure expressed as a JSON object.
7584
* Use `null` for empty directories and empty string for empty files.
76-
* @param cwd Current working directory. The JSON structure will be created
77-
* relative to this path.
85+
* @param cwdOrOpts Current working directory (string) or options object.
86+
* The JSON structure will be created relative to the cwd path.
7887
* @returns A `memfs` file system instance, which is a drop-in replacement for
7988
* the `fs` module.
8089
*/
81-
export const memfs = (json: NestedDirectoryJSON = {}, cwd: string = '/'): { fs: IFs; vol: Volume } => {
82-
const vol = Volume.fromNestedJSON(json, cwd);
90+
export const memfs = (
91+
json: NestedDirectoryJSON = {},
92+
cwdOrOpts: string | MemfsOptions = '/',
93+
): { fs: IFs; vol: Volume } => {
94+
const opts: MemfsOptions = typeof cwdOrOpts === 'string' ? { cwd: cwdOrOpts } : cwdOrOpts;
95+
// When no explicit cwd is given but a custom process is provided, let the
96+
// Superblock use that process's cwd(). Otherwise default to '/' so the
97+
// convenience function keeps its opinionated virtual-root default.
98+
const cwd = opts.cwd ?? (opts.process ? undefined : '/');
99+
const vol = Volume.fromNestedJSON(json, cwd, { process: opts.process });
83100
const fs = createFsFromVolume(vol);
84101
return { fs, vol };
85102
};

0 commit comments

Comments
 (0)