Skip to content
Open
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
18 changes: 18 additions & 0 deletions src/Bash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<any>[] = [];

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
12 changes: 6 additions & 6 deletions src/commands/ls/ls.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
});
Expand All @@ -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("");
});
Expand All @@ -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("");
});

Expand Down
48 changes: 38 additions & 10 deletions src/commands/ls/ls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand All @@ -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))
Expand All @@ -183,6 +189,8 @@ export const lsCommand: Command = {
humanReadable,
sortBySize,
classifyFiles,
uidToName,
gidToName,
);
stdout += result.stdout;
stderr += result.stderr;
Expand All @@ -200,6 +208,9 @@ export const lsCommand: Command = {
humanReadable,
sortBySize,
classifyFiles,
false,
uidToName,
gidToName,
);
stdout += result.stdout;
stderr += result.stderr;
Expand All @@ -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<ExecResult> {
const showHidden = showAll || showAlmostAll;
const allPaths = ctx.fs.getAllPaths();
Expand Down Expand Up @@ -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
Expand All @@ -291,10 +306,12 @@ 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 };
Expand Down Expand Up @@ -330,6 +347,8 @@ async function listPath(
sortBySize: boolean = false,
classifyFiles: boolean = false,
_isSubdir: boolean = false,
uidToName: (uid: number) => string = () => "user",
gidToName: (gid: number) => string = () => "group",
): Promise<ExecResult> {
const showHidden = showAll || showAlmostAll;
const fullPath = ctx.fs.resolvePath(ctx.cwd, path);
Expand All @@ -343,14 +362,17 @@ 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)
: String(size).padStart(5);
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,
};
Expand Down Expand Up @@ -415,7 +437,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
Expand All @@ -432,7 +454,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
Expand All @@ -446,12 +470,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`,
};
}
}),
Expand Down Expand Up @@ -561,6 +587,8 @@ async function listPath(
sortBySize,
classifyFiles,
true,
uidToName,
gidToName,
);
return { name: dir.name, result };
}),
Expand Down
88 changes: 88 additions & 0 deletions src/commands/ls/ls.uid-gid.test.ts
Original file line number Diff line number Diff line change
@@ -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<FsStat> => {
const s = await target.stat(path);
return { ...s, uid: 0, gid: 42 };
};
}
if (prop === "lstat") {
return async (path: string): Promise<FsStat> => {
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);
});
});
11 changes: 7 additions & 4 deletions src/commands/stat/stat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down
Loading
Loading