From b3a85708a0af0e2db788a33febe8e97ebf389032 Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 11 Mar 2026 12:57:46 +0100 Subject: [PATCH 1/2] add uid/guid resolvers --- src/Bash.ts | 18 +++++++++++ src/commands/ls/ls.ts | 46 +++++++++++++++++++++------ src/commands/stat/stat.ts | 11 ++++--- src/fs/interface.ts | 2 ++ src/fs/overlay-fs/overlay-fs.ts | 4 +++ src/fs/read-write-fs/read-write-fs.ts | 4 +++ src/interpreter/builtin-dispatch.ts | 2 ++ src/interpreter/interpreter.ts | 6 ++++ src/interpreter/types.ts | 4 +++ src/types.ts | 4 +++ 10 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/Bash.ts b/src/Bash.ts index b6983262..31a688af 100644 --- a/src/Bash.ts +++ b/src/Bash.ts @@ -222,6 +222,16 @@ export interface BashOptions { uid?: number; gid?: number; }; + /** + * Resolve a numeric UID to a username for display in ls -l, stat, etc. + * Falls back to "user" if not provided. + */ + uidToName?: (uid: number) => string; + /** + * Resolve a numeric GID to a group name for display in ls -l, stat, etc. + * Falls back to "group" if not provided. + */ + gidToName?: (gid: number) => string; } export interface ExecOptions { @@ -277,6 +287,8 @@ export class Bash { private defenseInDepthConfig?: DefenseInDepthConfig | boolean; private coverageWriter?: FeatureCoverageWriter; private jsBootstrapCode?: string; + private uidToNameFn: (uid: number) => string; + private gidToNameFn: (gid: number) => string; // biome-ignore lint/suspicious/noExplicitAny: type-erased plugin storage for untyped API private transformPlugins: TransformPlugin[] = []; @@ -342,6 +354,10 @@ export class Bash { // Store coverage writer if provided (for fuzzing instrumentation) this.coverageWriter = options.coverage; + // Store UID/GID name resolution callbacks + this.uidToNameFn = options.uidToName ?? (() => "user"); + this.gidToNameFn = options.gidToName ?? (() => "group"); + // Initialize interpreter state this.state = { env, @@ -666,6 +682,8 @@ export class Bash { coverage: this.coverageWriter, requireDefenseContext: defenseBox?.isEnabled() === true, jsBootstrapCode: this.jsBootstrapCode, + uidToName: this.uidToNameFn, + gidToName: this.gidToNameFn, }; const interpreter = new Interpreter(interpreterOptions, execState); diff --git a/src/commands/ls/ls.ts b/src/commands/ls/ls.ts index a59ef003..ee8434a1 100644 --- a/src/commands/ls/ls.ts +++ b/src/commands/ls/ls.ts @@ -3,6 +3,7 @@ import type { FsStat } from "../../fs/interface.js"; import type { Command, CommandContext, ExecResult } from "../../types.js"; import { parseArgs } from "../../utils/args.js"; import { DEFAULT_BATCH_SIZE } from "../../utils/constants.js"; +import { formatMode } from "../format-mode.js"; import { hasHelpFlag, showHelp } from "../help.js"; // Format size in human-readable format (e.g., 1.5K, 234M, 2G) @@ -121,6 +122,9 @@ export const lsCommand: Command = { // Note: onePerLine is accepted but implicit in our output void parsed.result.flags.onePerLine; + const uidToName = ctx.uidToName ?? (() => "user"); + const gidToName = ctx.gidToName ?? (() => "group"); + const paths = parsed.result.positional; if (paths.length === 0) { @@ -145,7 +149,9 @@ export const lsCommand: Command = { try { const stat = await ctx.fs.stat(fullPath); if (longFormat) { - const mode = stat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; + const modeStr = formatMode(stat.mode, stat.isDirectory); + const owner = uidToName(stat.uid ?? 1000); + const group = gidToName(stat.gid ?? 1000); const suffix = classifyFiles ? classifySuffix(await ctx.fs.lstat(fullPath)) : stat.isDirectory @@ -157,7 +163,7 @@ export const lsCommand: Command = { : String(size).padStart(5); const mtime = stat.mtime ?? new Date(0); const dateStr = formatDate(mtime); - stdout += `${mode} 1 user user ${sizeStr} ${dateStr} ${path}${suffix}\n`; + stdout += `${modeStr} 1 ${owner} ${group} ${sizeStr} ${dateStr} ${path}${suffix}\n`; } else { const suffix = classifyFiles ? classifySuffix(await ctx.fs.lstat(fullPath)) @@ -183,6 +189,8 @@ export const lsCommand: Command = { humanReadable, sortBySize, classifyFiles, + uidToName, + gidToName, ); stdout += result.stdout; stderr += result.stderr; @@ -200,6 +208,9 @@ export const lsCommand: Command = { humanReadable, sortBySize, classifyFiles, + false, + uidToName, + gidToName, ); stdout += result.stdout; stderr += result.stderr; @@ -221,6 +232,8 @@ async function listGlob( humanReadable: boolean = false, sortBySize: boolean = false, classifyFiles: boolean = false, + uidToName: (uid: number) => string = () => "user", + gidToName: (gid: number) => string = () => "group", ): Promise { const showHidden = showAll || showAlmostAll; const allPaths = ctx.fs.getAllPaths(); @@ -278,7 +291,9 @@ async function listGlob( const fullPath = ctx.fs.resolvePath(ctx.cwd, match); try { const stat = await ctx.fs.stat(fullPath); - const mode = stat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; + const modeStr = formatMode(stat.mode, stat.isDirectory); + const owner = uidToName(stat.uid ?? 1000); + const group = gidToName(stat.gid ?? 1000); const suffix = classifyFiles ? classifySuffix(await ctx.fs.lstat(fullPath)) : stat.isDirectory @@ -291,10 +306,10 @@ async function listGlob( const mtime = stat.mtime ?? new Date(0); const dateStr = formatDate(mtime); lines.push( - `${mode} 1 user user ${sizeStr} ${dateStr} ${match}${suffix}`, + `${modeStr} 1 ${owner} ${group} ${sizeStr} ${dateStr} ${match}${suffix}`, ); } catch { - lines.push(`-rw-r--r-- 1 user user 0 Jan 1 00:00 ${match}`); + lines.push(`-rw-r--r-- 1 ${uidToName(1000)} ${gidToName(1000)} 0 Jan 1 00:00 ${match}`); } } return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 }; @@ -330,6 +345,8 @@ async function listPath( sortBySize: boolean = false, classifyFiles: boolean = false, _isSubdir: boolean = false, + uidToName: (uid: number) => string = () => "user", + gidToName: (gid: number) => string = () => "group", ): Promise { const showHidden = showAll || showAlmostAll; const fullPath = ctx.fs.resolvePath(ctx.cwd, path); @@ -343,6 +360,9 @@ async function listPath( ? classifySuffix(await ctx.fs.lstat(fullPath)) : ""; if (longFormat) { + const modeStr = formatMode(stat.mode, stat.isDirectory); + const owner = uidToName(stat.uid ?? 1000); + const group = gidToName(stat.gid ?? 1000); const size = stat.size ?? 0; const sizeStr = humanReadable ? formatHumanSize(size).padStart(5) @@ -350,7 +370,7 @@ async function listPath( const mtime = stat.mtime ?? new Date(0); const dateStr = formatDate(mtime); return { - stdout: `-rw-r--r-- 1 user user ${sizeStr} ${dateStr} ${path}${fileSuffix}\n`, + stdout: `${modeStr} 1 ${owner} ${group} ${sizeStr} ${dateStr} ${path}${fileSuffix}\n`, stderr: "", exitCode: 0, }; @@ -415,7 +435,7 @@ async function listPath( // Add special entries first for (const entry of specialEntries) { - stdout += `drwxr-xr-x 1 user user 0 Jan 1 00:00 ${entry}\n`; + stdout += `drwxr-xr-x 1 ${uidToName(1000)} ${gidToName(1000)} 0 Jan 1 00:00 ${entry}\n`; } // Parallelize stat calls for regular entries @@ -432,7 +452,9 @@ async function listPath( fullPath === "/" ? `/${entry}` : `${fullPath}/${entry}`; try { const entryStat = await ctx.fs.stat(entryPath); - const mode = entryStat.isDirectory ? "drwxr-xr-x" : "-rw-r--r--"; + const modeStr = formatMode(entryStat.mode, entryStat.isDirectory); + const owner = uidToName(entryStat.uid ?? 1000); + const group = gidToName(entryStat.gid ?? 1000); const suffix = classifyFiles ? classifySuffix(await ctx.fs.lstat(entryPath)) : entryStat.isDirectory @@ -446,12 +468,14 @@ async function listPath( const dateStr = formatDate(mtime); return { name: entry, - line: `${mode} 1 user user ${sizeStr} ${dateStr} ${entry}${suffix}\n`, + line: `${modeStr} 1 ${owner} ${group} ${sizeStr} ${dateStr} ${entry}${suffix}\n`, }; } catch { + const owner = uidToName(1000); + const group = gidToName(1000); return { name: entry, - line: `-rw-r--r-- 1 user user 0 Jan 1 00:00 ${entry}\n`, + line: `-rw-r--r-- 1 ${owner} ${group} 0 Jan 1 00:00 ${entry}\n`, }; } }), @@ -561,6 +585,8 @@ async function listPath( sortBySize, classifyFiles, true, + uidToName, + gidToName, ); return { name: dir.name, result }; }), diff --git a/src/commands/stat/stat.ts b/src/commands/stat/stat.ts index a3ed5cac..0914e199 100644 --- a/src/commands/stat/stat.ts +++ b/src/commands/stat/stat.ts @@ -39,6 +39,9 @@ export const statCommand: Command = { }; } + const uidToName = ctx.uidToName ?? (() => "user"); + const gidToName = ctx.gidToName ?? (() => "group"); + let stdout = ""; let stderr = ""; let hasError = false; @@ -63,10 +66,10 @@ export const statCommand: Command = { ); // file type output = output.replace(/%a/g, modeOctal); // access rights (octal) output = output.replace(/%A/g, modeStr); // access rights (human readable) - output = output.replace(/%u/g, "1000"); // user ID - output = output.replace(/%U/g, "user"); // user name - output = output.replace(/%g/g, "1000"); // group ID - output = output.replace(/%G/g, "group"); // group name + output = output.replace(/%u/g, String(stat.uid ?? 1000)); + output = output.replace(/%U/g, uidToName(stat.uid ?? 1000)); + output = output.replace(/%g/g, String(stat.gid ?? 1000)); + output = output.replace(/%G/g, gidToName(stat.gid ?? 1000)); stdout += `${output}\n`; } else { // Default format diff --git a/src/fs/interface.ts b/src/fs/interface.ts index 0f848e8a..65d931cc 100644 --- a/src/fs/interface.ts +++ b/src/fs/interface.ts @@ -82,6 +82,8 @@ export interface FsStat { mode: number; size: number; mtime: Date; + uid?: number; + gid?: number; } /** diff --git a/src/fs/overlay-fs/overlay-fs.ts b/src/fs/overlay-fs/overlay-fs.ts index cdfae657..eb0f953c 100644 --- a/src/fs/overlay-fs/overlay-fs.ts +++ b/src/fs/overlay-fs/overlay-fs.ts @@ -605,6 +605,8 @@ export class OverlayFs implements IFileSystem { mode: lstatResult.mode, size: lstatResult.size, mtime: lstatResult.mtime, + uid: lstatResult.uid, + gid: lstatResult.gid, }; } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") { @@ -669,6 +671,8 @@ export class OverlayFs implements IFileSystem { mode: stat.mode, size: stat.size, mtime: stat.mtime, + uid: stat.uid, + gid: stat.gid, }; } catch (e) { if ((e as NodeJS.ErrnoException).code === "ENOENT") { diff --git a/src/fs/read-write-fs/read-write-fs.ts b/src/fs/read-write-fs/read-write-fs.ts index a3f60415..8199060b 100644 --- a/src/fs/read-write-fs/read-write-fs.ts +++ b/src/fs/read-write-fs/read-write-fs.ts @@ -290,6 +290,8 @@ export class ReadWriteFs implements IFileSystem { mode: stat.mode, size: stat.size, mtime: stat.mtime, + uid: stat.uid, + gid: stat.gid, }; } catch (e) { const err = e as NodeJS.ErrnoException; @@ -314,6 +316,8 @@ export class ReadWriteFs implements IFileSystem { mode: stat.mode, size: stat.size, mtime: stat.mtime, + uid: stat.uid, + gid: stat.gid, }; } catch (e) { const err = e as NodeJS.ErrnoException; diff --git a/src/interpreter/builtin-dispatch.ts b/src/interpreter/builtin-dispatch.ts index 00207174..17b1a8b8 100644 --- a/src/interpreter/builtin-dispatch.ts +++ b/src/interpreter/builtin-dispatch.ts @@ -437,6 +437,8 @@ export async function executeExternalCommand( signal: ctx.state.signal, requireDefenseContext: ctx.requireDefenseContext, jsBootstrapCode: ctx.jsBootstrapCode, + uidToName: ctx.uidToName, + gidToName: ctx.gidToName, }; const guardedCmdCtx = createDefenseAwareCommandContext(cmdCtx, commandName); diff --git a/src/interpreter/interpreter.ts b/src/interpreter/interpreter.ts index 3703a185..52b32f4b 100644 --- a/src/interpreter/interpreter.ts +++ b/src/interpreter/interpreter.ts @@ -134,6 +134,10 @@ export interface InterpreterOptions { requireDefenseContext?: boolean; /** Bootstrap JavaScript code for js-exec */ jsBootstrapCode?: string; + /** Resolve a numeric UID to a username for display in ls -l, stat, etc. */ + uidToName?: (uid: number) => string; + /** Resolve a numeric GID to a group name for display in ls -l, stat, etc. */ + gidToName?: (gid: number) => string; } export class Interpreter { @@ -155,6 +159,8 @@ export class Interpreter { coverage: options.coverage, requireDefenseContext: options.requireDefenseContext ?? false, jsBootstrapCode: options.jsBootstrapCode, + uidToName: options.uidToName, + gidToName: options.gidToName, }; } diff --git a/src/interpreter/types.ts b/src/interpreter/types.ts index 74106ccf..41d8b4dd 100644 --- a/src/interpreter/types.ts +++ b/src/interpreter/types.ts @@ -444,4 +444,8 @@ export interface InterpreterContext { * Threaded through the context chain instead of shell env. */ jsBootstrapCode?: string; + /** Resolve a numeric UID to a username for display in ls -l, stat, etc. */ + uidToName?: (uid: number) => string; + /** Resolve a numeric GID to a group name for display in ls -l, stat, etc. */ + gidToName?: (gid: number) => string; } diff --git a/src/types.ts b/src/types.ts index 83dbb186..e5d454c0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -189,6 +189,10 @@ export interface CommandContext { * user access/injection via environment variables. */ jsBootstrapCode?: string; + /** Resolve a numeric UID to a username for display in ls -l, stat, etc. */ + uidToName?: (uid: number) => string; + /** Resolve a numeric GID to a group name for display in ls -l, stat, etc. */ + gidToName?: (gid: number) => string; } export interface Command { From 0f80d33079d3b7386e689c3947f23b0d0c45b00f Mon Sep 17 00:00:00 2001 From: Steve James Date: Wed, 11 Mar 2026 13:29:43 +0100 Subject: [PATCH 2/2] add tests --- src/commands/ls/ls.test.ts | 12 ++-- src/commands/ls/ls.ts | 4 +- src/commands/ls/ls.uid-gid.test.ts | 88 ++++++++++++++++++++++++++ src/commands/stat/stat.uid-gid.test.ts | 83 ++++++++++++++++++++++++ 4 files changed, 180 insertions(+), 7 deletions(-) create mode 100644 src/commands/ls/ls.uid-gid.test.ts create mode 100644 src/commands/stat/stat.uid-gid.test.ts diff --git a/src/commands/ls/ls.test.ts b/src/commands/ls/ls.test.ts index 18f84b09..2213f2a2 100644 --- a/src/commands/ls/ls.test.ts +++ b/src/commands/ls/ls.test.ts @@ -65,7 +65,7 @@ describe("ls", () => { }); const result = await env.exec("ls -l /dir"); expect(result.stdout).toMatch( - /^total 1\n-rw-r--r-- 1 user user\s+0 \w{3}\s+\d+\s+[\d:]+\s+test\.txt\n$/, + /^total 1\n-rw-r--r-- 1 user group\s+0 \w{3}\s+\d+\s+[\d:]+\s+test\.txt\n$/, ); expect(result.stderr).toBe(""); }); @@ -76,7 +76,7 @@ describe("ls", () => { }); const result = await env.exec("ls -l /dir"); expect(result.stdout).toMatch( - /^total 1\ndrwxr-xr-x 1 user user\s+0 \w{3}\s+\d+\s+[\d:]+\s+subdir\/\n$/, + /^total 1\ndrwxr-xr-x 1 user group\s+0 \w{3}\s+\d+\s+[\d:]+\s+subdir\/\n$/, ); expect(result.stderr).toBe(""); }); @@ -92,10 +92,10 @@ describe("ls", () => { // Check structure: 4 entries (., .., .hidden, visible) const lines = result.stdout.split("\n").filter((l) => l); expect(lines[0]).toBe("total 4"); - expect(lines[1]).toMatch(/^drwxr-xr-x 1 user user\s+0 .+ \.$/); - expect(lines[2]).toMatch(/^drwxr-xr-x 1 user user\s+0 .+ \.\.$/); - expect(lines[3]).toMatch(/^-rw-r--r-- 1 user user\s+0 .+ \.hidden$/); - expect(lines[4]).toMatch(/^-rw-r--r-- 1 user user\s+0 .+ visible$/); + expect(lines[1]).toMatch(/^drwxr-xr-x 1 user group\s+0 .+ \.$/); + expect(lines[2]).toMatch(/^drwxr-xr-x 1 user group\s+0 .+ \.\.$/); + expect(lines[3]).toMatch(/^-rw-r--r-- 1 user group\s+0 .+ \.hidden$/); + expect(lines[4]).toMatch(/^-rw-r--r-- 1 user group\s+0 .+ visible$/); expect(result.stderr).toBe(""); }); diff --git a/src/commands/ls/ls.ts b/src/commands/ls/ls.ts index ee8434a1..06e9a378 100644 --- a/src/commands/ls/ls.ts +++ b/src/commands/ls/ls.ts @@ -309,7 +309,9 @@ async function listGlob( `${modeStr} 1 ${owner} ${group} ${sizeStr} ${dateStr} ${match}${suffix}`, ); } catch { - lines.push(`-rw-r--r-- 1 ${uidToName(1000)} ${gidToName(1000)} 0 Jan 1 00:00 ${match}`); + lines.push( + `-rw-r--r-- 1 ${uidToName(1000)} ${gidToName(1000)} 0 Jan 1 00:00 ${match}`, + ); } } return { stdout: `${lines.join("\n")}\n`, stderr: "", exitCode: 0 }; diff --git a/src/commands/ls/ls.uid-gid.test.ts b/src/commands/ls/ls.uid-gid.test.ts new file mode 100644 index 00000000..9db18797 --- /dev/null +++ b/src/commands/ls/ls.uid-gid.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; +import type { FsStat, IFileSystem } from "../../fs/interface.js"; + +describe("ls -l uid/gid and mode display", () => { + it("should use real mode from stat instead of hardcoding", async () => { + const env = new Bash({ + files: { + "/dir/script.sh": { content: "#!/bin/bash", mode: 0o755 }, + "/dir/secret.txt": { content: "top secret", mode: 0o600 }, + }, + }); + const result = await env.exec("ls -l /dir"); + const lines = result.stdout.split("\n").filter((l) => l); + expect(lines[0]).toBe("total 2"); + expect(lines[1]).toMatch(/^-rwxr-xr-x 1 user group\s+11 .+ script\.sh$/); + expect(lines[2]).toMatch(/^-rw------- 1 user group\s+10 .+ secret\.txt$/); + expect(result.exitCode).toBe(0); + }); + + it("should resolve uid/gid names via callbacks", async () => { + const env = new Bash({ + files: { "/dir/file.txt": "content" }, + uidToName: (uid) => (uid === 0 ? "root" : "alice"), + gidToName: (gid) => (gid === 0 ? "root" : "staff"), + }); + const result = await env.exec("ls -l /dir"); + const lines = result.stdout.split("\n").filter((l) => l); + expect(lines[1]).toMatch(/^-rw-r--r-- 1 alice staff\s+7 .+ file\.txt$/); + expect(result.exitCode).toBe(0); + }); + + it("should fall back to user/group without callbacks", async () => { + const env = new Bash({ + files: { "/dir/file.txt": "data" }, + }); + const result = await env.exec("ls -l /dir"); + const lines = result.stdout.split("\n").filter((l) => l); + expect(lines[1]).toMatch(/^-rw-r--r-- 1 user group\s+4 .+ file\.txt$/); + expect(result.exitCode).toBe(0); + }); + + it("should resolve names for . and .. in ls -la", async () => { + const env = new Bash({ + files: { "/dir/file.txt": "x" }, + uidToName: () => "bob", + gidToName: () => "devs", + }); + const result = await env.exec("ls -la /dir"); + const lines = result.stdout.split("\n").filter((l) => l); + expect(lines[1]).toMatch(/^drwxr-xr-x 1 bob devs\s+0 .+ \.$/); + expect(lines[2]).toMatch(/^drwxr-xr-x 1 bob devs\s+0 .+ \.\.$/); + expect(result.exitCode).toBe(0); + }); + + it("should use uid/gid from custom IFileSystem with name resolution", async () => { + const inner = new Bash({ + files: { "/dir/owned.txt": "test" }, + }); + const wrappedFs = new Proxy(inner.fs, { + get(target, prop, receiver) { + if (prop === "stat") { + return async (path: string): Promise => { + const s = await target.stat(path); + return { ...s, uid: 0, gid: 42 }; + }; + } + if (prop === "lstat") { + return async (path: string): Promise => { + const s = await target.lstat(path); + return { ...s, uid: 0, gid: 42 }; + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as IFileSystem; + + const env = new Bash({ + fs: wrappedFs, + uidToName: (uid) => (uid === 0 ? "root" : `user${uid}`), + gidToName: (gid) => (gid === 42 ? "staff" : `group${gid}`), + }); + const result = await env.exec("ls -l /dir"); + const lines = result.stdout.split("\n").filter((l) => l); + expect(lines[1]).toMatch(/^-rw-r--r-- 1 root staff\s+4 .+ owned\.txt$/); + expect(result.exitCode).toBe(0); + }); +}); diff --git a/src/commands/stat/stat.uid-gid.test.ts b/src/commands/stat/stat.uid-gid.test.ts new file mode 100644 index 00000000..22c37c00 --- /dev/null +++ b/src/commands/stat/stat.uid-gid.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { Bash } from "../../Bash.js"; +import type { FsStat, IFileSystem } from "../../fs/interface.js"; + +describe("stat uid/gid format specifiers", () => { + it("should return 1000/user/1000/group by default", async () => { + const env = new Bash({ + files: { "/test.txt": "hello" }, + }); + const result = await env.exec('stat -c "%u %U %g %G" /test.txt'); + expect(result.stdout).toBe("1000 user 1000 group\n"); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("should resolve names via uidToName/gidToName callbacks", async () => { + const env = new Bash({ + files: { "/test.txt": "hello" }, + uidToName: (uid) => (uid === 0 ? "root" : "alice"), + gidToName: (gid) => (gid === 0 ? "root" : "staff"), + }); + const result = await env.exec( + 'stat -c "%n owned by %U:%G (uid=%u, gid=%g)" /test.txt', + ); + expect(result.stdout).toBe( + "/test.txt owned by alice:staff (uid=1000, gid=1000)\n", + ); + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + }); + + it("should use uid/gid from custom IFileSystem", async () => { + const inner = new Bash({ + files: { + "/alice.txt": "alice file", + "/bob.txt": "bob file", + }, + }); + const wrappedFs = new Proxy(inner.fs, { + get(target, prop, receiver) { + if (prop === "stat") { + return async (path: string): Promise => { + const s = await target.stat(path); + if (path === "/alice.txt") return { ...s, uid: 1001, gid: 100 }; + if (path === "/bob.txt") return { ...s, uid: 1002, gid: 200 }; + return s; + }; + } + if (prop === "lstat") { + return async (path: string): Promise => { + const s = await target.lstat(path); + if (path === "/alice.txt") return { ...s, uid: 1001, gid: 100 }; + if (path === "/bob.txt") return { ...s, uid: 1002, gid: 200 }; + return s; + }; + } + return Reflect.get(target, prop, receiver); + }, + }) as IFileSystem; + + const env = new Bash({ + fs: wrappedFs, + uidToName: (uid) => { + if (uid === 1001) return "alice"; + if (uid === 1002) return "bob"; + return `uid${uid}`; + }, + gidToName: (gid) => { + if (gid === 100) return "users"; + if (gid === 200) return "devs"; + return `gid${gid}`; + }, + }); + + const alice = await env.exec('stat -c "%u %U %g %G" /alice.txt'); + expect(alice.stdout).toBe("1001 alice 100 users\n"); + expect(alice.exitCode).toBe(0); + + const bob = await env.exec('stat -c "%u %U %g %G" /bob.txt'); + expect(bob.stdout).toBe("1002 bob 200 devs\n"); + expect(bob.exitCode).toBe(0); + }); +});