Files
openclaw/src/infra/tmp-openclaw-dir.test.ts
2026-02-26 21:59:55 +00:00

309 lines
9.2 KiB
TypeScript

import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { POSIX_OPENCLAW_TMP_DIR, resolvePreferredOpenClawTmpDir } from "./tmp-openclaw-dir.js";
type TmpDirOptions = NonNullable<Parameters<typeof resolvePreferredOpenClawTmpDir>[0]>;
function fallbackTmp(uid = 501) {
return path.join("/var/fallback", `openclaw-${uid}`);
}
function nodeErrorWithCode(code: string) {
const err = new Error(code) as Error & { code?: string };
err.code = code;
return err;
}
function secureDirStat(uid = 501) {
return {
isDirectory: () => true,
isSymbolicLink: () => false,
uid,
mode: 0o40700,
};
}
function resolveWithMocks(params: {
lstatSync: NonNullable<TmpDirOptions["lstatSync"]>;
fallbackLstatSync?: NonNullable<TmpDirOptions["lstatSync"]>;
accessSync?: NonNullable<TmpDirOptions["accessSync"]>;
chmodSync?: NonNullable<TmpDirOptions["chmodSync"]>;
warn?: NonNullable<TmpDirOptions["warn"]>;
uid?: number;
tmpdirPath?: string;
}) {
const uid = params.uid ?? 501;
const fallbackPath = fallbackTmp(uid);
const accessSync = params.accessSync ?? vi.fn();
const chmodSync = params.chmodSync ?? vi.fn();
const warn = params.warn ?? vi.fn();
const wrappedLstatSync = vi.fn((target: string) => {
if (target === POSIX_OPENCLAW_TMP_DIR) {
return params.lstatSync(target);
}
if (target === fallbackPath) {
if (params.fallbackLstatSync) {
return params.fallbackLstatSync(target);
}
return secureDirStat(uid);
}
return secureDirStat(uid);
}) as NonNullable<TmpDirOptions["lstatSync"]>;
const mkdirSync = vi.fn();
const getuid = vi.fn(() => uid);
const tmpdir = vi.fn(() => params.tmpdirPath ?? "/var/fallback");
const resolved = resolvePreferredOpenClawTmpDir({
accessSync,
chmodSync,
lstatSync: wrappedLstatSync,
mkdirSync,
getuid,
tmpdir,
warn,
});
return { resolved, accessSync, lstatSync: wrappedLstatSync, mkdirSync, tmpdir };
}
describe("resolvePreferredOpenClawTmpDir", () => {
it("prefers /tmp/openclaw when it already exists and is writable", () => {
const lstatSync: NonNullable<TmpDirOptions["lstatSync"]> = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: 0o40700,
}));
const { resolved, accessSync, tmpdir } = resolveWithMocks({ lstatSync });
expect(lstatSync).toHaveBeenCalledTimes(1);
expect(accessSync).toHaveBeenCalledTimes(1);
expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
expect(tmpdir).not.toHaveBeenCalled();
});
it("prefers /tmp/openclaw when it does not exist but /tmp is writable", () => {
const lstatSyncMock = vi
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
.mockImplementationOnce(() => {
throw nodeErrorWithCode("ENOENT");
})
.mockImplementationOnce(() => secureDirStat(501));
const { resolved, accessSync, mkdirSync, tmpdir } = resolveWithMocks({
lstatSync: lstatSyncMock,
});
expect(resolved).toBe(POSIX_OPENCLAW_TMP_DIR);
expect(accessSync).toHaveBeenCalledWith("/tmp", expect.any(Number));
expect(mkdirSync).toHaveBeenCalledWith(POSIX_OPENCLAW_TMP_DIR, expect.any(Object));
expect(tmpdir).not.toHaveBeenCalled();
});
it("falls back to os.tmpdir()/openclaw when /tmp/openclaw is not a directory", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => false,
isSymbolicLink: () => false,
uid: 501,
mode: 0o100644,
})) as unknown as ReturnType<typeof vi.fn> & NonNullable<TmpDirOptions["lstatSync"]>;
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalled();
});
it("falls back to os.tmpdir()/openclaw when /tmp is not writable", () => {
const accessSync = vi.fn((target: string) => {
if (target === "/tmp") {
throw new Error("read-only");
}
});
const lstatSync = vi.fn(() => {
throw nodeErrorWithCode("ENOENT");
});
const { resolved, tmpdir } = resolveWithMocks({
accessSync,
lstatSync,
});
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is a symlink", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => true,
uid: 501,
mode: 0o120777,
}));
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is not owned by the current user", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 0,
mode: 0o40700,
}));
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalled();
});
it("falls back when /tmp/openclaw is group/other writable", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: 0o40777,
}));
const { resolved, tmpdir } = resolveWithMocks({ lstatSync });
expect(resolved).toBe(fallbackTmp());
expect(tmpdir).toHaveBeenCalled();
});
it("throws when fallback path is a symlink", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => true,
uid: 501,
mode: 0o120777,
}));
const fallbackLstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => true,
uid: 501,
mode: 0o120777,
}));
expect(() =>
resolveWithMocks({
lstatSync,
fallbackLstatSync,
}),
).toThrow(/Unsafe fallback OpenClaw temp dir/);
});
it("creates fallback directory when missing, then validates ownership and mode", () => {
const lstatSync = vi.fn(() => ({
isDirectory: () => true,
isSymbolicLink: () => true,
uid: 501,
mode: 0o120777,
}));
const fallbackLstatSync = vi
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
.mockImplementationOnce(() => {
throw nodeErrorWithCode("ENOENT");
})
.mockImplementationOnce(() => secureDirStat(501));
const { resolved, mkdirSync } = resolveWithMocks({
lstatSync,
fallbackLstatSync,
});
expect(resolved).toBe(fallbackTmp());
expect(mkdirSync).toHaveBeenCalledWith(fallbackTmp(), { recursive: true, mode: 0o700 });
});
it("repairs fallback directory permissions after create when umask makes it group-writable", () => {
const fallbackPath = fallbackTmp();
let fallbackMode = 0o40775;
const lstatSync = vi.fn<NonNullable<TmpDirOptions["lstatSync"]>>(() => {
throw nodeErrorWithCode("ENOENT");
});
const fallbackLstatSync = vi
.fn<NonNullable<TmpDirOptions["lstatSync"]>>()
.mockImplementationOnce(() => {
throw nodeErrorWithCode("ENOENT");
})
.mockImplementation(() => ({
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: fallbackMode,
}));
const chmodSync = vi.fn((target: string, mode: number) => {
if (target === fallbackPath && mode === 0o700) {
fallbackMode = 0o40700;
}
});
const resolved = resolvePreferredOpenClawTmpDir({
accessSync: vi.fn((target: string) => {
if (target === "/tmp") {
throw new Error("read-only");
}
}),
lstatSync: vi.fn((target: string) => {
if (target === POSIX_OPENCLAW_TMP_DIR) {
return lstatSync(target);
}
if (target === fallbackPath) {
return fallbackLstatSync(target);
}
return secureDirStat(501);
}),
mkdirSync: vi.fn(),
chmodSync,
getuid: vi.fn(() => 501),
tmpdir: vi.fn(() => "/var/fallback"),
warn: vi.fn(),
});
expect(resolved).toBe(fallbackPath);
expect(chmodSync).toHaveBeenCalledWith(fallbackPath, 0o700);
});
it("repairs existing fallback directory when permissions are too broad", () => {
const fallbackPath = fallbackTmp();
let fallbackMode = 0o40775;
const chmodSync = vi.fn((target: string, mode: number) => {
if (target === fallbackPath && mode === 0o700) {
fallbackMode = 0o40700;
}
});
const warn = vi.fn();
const resolved = resolvePreferredOpenClawTmpDir({
accessSync: vi.fn((target: string) => {
if (target === "/tmp") {
throw new Error("read-only");
}
}),
lstatSync: vi.fn((target: string) => {
if (target === POSIX_OPENCLAW_TMP_DIR) {
throw nodeErrorWithCode("ENOENT");
}
if (target === fallbackPath) {
return {
isDirectory: () => true,
isSymbolicLink: () => false,
uid: 501,
mode: fallbackMode,
};
}
return secureDirStat(501);
}),
mkdirSync: vi.fn(),
chmodSync,
getuid: vi.fn(() => 501),
tmpdir: vi.fn(() => "/var/fallback"),
warn,
});
expect(resolved).toBe(fallbackPath);
expect(chmodSync).toHaveBeenCalledWith(fallbackPath, 0o700);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("tightened permissions on temp dir"));
});
});