// Read-only file access for approved Pi session roots (#1507, CHAT-02). // // A root is /.pi/state//sessions. The project root comes // from the board's own configuration and is trusted as given (it may itself // be a symlink, like the compatibility path to this checkout). Every // component below it must be a real directory, never a symlink, and a // session file must be a regular file directly inside the root. // // A file is opened O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and the descriptor's // (dev, ino) must match the lstat taken before the open. Node has no openat, // so a directory component swapped between the checks and the open is caught // by re-checking the components after the open, not prevented outright. The // seat that owns a root can write there anyway; the checks keep anything // outside the root from being read through it. // // Nothing here writes, renames, creates or migrates a file. import { lstatSync, openSync, fstatSync, readSync, closeSync, readdirSync, constants } from "node:fs"; import { join, relative, isAbsolute, sep, basename } from "node:path"; export class Refusal extends Error { constructor(code, message, { reconcile = false } = {}) { super(message); this.code = code; this.reconcile = reconcile; } } // Refusals of the CHAT-03 control layer (controller, claims, guard, engine // pin). They reach a socket client, never the reader's HTTP routes, so the // control board's status map covers only the reader's own codes. export class ControlRefusal extends Refusal {} const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*\.jsonl$/; // Permission errors inside a root are one conversation's problem, not the // catalogue's: they become a refusal instead of a thrown error. function denied(err, what) { if (err.code === "EACCES" || err.code === "EPERM") return new Refusal("unreadable", `${what} is not readable`); return err; } // A directory above the file without search permission is refused the same // way, so one bad root does not fail the catalogue. function lstatOrNull(path) { try { return lstatSync(path, { bigint: true }); } catch (err) { if (err.code === "ENOENT" || err.code === "ENOTDIR") return null; throw denied(err, "a session path component"); } } // Every component from the project root down to the sessions directory must // be a real directory. export function checkRoot(root) { const rel = relative(root.projectRoot, root.dir); if (!rel || rel.startsWith("..") || isAbsolute(rel)) throw new Refusal("unsafe-path", "session root is outside its project"); let path = root.projectRoot; for (const part of rel.split(sep)) { path = join(path, part); const st = lstatOrNull(path); if (!st) throw new Refusal("unavailable", "session root does not exist"); if (st.isSymbolicLink()) throw new Refusal("unsafe-path", "session root contains a symlink"); if (!st.isDirectory()) throw new Refusal("unsafe-path", "session root is not a directory"); } } // Session files directly inside a root, by name. Symlinks and anything that // is not a regular *.jsonl file are reported, never followed. export function listSessionFiles(root) { checkRoot(root); const files = [], refused = []; let dirents; try { dirents = readdirSync(root.dir, { withFileTypes: true }); } catch (err) { throw denied(err, "session root"); } for (const dirent of dirents) { if (!dirent.name.endsWith(".jsonl")) continue; if (!SESSION_NAME.test(dirent.name)) refused.push({ name: dirent.name, code: "unsafe-path" }); else if (dirent.isSymbolicLink()) refused.push({ name: dirent.name, code: "unsafe-path" }); else if (dirent.isFile()) files.push(dirent.name); } return { files: files.sort(), refused }; } // Opens one session file read-only. The caller must close the returned fd. export function openSessionFile(root, name) { if (typeof name !== "string" || name !== basename(name) || !SESSION_NAME.test(name)) throw new Refusal("unsafe-path", "not a session file name"); checkRoot(root); const path = join(root.dir, name); const before = lstatOrNull(path); if (!before) throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true }); if (before.isSymbolicLink()) throw new Refusal("unsafe-path", "session file is a symlink"); if (!before.isFile()) throw new Refusal("unsafe-path", "session file is not a regular file"); let fd; try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (err) { if (err.code === "ELOOP") throw new Refusal("unsafe-path", "session file became a symlink"); if (err.code === "ENOENT") throw new Refusal("unknown-conversation", "session file no longer exists", { reconcile: true }); throw denied(err, "session file"); } try { const st = fstatSync(fd, { bigint: true }); if (!st.isFile() || st.dev !== before.dev || st.ino !== before.ino) throw new Refusal("unsafe-path", "session file changed while it was opened"); checkRoot(root); return { fd, dev: st.dev.toString(), ino: st.ino.toString(), size: Number(st.size) }; } catch (err) { closeSync(fd); throw err; } } export function readRange(fd, start, length) { const buf = Buffer.alloc(length); let done = 0; while (done < length) { const n = readSync(fd, buf, done, length - done, start + done); if (n === 0) break; done += n; } return done === length ? buf : buf.subarray(0, done); } export { closeSync };