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[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; fallbackLstatSync?: NonNullable; accessSync?: NonNullable; chmodSync?: NonNullable; warn?: NonNullable; 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; 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 = 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>() .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 & NonNullable; 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>() .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>(() => { throw nodeErrorWithCode("ENOENT"); }); const fallbackLstatSync = vi .fn>() .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")); }); });