diff --git a/src/lib/dataPaths.ts b/src/lib/dataPaths.ts index 3152265828..5ad61ddbee 100644 --- a/src/lib/dataPaths.ts +++ b/src/lib/dataPaths.ts @@ -70,6 +70,45 @@ export function resolveDataDir({ isCloud = false }: { isCloud?: boolean } = {}): return getDefaultDataDir(); } +/** + * Resolve the data directory and guarantee it is writable. + * + * Unlike {@link resolveDataDir} (a pure, side-effect-free path resolver used by + * many callers), this variant probes the resolved directory by attempting to + * create it. When a configured `DATA_DIR` is not writable (`EACCES`/`EPERM`), + * it falls back to the default user directory so the app keeps working instead + * of crashing on an unwritable, operator-supplied path. Any other error (e.g. + * `ENOTDIR`, `ENOSPC`) still propagates. + * + * Use this only at the single startup site that owns directory creation + * (currently `db/core.ts`); everywhere else keep using the pure resolver. + */ +export function resolveWritableDataDir({ isCloud = false }: { isCloud?: boolean } = {}): string { + const resolved = resolveDataDir({ isCloud }); + + // Cloud/serverless never owns a writable home dir; leave its sentinel alone. + if (isCloud) return resolved; + + // No explicit override → already the default user dir; nothing to fall back to. + const configured = normalizeConfiguredPath(process.env.DATA_DIR); + if (!configured) return resolved; + + try { + fs.mkdirSync(resolved, { recursive: true }); + return resolved; + } catch (err: unknown) { + const code = (err as NodeJS.ErrnoException | null)?.code; + if (code === "EACCES" || code === "EPERM") { + const fallback = getDefaultDataDir(); + console.warn( + `[DATA_DIR] '${resolved}' is not writable (${code}) → falling back to '${fallback}'` + ); + return fallback; + } + throw err; + } +} + export function isSamePath(a: string | null | undefined, b: string | null | undefined): boolean { if (!a || !b) return false; const normalizedA = path.resolve(a); diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 99a04cda7f..261749a814 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -13,7 +13,7 @@ import { } from "./adapters/driverFactory"; import path from "path"; import fs from "fs"; -import { resolveDataDir, getLegacyDotDataDir } from "../dataPaths"; +import { resolveWritableDataDir, getLegacyDotDataDir } from "../dataPaths"; import { runMigrations } from "./migrationRunner"; import { runDbHealthCheck } from "./healthCheck"; import { resetAllDbModuleState } from "./stateReset"; @@ -83,7 +83,7 @@ export const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build"; // ──────────────── Paths ──────────────── -export const DATA_DIR = resolveDataDir({ isCloud }); +export const DATA_DIR = resolveWritableDataDir({ isCloud }); const LEGACY_DATA_DIR = isCloud ? null : getLegacyDotDataDir(); export const SQLITE_FILE = isCloud ? null : path.join(DATA_DIR, "storage.sqlite"); const JSON_DB_FILE = isCloud ? null : path.join(DATA_DIR, "db.json"); diff --git a/tests/unit/data-dir-writable-fallback.test.ts b/tests/unit/data-dir-writable-fallback.test.ts new file mode 100644 index 0000000000..77661b530c --- /dev/null +++ b/tests/unit/data-dir-writable-fallback.test.ts @@ -0,0 +1,115 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + resolveWritableDataDir, + getDefaultDataDir, + resolveDataDir, +} from "../../src/lib/dataPaths.ts"; + +// Running as root bypasses POSIX permission bits, so a chmod-based "unwritable" +// directory would still be writable and the EACCES/EPERM branch never triggers. +const IS_ROOT = typeof process.getuid === "function" && process.getuid() === 0; +const IS_WINDOWS = process.platform === "win32"; + +async function withTempEnv( + fn: (paths: { root: string; home: string }) => void | Promise +) { + const originalEnv = { ...process.env }; + const root = fs.mkdtempSync(path.join(os.tmpdir(), "omni-datadir-")); + const home = path.join(root, "home"); + fs.mkdirSync(home, { recursive: true }); + + delete process.env.DATA_DIR; + delete process.env.XDG_CONFIG_HOME; + delete process.env.APPDATA; + process.env.HOME = home; + process.env.USERPROFILE = home; + + try { + await fn({ root, home }); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in originalEnv)) delete process.env[key]; + } + for (const [key, value] of Object.entries(originalEnv)) { + process.env[key] = value; + } + // Restore perms before cleanup so rmSync can delete read-only parents. + try { + fs.chmodSync(root, 0o755); + } catch { + // ignore + } + fs.rmSync(root, { recursive: true, force: true }); + } +} + +test("resolveWritableDataDir returns the configured DATA_DIR when it is writable", async () => { + await withTempEnv(({ root }) => { + const configured = path.join(root, "writable-data"); + process.env.DATA_DIR = configured; + + const resolved = resolveWritableDataDir(); + assert.equal(resolved, path.resolve(configured)); + // The probe creates the directory as a side effect. + assert.ok(fs.existsSync(configured)); + }); +}); + +test("resolveWritableDataDir falls back to the default dir when DATA_DIR is not writable (EACCES/EPERM)", { skip: IS_ROOT || IS_WINDOWS }, async () => { + await withTempEnv(({ root, home }) => { + // A read-only parent makes mkdir of the child fail with EACCES/EPERM. + const lockedParent = path.join(root, "locked"); + fs.mkdirSync(lockedParent, { recursive: true }); + fs.chmodSync(lockedParent, 0o555); + + const configured = path.join(lockedParent, "data"); + process.env.DATA_DIR = configured; + + const resolved = resolveWritableDataDir(); + const expectedFallback = getDefaultDataDir(); + + // It must NOT return the unwritable configured dir... + assert.notEqual(resolved, path.resolve(configured)); + // ...and instead fall back to the default user dir (~/.omniroute under HOME). + assert.equal(resolved, expectedFallback); + assert.ok(resolved.startsWith(path.resolve(home))); + }); +}); + +test("resolveWritableDataDir returns the default dir (no probe) when DATA_DIR is unset", async () => { + await withTempEnv(() => { + delete process.env.DATA_DIR; + const resolved = resolveWritableDataDir(); + assert.equal(resolved, getDefaultDataDir()); + // Matches the pure resolver when no override is present. + assert.equal(resolved, resolveDataDir()); + }); +}); + +test("resolveWritableDataDir rethrows non-permission errors", { skip: IS_WINDOWS }, async () => { + await withTempEnv(({ root }) => { + // Point DATA_DIR at a path whose parent is a regular file → ENOTDIR, not EACCES. + const fileParent = path.join(root, "iam-a-file"); + fs.writeFileSync(fileParent, "x"); + + const configured = path.join(fileParent, "data"); + process.env.DATA_DIR = configured; + + assert.throws(() => resolveWritableDataDir(), (err: NodeJS.ErrnoException) => { + return err.code !== "EACCES" && err.code !== "EPERM"; + }); + }); +}); + +test("resolveWritableDataDir leaves the cloud sentinel untouched", async () => { + await withTempEnv(() => { + process.env.DATA_DIR = "/some/configured/path"; + const resolved = resolveWritableDataDir({ isCloud: true }); + assert.equal(resolved, "/tmp"); + }); +});