mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-09-14 11:06:15 +05:00
test(sandbox): container options, tar, file listing, exec runner and reaper
This commit is contained in:
parent
e3954f7915
commit
3145478595
101
apps/dokploy/__test__/sandbox/container.test.ts
Normal file
101
apps/dokploy/__test__/sandbox/container.test.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import {
|
||||
buildSandboxContainerOptions,
|
||||
getSandboxNetworkName,
|
||||
parseSandboxEnvVars,
|
||||
parseSandboxUser,
|
||||
SANDBOX_INTERNET_NETWORK,
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
} from "@dokploy/server/utils/sandbox/container";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const spec = {
|
||||
sandboxId: "sb_1",
|
||||
projectId: "proj_1",
|
||||
environmentId: "env_1",
|
||||
image: "python:3.12-slim",
|
||||
cpu: 1.5,
|
||||
memoryMb: 512,
|
||||
pidsLimit: 256,
|
||||
networkMode: "isolated" as const,
|
||||
envVars: "FOO=bar\n# comment\n\nBAZ=qux=1\ninvalid",
|
||||
workdir: "/home/user",
|
||||
user: "1000:1000",
|
||||
};
|
||||
|
||||
describe("buildSandboxContainerOptions", () => {
|
||||
it("applies cpu, memory and pids limits", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.NanoCpus).toBe(1_500_000_000);
|
||||
expect(options.HostConfig?.Memory).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.MemorySwap).toBe(512 * 1024 * 1024);
|
||||
expect(options.HostConfig?.PidsLimit).toBe(256);
|
||||
});
|
||||
|
||||
it("hardens the container", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.HostConfig?.CapDrop).toEqual(["ALL"]);
|
||||
expect(options.HostConfig?.SecurityOpt).toEqual(["no-new-privileges"]);
|
||||
expect(options.HostConfig?.RestartPolicy).toEqual({ Name: "no" });
|
||||
expect(options.HostConfig?.LogConfig?.Config?.["max-size"]).toBe("10m");
|
||||
expect(options.User).toBe("1000:1000");
|
||||
});
|
||||
|
||||
it("keeps the container alive with a portable command", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Entrypoint).toEqual([""]);
|
||||
expect(options.Cmd).toEqual(["tail", "-f", "/dev/null"]);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
});
|
||||
|
||||
it("sets the labels used by the reaper and reconcile", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.name).toBe("dokploy-sandbox-sb_1");
|
||||
expect(options.Labels).toEqual({
|
||||
"dokploy.sandbox": "true",
|
||||
"dokploy.sandboxId": "sb_1",
|
||||
"dokploy.projectId": "proj_1",
|
||||
"dokploy.environmentId": "env_1",
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the isolated or internet network and never dokploy-network", () => {
|
||||
expect(buildSandboxContainerOptions(spec).HostConfig?.NetworkMode).toBe(
|
||||
SANDBOX_ISOLATED_NETWORK,
|
||||
);
|
||||
expect(
|
||||
buildSandboxContainerOptions({ ...spec, networkMode: "internet" })
|
||||
.HostConfig?.NetworkMode,
|
||||
).toBe(SANDBOX_INTERNET_NETWORK);
|
||||
expect(getSandboxNetworkName("isolated")).not.toBe("dokploy-network");
|
||||
expect(getSandboxNetworkName("internet")).not.toBe("dokploy-network");
|
||||
});
|
||||
|
||||
it("passes env vars and HOME, skipping comments and malformed lines", () => {
|
||||
const options = buildSandboxContainerOptions(spec);
|
||||
expect(options.Env).toEqual(["HOME=/home/user", "FOO=bar", "BAZ=qux=1"]);
|
||||
});
|
||||
|
||||
it("omits User when the image default should be used", () => {
|
||||
const options = buildSandboxContainerOptions({ ...spec, user: null });
|
||||
expect(options).not.toHaveProperty("User");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxEnvVars", () => {
|
||||
it("returns an empty list for empty input", () => {
|
||||
expect(parseSandboxEnvVars(null)).toEqual([]);
|
||||
expect(parseSandboxEnvVars("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxUser", () => {
|
||||
it("parses uid:gid", () => {
|
||||
expect(parseSandboxUser("1000:1000")).toEqual({ uid: 1000, gid: 1000 });
|
||||
expect(parseSandboxUser("1001")).toEqual({ uid: 1001, gid: 1001 });
|
||||
});
|
||||
|
||||
it("rejects named users", () => {
|
||||
expect(parseSandboxUser("node")).toBeNull();
|
||||
expect(parseSandboxUser(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
213
apps/dokploy/__test__/sandbox/exec.test.ts
Normal file
213
apps/dokploy/__test__/sandbox/exec.test.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough } from "node:stream";
|
||||
import {
|
||||
buildSandboxKillCommand,
|
||||
runSandboxExec,
|
||||
SANDBOX_EXEC_MARKER_ENV,
|
||||
SANDBOX_EXEC_TIMEOUT_EXIT_CODE,
|
||||
toSandboxEnvArray,
|
||||
} from "@dokploy/server/utils/sandbox/exec";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Docker multiplexes stdout/stderr on a non-tty exec as 8-byte frames:
|
||||
// [stream type, 0, 0, 0, size (BE uint32)] followed by the payload.
|
||||
const frame = (type: 1 | 2, text: string) => {
|
||||
const payload = Buffer.from(text);
|
||||
const header = Buffer.alloc(8);
|
||||
header[0] = type;
|
||||
header.writeUInt32BE(payload.length, 4);
|
||||
return Buffer.concat([header, payload]);
|
||||
};
|
||||
|
||||
// Mirrors docker-modem's demuxStream so the runner is exercised end to end.
|
||||
const demuxStream = (
|
||||
stream: NodeJS.ReadableStream,
|
||||
stdout: NodeJS.WritableStream,
|
||||
stderr: NodeJS.WritableStream,
|
||||
) => {
|
||||
let buffer = Buffer.alloc(0);
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk]);
|
||||
while (buffer.length >= 8) {
|
||||
const size = buffer.readUInt32BE(4);
|
||||
if (buffer.length < 8 + size) break;
|
||||
const payload = buffer.subarray(8, 8 + size);
|
||||
(buffer[0] === 2 ? stderr : stdout).write(payload);
|
||||
buffer = buffer.subarray(8 + size);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const createDocker = (opts: {
|
||||
frames: Buffer[];
|
||||
exitCode: number | null;
|
||||
endAfterMs?: number;
|
||||
dieOnKill?: boolean;
|
||||
}) => {
|
||||
const stream = new PassThrough();
|
||||
const execCreate = vi.fn();
|
||||
const execStart = vi.fn();
|
||||
const kill = vi.fn(async () => {
|
||||
stream.end();
|
||||
});
|
||||
const killExecStart = vi.fn(async () => {
|
||||
if (opts.dieOnKill) stream.end();
|
||||
});
|
||||
const inspect = vi.fn(async () => ({ ExitCode: opts.exitCode }));
|
||||
|
||||
const exec = vi.fn(async (options: { Cmd: string[] }) => {
|
||||
execCreate(options);
|
||||
if (options.Cmd[2]?.startsWith("for p in /proc/")) {
|
||||
return { start: killExecStart, inspect };
|
||||
}
|
||||
return {
|
||||
start: vi.fn(async (startOptions: unknown) => {
|
||||
execStart(startOptions);
|
||||
setTimeout(() => {
|
||||
for (const chunk of opts.frames) stream.write(chunk);
|
||||
if (opts.endAfterMs === undefined) stream.end();
|
||||
else if (opts.endAfterMs > 0)
|
||||
setTimeout(() => stream.end(), opts.endAfterMs);
|
||||
}, 5);
|
||||
return stream;
|
||||
}),
|
||||
inspect,
|
||||
};
|
||||
});
|
||||
|
||||
const docker = {
|
||||
getContainer: vi.fn(() => ({ exec, kill })),
|
||||
modem: { demuxStream },
|
||||
};
|
||||
return { docker, execCreate, execStart, kill, killExecStart, inspect };
|
||||
};
|
||||
|
||||
describe("runSandboxExec", () => {
|
||||
it("demuxes stdout/stderr and reports the exit code", async () => {
|
||||
const { docker, execCreate, execStart } = createDocker({
|
||||
frames: [frame(1, "hello "), frame(1, "world\n"), frame(2, "oops\n")],
|
||||
exitCode: 3,
|
||||
});
|
||||
const stdoutChunks: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "echo hello world; echo oops >&2; exit 3",
|
||||
cwd: "/home/user",
|
||||
env: { FOO: "bar" },
|
||||
timeoutMs: 5000,
|
||||
onStdout: (chunk) => stdoutChunks.push(chunk),
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
stdout: "hello world\n",
|
||||
stderr: "oops\n",
|
||||
exitCode: 3,
|
||||
timedOut: false,
|
||||
truncated: false,
|
||||
containerKilled: false,
|
||||
});
|
||||
expect(stdoutChunks.join("")).toBe("hello world\n");
|
||||
|
||||
const options = execCreate.mock.calls[0]?.[0];
|
||||
expect(options.Cmd).toEqual([
|
||||
"sh",
|
||||
"-c",
|
||||
"echo hello world; echo oops >&2; exit 3",
|
||||
]);
|
||||
expect(options.WorkingDir).toBe("/home/user");
|
||||
expect(options.Tty).toBe(false);
|
||||
expect(options.Env).toContain("FOO=bar");
|
||||
expect(
|
||||
options.Env.some((e: string) =>
|
||||
e.startsWith(`${SANDBOX_EXEC_MARKER_ENV}=`),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(execStart).toHaveBeenCalledWith({ hijack: true, stdin: false });
|
||||
});
|
||||
|
||||
it("treats a null exit code as failure", async () => {
|
||||
const { docker } = createDocker({ frames: [], exitCode: null });
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "true",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("truncates output beyond maxOutputBytes but keeps streaming", async () => {
|
||||
const { docker } = createDocker({
|
||||
frames: [frame(1, "a".repeat(100)), frame(1, "b".repeat(100))],
|
||||
exitCode: 0,
|
||||
});
|
||||
const streamed: string[] = [];
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "yes",
|
||||
timeoutMs: 5000,
|
||||
maxOutputBytes: 150,
|
||||
onStdout: (chunk) => streamed.push(chunk),
|
||||
});
|
||||
expect(result.stdout).toHaveLength(150);
|
||||
expect(result.truncated).toBe(true);
|
||||
expect(streamed.join("")).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("kills the process on timeout and returns exit code 124", async () => {
|
||||
const { docker, killExecStart, kill } = createDocker({
|
||||
frames: [frame(1, "partial")],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: true,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(result.stdout).toBe("partial");
|
||||
expect(killExecStart).toHaveBeenCalledWith({ Detach: true });
|
||||
expect(kill).not.toHaveBeenCalled();
|
||||
expect(result.containerKilled).toBe(false);
|
||||
});
|
||||
|
||||
it("kills the container when the process survives the marker kill", async () => {
|
||||
const { docker, kill } = createDocker({
|
||||
frames: [],
|
||||
exitCode: 0,
|
||||
endAfterMs: 0,
|
||||
dieOnKill: false,
|
||||
});
|
||||
const result = await runSandboxExec(docker as never, "ctr", {
|
||||
cmd: "sleep 100",
|
||||
timeoutMs: 50,
|
||||
});
|
||||
expect(result.timedOut).toBe(true);
|
||||
expect(result.exitCode).toBe(SANDBOX_EXEC_TIMEOUT_EXIT_CODE);
|
||||
expect(kill).toHaveBeenCalledWith({ signal: "SIGKILL" });
|
||||
expect(result.containerKilled).toBe(true);
|
||||
}, 10_000);
|
||||
});
|
||||
|
||||
describe("buildSandboxKillCommand", () => {
|
||||
it("matches the marker as a fixed string and never fails the shell", () => {
|
||||
const cmd = buildSandboxKillCommand("abc-123");
|
||||
expect(cmd).toContain(`grep -qxF ${quote(["DOKPLOY_EXEC_ID=abc-123"])}`);
|
||||
expect(parse(cmd)).toContain("DOKPLOY_EXEC_ID=abc-123");
|
||||
expect(cmd).toContain("kill -9");
|
||||
expect(cmd.endsWith("; true")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toSandboxEnvArray", () => {
|
||||
it("converts records and passes arrays through", () => {
|
||||
expect(toSandboxEnvArray({ A: "1", B: "x=y" })).toEqual(["A=1", "B=x=y"]);
|
||||
expect(toSandboxEnvArray(["C=3"])).toEqual(["C=3"]);
|
||||
expect(toSandboxEnvArray(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventEmitter sanity", () => {
|
||||
it("keeps the fake stream API compatible with what the runner uses", () => {
|
||||
expect(new PassThrough()).toBeInstanceOf(EventEmitter);
|
||||
});
|
||||
});
|
||||
80
apps/dokploy/__test__/sandbox/files.test.ts
Normal file
80
apps/dokploy/__test__/sandbox/files.test.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import {
|
||||
buildSandboxFindCommand,
|
||||
buildSandboxLsCommand,
|
||||
parseSandboxFindOutput,
|
||||
parseSandboxLsOutput,
|
||||
} from "@dokploy/server/utils/sandbox/files";
|
||||
import { parse } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("list files commands", () => {
|
||||
it("quotes the path against shell injection", () => {
|
||||
expect(buildSandboxFindCommand("/tmp/a b; rm -rf /")).toContain(
|
||||
"'/tmp/a b; rm -rf /'",
|
||||
);
|
||||
expect(parse(buildSandboxLsCommand("/tmp/$(id)"))).toEqual([
|
||||
"ls",
|
||||
"-1Ap",
|
||||
"/tmp/$(id)",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxFindOutput", () => {
|
||||
it("parses type, size, mtime, mode and name, directories first", () => {
|
||||
const out = [
|
||||
"f\t12\t1700000000.5000\t644\tmain.py",
|
||||
"d\t4096\t1700000100.0000\t755\tsrc",
|
||||
"l\t7\t1700000200.0000\t777\tlink",
|
||||
"f\t0\t1700000300.0000\t600\tname with\tspaces",
|
||||
].join("\n");
|
||||
const entries = parseSandboxFindOutput(out);
|
||||
expect(entries.map((e) => e.name)).toEqual([
|
||||
"src",
|
||||
"link",
|
||||
"main.py",
|
||||
"name with\tspaces",
|
||||
]);
|
||||
expect(entries[0]).toEqual({
|
||||
name: "src",
|
||||
type: "directory",
|
||||
size: 4096,
|
||||
mode: "755",
|
||||
modifiedAt: new Date(1700000100 * 1000).toISOString(),
|
||||
});
|
||||
expect(entries[2]).toMatchObject({
|
||||
type: "file",
|
||||
size: 12,
|
||||
modifiedAt: new Date(1700000000500).toISOString(),
|
||||
});
|
||||
expect(entries[1]?.type).toBe("symlink");
|
||||
});
|
||||
|
||||
it("returns an empty list for an empty directory", () => {
|
||||
expect(parseSandboxFindOutput("")).toEqual([]);
|
||||
expect(parseSandboxFindOutput("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseSandboxLsOutput", () => {
|
||||
it("marks trailing-slash entries as directories", () => {
|
||||
const entries = parseSandboxLsOutput("b.txt\na/\n.hidden\n");
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
name: "a",
|
||||
type: "directory",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{
|
||||
name: ".hidden",
|
||||
type: "file",
|
||||
size: null,
|
||||
mode: null,
|
||||
modifiedAt: null,
|
||||
},
|
||||
{ name: "b.txt", type: "file", size: null, mode: null, modifiedAt: null },
|
||||
]);
|
||||
});
|
||||
});
|
||||
109
apps/dokploy/__test__/sandbox/reaper.test.ts
Normal file
109
apps/dokploy/__test__/sandbox/reaper.test.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: { query: { sandboxes: { findMany } } },
|
||||
}));
|
||||
vi.mock("@dokploy/server/services/sandbox", () => ({
|
||||
killSandbox: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
findExpiredSandboxes,
|
||||
planSandboxReconcile,
|
||||
reapExpiredSandboxes,
|
||||
} from "@dokploy/server/utils/sandbox/reaper";
|
||||
|
||||
const now = new Date("2026-09-10T12:00:00Z");
|
||||
const past = new Date(now.getTime() - 1000);
|
||||
const future = new Date(now.getTime() + 60_000);
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("findExpiredSandboxes", () => {
|
||||
it("returns only running sandboxes whose expiresAt has passed", () => {
|
||||
const list = [
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
{ sandboxId: "no-expiry", status: "running", expiresAt: null },
|
||||
{ sandboxId: "killed", status: "killed", expiresAt: past },
|
||||
{ sandboxId: "error", status: "error", expiresAt: past },
|
||||
];
|
||||
expect(findExpiredSandboxes(list, now).map((s) => s.sandboxId)).toEqual([
|
||||
"expired",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reapExpiredSandboxes", () => {
|
||||
it("kills expired sandboxes and leaves the rest untouched", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "expired", status: "running", expiresAt: past },
|
||||
{ sandboxId: "alive", status: "running", expiresAt: future },
|
||||
]);
|
||||
const kill = vi.fn(async () => undefined);
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
expect(killed).toEqual(["expired"]);
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenCalledWith("expired");
|
||||
});
|
||||
|
||||
it("continues when one kill fails", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{ sandboxId: "a", status: "running", expiresAt: past },
|
||||
{ sandboxId: "b", status: "running", expiresAt: past },
|
||||
]);
|
||||
const kill = vi.fn(async (id: string) => {
|
||||
if (id === "a") throw new Error("docker down");
|
||||
});
|
||||
const spy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const killed = await reapExpiredSandboxes(now, kill);
|
||||
spy.mockRestore();
|
||||
expect(killed).toEqual(["b"]);
|
||||
expect(kill).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planSandboxReconcile", () => {
|
||||
const container = (id: string, sandboxId: string, state = "running") => ({
|
||||
Id: id,
|
||||
State: state,
|
||||
Labels: { "dokploy.sandbox": "true", "dokploy.sandboxId": sandboxId },
|
||||
});
|
||||
|
||||
it("removes containers without a running row and errors rows without a container", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[
|
||||
{ sandboxId: "ok", containerId: "c-ok" },
|
||||
{ sandboxId: "gone", containerId: "c-gone" },
|
||||
{ sandboxId: "never-started", containerId: null },
|
||||
],
|
||||
[
|
||||
container("c-ok", "ok"),
|
||||
container("c-orphan", "orphan"),
|
||||
container("c-exited", "ok-old", "exited"),
|
||||
],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c-orphan", "c-exited"]);
|
||||
expect(plan.markError).toEqual(["gone", "never-started"]);
|
||||
});
|
||||
|
||||
it("removes a stopped container even when its row is running, and errors the row", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1" }],
|
||||
[container("c1", "sb", "exited")],
|
||||
);
|
||||
expect(plan.removeContainers).toEqual(["c1"]);
|
||||
expect(plan.markError).toEqual(["sb"]);
|
||||
});
|
||||
|
||||
it("does nothing when everything matches", () => {
|
||||
const plan = planSandboxReconcile(
|
||||
[{ sandboxId: "sb", containerId: "c1" }],
|
||||
[container("c1", "sb")],
|
||||
);
|
||||
expect(plan).toEqual({ removeContainers: [], markError: [] });
|
||||
});
|
||||
});
|
||||
131
apps/dokploy/__test__/sandbox/tar.test.ts
Normal file
131
apps/dokploy/__test__/sandbox/tar.test.ts
Normal file
@ -0,0 +1,131 @@
|
||||
import { Readable } from "node:stream";
|
||||
import {
|
||||
buildSandboxDirectoryTar,
|
||||
buildSandboxFileTar,
|
||||
extractSandboxFile,
|
||||
SandboxFileTooLargeError,
|
||||
SandboxNotRegularFileError,
|
||||
} from "@dokploy/server/utils/sandbox/tar";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Entry = {
|
||||
name: string;
|
||||
type: string;
|
||||
mode: number;
|
||||
uid: number;
|
||||
gid: number;
|
||||
size: number;
|
||||
content: string;
|
||||
};
|
||||
|
||||
const TYPES: Record<string, string> = {
|
||||
"0": "file",
|
||||
"\0": "file",
|
||||
"5": "directory",
|
||||
};
|
||||
|
||||
// Minimal ustar reader: 512-byte header blocks with octal numeric fields.
|
||||
const readTar = (buffer: Buffer): Entry[] => {
|
||||
const entries: Entry[] = [];
|
||||
let offset = 0;
|
||||
const field = (start: number, length: number) =>
|
||||
buffer
|
||||
.subarray(offset + start, offset + start + length)
|
||||
.toString("utf8")
|
||||
.replace(/\0.*$/s, "");
|
||||
const octal = (start: number, length: number) =>
|
||||
Number.parseInt(field(start, length).trim() || "0", 8);
|
||||
while (offset + 512 <= buffer.length) {
|
||||
const name = field(0, 100);
|
||||
if (!name) break;
|
||||
const size = octal(124, 12);
|
||||
const typeflag = buffer[offset + 156] ?? 0;
|
||||
entries.push({
|
||||
name,
|
||||
type: TYPES[String.fromCharCode(typeflag)] ?? "other",
|
||||
mode: octal(100, 8),
|
||||
uid: octal(108, 8),
|
||||
gid: octal(116, 8),
|
||||
size,
|
||||
content: buffer
|
||||
.subarray(offset + 512, offset + 512 + size)
|
||||
.toString("utf8"),
|
||||
});
|
||||
offset += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return entries;
|
||||
};
|
||||
|
||||
describe("buildSandboxFileTar", () => {
|
||||
it("packs a single file with the requested ownership and mode", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "main.py",
|
||||
content: Buffer.from("print('hi')\n"),
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
mode: 0o600,
|
||||
});
|
||||
const entries = await readTar(tar);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "main.py",
|
||||
type: "file",
|
||||
mode: 0o600,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
size: 12,
|
||||
content: "print('hi')\n",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to root ownership and 0644", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxFileTar({ name: "a.txt", content: Buffer.alloc(0) }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({ mode: 0o644, uid: 0, gid: 0, size: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSandboxDirectoryTar", () => {
|
||||
it("packs a directory entry with a trailing slash", async () => {
|
||||
const entries = await readTar(
|
||||
await buildSandboxDirectoryTar({ name: "user", uid: 1000, gid: 1000 }),
|
||||
);
|
||||
expect(entries[0]).toMatchObject({
|
||||
name: "user/",
|
||||
type: "directory",
|
||||
mode: 0o755,
|
||||
uid: 1000,
|
||||
gid: 1000,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractSandboxFile", () => {
|
||||
it("extracts the first regular file", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "out.txt",
|
||||
content: Buffer.from("hello"),
|
||||
});
|
||||
const file = await extractSandboxFile(Readable.from(tar), 1024);
|
||||
expect(file.name).toBe("out.txt");
|
||||
expect(file.content.toString()).toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects files over the limit", async () => {
|
||||
const tar = await buildSandboxFileTar({
|
||||
name: "big.bin",
|
||||
content: Buffer.alloc(2048, 1),
|
||||
});
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxFileTooLargeError);
|
||||
});
|
||||
|
||||
it("rejects archives without a regular file", async () => {
|
||||
const tar = await buildSandboxDirectoryTar({ name: "dir" });
|
||||
await expect(
|
||||
extractSandboxFile(Readable.from(tar), 1024),
|
||||
).rejects.toBeInstanceOf(SandboxNotRegularFileError);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user