diff --git a/api/agent/src/client.js b/api/agent/src/client.js index 7e1ae74a9..9d34fe84f 100644 --- a/api/agent/src/client.js +++ b/api/agent/src/client.js @@ -107,7 +107,9 @@ export function projectArgs(client) { return { project: client.projectName, home: client.options.home, deploy: client.options.deploy }; } -export const storeArgs = (client) => (client.options.storePath ? { storePath: client.options.storePath } : {}); +export const storeArgs = (client) => (client.options.storePath + ? { storePath: client.options.storePath, ...(client.options.readOnly ? { readOnly: true } : {}) } + : {}); /** Opens the client's Store in this process (one more client of it). */ export async function storeOpen(client) { diff --git a/api/agent/src/index.js b/api/agent/src/index.js index 446b59a53..fe73acef8 100644 --- a/api/agent/src/index.js +++ b/api/agent/src/index.js @@ -1,7 +1,9 @@ // @layerfs/agent (#428): what an agent's script calls. A client is opened by -// `LayerFS.open(name)` (an existing project) or `LayerFS.init(name, folder, -// timeout?)` (a new one) and released by `close()`; there is no public -// constructor and no options argument: settings come from the environment. +// `LayerFS.open(name)` (an existing project), `LayerFS.openReadOnly(name)` +// (the same, with the Store opened for reads only) or `LayerFS.init(name, +// folder, timeout?)` (a new one) and released by `close()`; there is no +// public constructor and no options argument: settings come from the +// environment. // Calls name their Branch. `sandbox.create()` starts a Sandbox (the only // call that does) and `sandbox.close` stops it; `workspace.mount(sandbox, // branch)` returns a Workspace, `{ sandbox, path }`, in the Sandbox it names @@ -78,6 +80,18 @@ export class LayerFS { return client; } + /** A ready client of the existing project `projectName`, with its Store + * opened for reads only: file-changing and history-mutating Store calls + * are refused `ReadOnly`. Workspace and Sandbox calls still go to the + * daemon. The constructor lists its settings. */ + static async openReadOnly(projectName, ...rest) { + noOptions("LayerFS.openReadOnly(name)", rest.some((value) => value !== undefined)); + const client = new LayerFS(projectName, FACTORY); + client.options.readOnly = true; + await opened(client); + return client; + } + /** Creates the project `projectName` from `folder` (one Init), then opens * it. `created` on the client is what Init returned. An uncertain Init * keeps everything it made. `timeout` (milliseconds, a positive whole @@ -92,7 +106,8 @@ export class LayerFS { return client; } - /** Not public: `LayerFS.open` and `LayerFS.init` make clients. Every + /** Not public: `LayerFS.open`, `LayerFS.openReadOnly` and `LayerFS.init` + * make clients. Every * setting is read from the environment once, here, into `options`, by its * LAYERFS_ name. LAYERFS_TRACE names a * per-call trace file; LAYERFS_TRACE_STEP, read at each call, labels its @@ -113,7 +128,7 @@ export class LayerFS { * `LayerFS.init` records instead of the default. */ constructor(projectName, token = undefined) { if (token !== FACTORY) { - throw new LayerFSError("InvalidInput", "use `await LayerFS.open(name)` or `await LayerFS.init(name, folder)`; there is no public constructor", null); + throw new LayerFSError("InvalidInput", "use `await LayerFS.open(name)`, `await LayerFS.openReadOnly(name)` or `await LayerFS.init(name, folder)`; there is no public constructor", null); } if (typeof projectName !== "string" || !/^[a-z0-9](?:[a-z0-9_.-]{0,61}[a-z0-9])?$/.test(projectName)) { throw new LayerFSError("InvalidInput", "a project name is required", null); diff --git a/api/agent/src/transport/worker_client.js b/api/agent/src/transport/worker_client.js index acec66952..8af1aca83 100644 --- a/api/agent/src/transport/worker_client.js +++ b/api/agent/src/transport/worker_client.js @@ -133,8 +133,9 @@ function initRun(init, request) { /** The binding that sends every call to the worker named by the endpoint file `file`. */ export function workerClient(file) { - /** Stores this process has opened, by path, with their clients. */ + /** Stores this process has opened, by path and open mode, with their clients. */ const opened = new Map(); + const storeKey = ({ storePath, readOnly }) => `${storePath}\0${readOnly ? "1" : "0"}`; async function send(fn, op, args) { let info; try { info = endpointOf(file); } catch (error) { return refused("Failed", `the LayerFS worker is not running for this home: ${error.message}`); } @@ -144,7 +145,7 @@ export function workerClient(file) { ...(info.docker ? { docker: info.docker } : {}), }; const sent = info.daemonHost || info.docker ? { ...args, deploy } : args; - const request = { fn, op, args: sent, open: opened.has(args.storePath) }; + const request = { fn, op, args: sent, open: opened.has(storeKey(args)) }; if (fn === "call" && op === "project.init" && info.init) return initRun(info.init, request); return connection(info).call(request); } @@ -152,13 +153,15 @@ export function workerClient(file) { call: (op, args) => send("call", op, args), backendCall: (op, args) => send("backendCall", op, args), /** The Store opens in the worker; here only its clients are counted. */ - async storeOpen({ storePath }) { - opened.set(storePath, (opened.get(storePath) ?? 0) + 1); + async storeOpen(args) { + const key = storeKey(args); + opened.set(key, (opened.get(key) ?? 0) + 1); return { value: null }; }, - async storeClose({ storePath }) { - const left = (opened.get(storePath) ?? 1) - 1; - if (left > 0) opened.set(storePath, left); else opened.delete(storePath); + async storeClose(args) { + const key = storeKey(args); + const left = (opened.get(key) ?? 1) - 1; + if (left > 0) opened.set(key, left); else opened.delete(key); return { value: null }; }, /** The worker returns a spooled exec's output whole, so there is no spool to read here. */ diff --git a/api/agent/test/fixture/fake-native.cjs b/api/agent/test/fixture/fake-native.cjs index 19dbe47be..c8e0fe2ac 100644 --- a/api/agent/test/fixture/fake-native.cjs +++ b/api/agent/test/fixture/fake-native.cjs @@ -36,6 +36,9 @@ function answer(op, args) { if (refuse.includes(op)) return refusal(process.env.CLI_FAKE_CODE || "Conflict", `${op} refused by the fake`, { op }); if (args.storePath === "/missing") return refusal("NoSuchProject", "no such project"); if (args.path === "/missing") return refusal("NoSuchPath", "/missing does not exist"); + if (args.readOnly && /^(fs\.(write|edit|apply|mkdir|rm|cp|mv)|branch\.(fork|merge|publish))$/.test(op)) { + return refusal("ReadOnly", "this Store is open read-only"); + } switch (op) { case "project.init": if (exports.initRefusal) return { refused: exports.initRefusal }; @@ -99,7 +102,7 @@ const traced = (op, args) => { }; exports.stores = []; // The in-process Store (#352 F-020): opened once per client, never traced. -exports.storeOpen = async (args) => { log("store.open", args); exports.stores.push(["open", args.storePath]); return { value: { clients: 1 } }; }; +exports.storeOpen = async (args) => { log("store.open", args); exports.stores.push(args.readOnly ? ["open", args.storePath, true] : ["open", args.storePath]); return { value: { clients: 1 } }; }; exports.storeClose = async (args) => { log("store.close", args); exports.stores.push(["close", args.storePath]); return { value: { clients: 0 } }; }; exports.call = async (op, args) => traced(op, args); exports.backendCall = async (op, args) => traced(op, args); diff --git a/api/agent/test/open_read_only.mjs b/api/agent/test/open_read_only.mjs new file mode 100644 index 000000000..6fdbc3dc4 --- /dev/null +++ b/api/agent/test/open_read_only.mjs @@ -0,0 +1,53 @@ +// LayerFS.openReadOnly over the stand-in binding: the Store is opened with +// readOnly, reads send it, and file-changing calls are refused ReadOnly. +// node api/agent/test/open_read_only.mjs +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { createRequire } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ENV } from "./fixture/cases.mjs"; + +const FAKE = fileURLToPath(new URL("./fixture/fake-native.cjs", import.meta.url)); +Object.assign(process.env, ENV, { LAYERFS_NATIVE: FAKE }); +delete process.env.CLI_FAKE_LOG; +delete process.env.LAYERFS_WORKER_ENDPOINT; +const native = createRequire(import.meta.url)(FAKE); +const { LayerFS } = await import("../src/index.js"); + +await assert.rejects(LayerFS.openReadOnly("website", {}), { + code: "InvalidInput", + message: /takes no options: settings come from the environment/, +}); + +const reader = await LayerFS.openReadOnly("website"); +assert.equal(reader.options.readOnly, true); +assert.deepEqual(native.stores.at(-1), ["open", ENV.LAYERFS_STORE_PATH, true]); +await reader.fs.read("main", "/README.md"); +assert.equal(native.args.at(-1).readOnly, true); +await assert.rejects(reader.fs.write("feat", "/README.md", "nope\n"), { + code: "ReadOnly", + message: /this Store is open read-only/, +}); +await assert.rejects(reader.branch.fork("other"), { code: "ReadOnly" }); +await reader.close(); + +const { fakeWorker } = await import("./fixture/fake-worker.mjs"); +const { useWorker } = await import("../src/internal.js"); +const worker = await fakeWorker(); +const dir = mkdtempSync(join(tmpdir(), "layerfs-open-read-only-")); +try { + const file = join(dir, "endpoint.json"); + worker.endpoint(file, ENV.LAYERFS_HOME); + useWorker(file); + const through = await LayerFS.openReadOnly("website"); + await through.fs.read("main", "/README.md"); + assert.equal(worker.calls.at(-1).args.readOnly, true); + await through.close(); +} finally { + worker.close(); + rmSync(dir, { recursive: true, force: true }); +} + +console.log("PASS open_read_only"); diff --git a/api/agent/test/packaging.mjs b/api/agent/test/packaging.mjs index 496a616b9..77093aa9b 100644 --- a/api/agent/test/packaging.mjs +++ b/api/agent/test/packaging.mjs @@ -81,10 +81,19 @@ if (selected === "surface") { assert.deepEqual(native.stores.slice(before), [["open", "/missing"], ["close", "/missing"]]); // the options argument is gone: an object where it went is refused, never ignored const asked = native.calls.length; - for (const call of [() => LayerFS.open("website", {}), () => LayerFS.open("website", { storePath: "/missing" }), () => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.init("fresh", "/src/fresh", 5000, {})]) { + for (const call of [() => LayerFS.open("website", {}), () => LayerFS.open("website", { storePath: "/missing" }), () => LayerFS.openReadOnly("website", {}), () => LayerFS.init("fresh", "/src/fresh", {}), () => LayerFS.init("fresh", "/src/fresh", { storePath: "/x" }, 5000), () => LayerFS.init("fresh", "/src/fresh", 5000, {})]) { await assert.rejects(call(), { code: "InvalidInput", message: /takes no options: settings come from the environment/ }); } assert.equal(native.calls.length, asked, "a refused options argument sends nothing"); + // openReadOnly opens the Store with readOnly and refuses mutation at the binding + const reader = await LayerFS.openReadOnly("website"); + assert.equal(reader.options.readOnly, true); + assert.deepEqual(native.stores.at(-1), ["open", "/fake/store.sqlite", true]); + await reader.fs.read("main", "/README.md"); + assert.equal(native.args.at(-1).readOnly, true); + await assert.rejects(reader.fs.write("feat", "/README.md", "nope\n"), { code: "ReadOnly", message: /this Store is open read-only/ }); + await assert.rejects(reader.branch.fork("other"), { code: "ReadOnly" }); + await reader.close(); // LayerFS.init runs project.init, then the same open const made = await LayerFS.init("fresh", "/src/fresh"); assert.deepEqual(native.calls.slice(-2), ["project.init", "project.list_branches"]); @@ -194,6 +203,11 @@ if (selected === "surface") { assert.equal(native.calls.length, local + 1, "the later client never reaches the addon"); assert.deepEqual(worker.calls.at(-1).op, "fs.read"); assert.equal(worker.calls.at(-1).open, true, "the worker opens the later client's Store"); + assert.equal("readOnly" in worker.calls.at(-1).args, false); + const reader = await LayerFS.openReadOnly("website"); + await reader.fs.read("main", "/README.md"); + assert.equal(worker.calls.at(-1).args.readOnly, true); + await reader.close(); // Init's timeout reaches the worker unchanged; an omitted one is the 300 s default await (await LayerFS.init("fresh", "/src/fresh", 2500)).close(); await (await LayerFS.init("fresh", "/src/fresh")).close(); diff --git a/api/cli/src/argv.js b/api/cli/src/argv.js index 246721176..c2b0c9fd1 100644 --- a/api/cli/src/argv.js +++ b/api/cli/src/argv.js @@ -14,13 +14,14 @@ export class UsageError extends Error { const camel = (name) => name.replace(/-([a-z])/g, (_, c) => c.toUpperCase()); -/** Splits the global options out of argv: --project , --text, -h/--help, --version. */ +/** Splits the global options out of argv: --project , --text, --read-only, -h/--help, --version. */ export function globals(argv) { - const g = { rest: [], project: undefined, text: false, help: false, version: false }; + const g = { rest: [], project: undefined, text: false, readOnly: false, help: false, version: false }; for (let i = 0; i < argv.length; i++) { const t = argv[i]; if (t === "--") { g.rest.push(...argv.slice(i)); break; } if (t === "--text") g.text = true; + else if (t === "--read-only") g.readOnly = true; else if (t === "-h" || t === "--help") g.help = true; else if (t === "--version") g.version = true; else if (t === "--project" || t.startsWith("--project=")) { diff --git a/api/cli/src/help.js b/api/cli/src/help.js index 8939df747..56b90b88f 100644 --- a/api/cli/src/help.js +++ b/api/cli/src/help.js @@ -26,6 +26,7 @@ usage: layerfs [args] [options] GLOBAL OPTIONS --project the project (default $LAYERFS_PROJECT) --text readable output instead of JSON + --read-only open the Store for reads only; writes are refused ReadOnly -h, --help help for the command SETTINGS come from the environment, as for the SDK: LAYERFS_HOME, LAYERFS_STORE_PATH, diff --git a/api/cli/src/main.js b/api/cli/src/main.js index fc44e81e6..375991d78 100644 --- a/api/cli/src/main.js +++ b/api/cli/src/main.js @@ -1,7 +1,7 @@ // layerfs (#428): one @layerfs/agent call per command. It finds the command // in the call table, parses its arguments, reads any input, opens the -// project with LayerFS.open, makes the call once, prints the result and -// closes. Nothing is retried. Exit 0 on success, 2 for a wrong command line +// project with LayerFS.open (or LayerFS.openReadOnly when --read-only), +// makes the call once, prints the result and closes. Nothing is retried. Exit 0 on success, 2 for a wrong command line // (nothing called), 125 when LayerFS refused or failed the call; exec // --stream exits with the command's own code. import { readFileSync } from "node:fs"; @@ -55,11 +55,12 @@ export async function main(argv, io) { const { LayerFS } = await import("@layerfs/agent"); let result; if (entry.init) { + if (g.readOnly) throw new UsageError("--read-only cannot be used with init"); result = await entry.run(LayerFS, a, o, exits); } else { const project = g.project ?? process.env.LAYERFS_PROJECT; if (!project) throw new UsageError("no LayerFS project: pass --project or set LAYERFS_PROJECT"); - const layerfs = await LayerFS.open(project); + const layerfs = await (g.readOnly ? LayerFS.openReadOnly : LayerFS.open)(project); try { result = await entry.run(layerfs, a, o, exits); } finally { diff --git a/api/cli/test/cli.test.mjs b/api/cli/test/cli.test.mjs index c91eb9f44..6476ad0d5 100644 --- a/api/cli/test/cli.test.mjs +++ b/api/cli/test/cli.test.mjs @@ -167,6 +167,27 @@ test("a missing project exits 2 before anything opens", () => { assert.deepEqual(r.calls, []); }); +test("--read-only opens the Store for reads only", () => { + const r = cli(["--read-only", "fs", "stat", "main", "/a"]); + assert.equal(r.code, 0, r.err); + const open = r.calls.find(([op]) => op === "store.open"); + assert.equal(open[1].readOnly, true); + assert.equal(r.made[0][1].readOnly, true); +}); + +test("--read-only refuses a file write", () => { + const r = cli(["--read-only", "fs", "write", "cart", "/a"], { input: "x\n" }); + assert.equal(r.code, 125, r.err); + assert.equal(JSON.parse(r.err).code, "ReadOnly"); +}); + +test("--read-only cannot be used with init", () => { + const r = cli(["--read-only", "init", "site", source]); + assert.equal(r.code, 2); + assert.match(r.err, /--read-only cannot be used with init/); + assert.deepEqual(r.calls, []); +}); + test("--project wins over LAYERFS_PROJECT; an integration's prefixed name is not read", () => { assert.equal(cli(["fs", "stat", "main", "/a", "--project", "other"]).made[0][1].project, "other"); assert.equal(cli(["--project=other", "fs", "stat", "main", "/a"]).made[0][1].project, "other"); diff --git a/api/docs/agent.md b/api/docs/agent.md index 330fcab91..2dc8bbc38 100644 --- a/api/docs/agent.md +++ b/api/docs/agent.md @@ -39,18 +39,25 @@ await layerfs.close(); ### Opening a project -A client is made by `LayerFS.open(name)` (an existing project) or -`LayerFS.init(name, folder, timeout?)` (Init, then the same open); both are -async and return a ready client, and `new LayerFS()` is refused -`InvalidInput`. **There is no options argument:** every setting comes from the -environment, read once when the client is made, by its `LAYERFS_` name only: -`LAYERFS_HOME`, `_STORE_PATH`, `_DOCKER`, `_IMAGE`, +A client is made by `LayerFS.open(name)` (an existing project), +`LayerFS.openReadOnly(name)` (the same, with the Store opened for reads +only) or `LayerFS.init(name, folder, timeout?)` (Init, then the same open as +`open`); all are async and return a ready client, and `new LayerFS()` is +refused `InvalidInput`. **There is no options argument:** every setting comes +from the environment, read once when the client is made, by its `LAYERFS_` +name only: `LAYERFS_HOME`, `_STORE_PATH`, `_DOCKER`, `_IMAGE`, `_VOLUME`, `_DAEMON`, `_CONTAINER_PREFIX`, `_DAEMON_HOST`, `_TRACE`, `_STORE_POLICY` (and `_TRACE_STEP`, read at each call). An object passed where -the options went (`LayerFS.open(name, {…})`, or an object as `LayerFS.init`'s -third argument) is refused `InvalidInput`, never ignored. `open` opens the project's Store and makes one project lookup -on it, so a missing project is `NoSuchProject` at once; if either fails, -nothing stays open. `close()` releases the Store (the process's last client of +the options went (`LayerFS.open(name, {…})`, `LayerFS.openReadOnly(name, {…})`, +or an object as `LayerFS.init`'s third argument) is refused `InvalidInput`, +never ignored. `open` and `openReadOnly` open the project's Store and make +one project lookup on it, so a missing project is `NoSuchProject` at once; if +either fails, nothing stays open. `openReadOnly` opens the catalog handle +read-only and does not create an overlay: file-changing Store calls +(`fs.write`, `fs.edit`, `fs.mkdir`, `fs.rm`, `fs.cp`, `fs.mv`, `fs.apply`) and +history-mutating ones (`branch.fork`, `branch.merge`, `branch.publish`) are +refused `ReadOnly` ("this Store is open read-only") before they bind or +write. Workspace and Sandbox calls still go to the daemon. `close()` releases the Store (the process's last client of it closes it), leaves Workspaces mounted, and makes every later call refuse `InvalidInput`; a second `close()` does nothing. `await using layerfs = await LayerFS.open(...)` closes it at the end of the block. One copy of @@ -267,7 +274,7 @@ trace line covers the command's run. | Group | Positional methods | | --- | --- | -| `LayerFS` (2) | `LayerFS.open(name)`, `LayerFS.init(name, folder, timeout?)`: static, each returns a ready client | +| `LayerFS` (3) | `LayerFS.open(name)`, `LayerFS.openReadOnly(name)`, `LayerFS.init(name, folder, timeout?)`: static, each returns a ready client | | project (2) | `list_branches(pageSize?, pagePos?)`, `list_sandboxes(pageSize?, pagePos?)` | | fs reads (7) | `read(ref, path, startLine?, endLine?)`, `list(ref, path?, pageSize?, pagePos?)`, `stat(ref, path)`, `grep(ref, pattern, path?, pageSize?, pagePos?)`, `glob(ref, pattern, path?, pageSize?, pagePos?)`, `diff_files(leftRef, leftPath, rightRef, rightPath)`, `blame(ref, path, fromLine?)` | | fs writes (7) | `write(branch, path, content, message? \| { message?, mode? })`, `edit(branch, path, oldText, newText, message?)`, `apply(branch, changes, message?)`, `mkdir(branch, path, message? \| { message?, mode? })`, `rm(branch, path, message?)`, `cp(branch, from, to, message?)`, `mv(branch, from, to, message?)` | @@ -400,7 +407,8 @@ merge, and a failed/unknown operation must be inspected before a new operation. `layerfs` runs one `@layerfs/agent` call per command, so an agent whose only tool is Bash can use LayerFS without writing a Node script for each call. -It is only a wrapper. Each command opens the project with `LayerFS.open`, +It is only a wrapper. Each command opens the project with `LayerFS.open` (or +`LayerFS.openReadOnly` when `--read-only` is given), makes exactly one SDK call through the SDK's public API, prints the result and closes the client. It adds no capability, check or retry of its own: the SDK checks the inputs and makes its one attempt, and the command reports what it @@ -458,7 +466,9 @@ layerfs sandbox close 1 same way. `fs edit` takes `--old ` or `--old-from `, and `--new` or `--new-from`. Input is read before anything opens. - **Global options**, anywhere before `--`: `--project `, `--text` - (readable output instead of JSON), `-h`/`--help`, `--version`. + (readable output instead of JSON), `--read-only` (opens the Store for reads + only; file-changing and history-mutating Store calls are refused `ReadOnly`; + cannot be used with `init`), `-h`/`--help`, `--version`. - **exec.** `workspace exec` prints the SDK's result as JSON, with the command's `exitCode` inside, and exits 0. With `--stream` it writes the command's stdout and stderr through as they arrive, prints nothing else and @@ -538,7 +548,8 @@ table differs, and when a family's files stop matching the SDK's. ### Cost per command Each command is a new Node process. Before its one call it loads the SDK and -the native addon and opens the project's Store (`LayerFS.open` also makes one +the native addon and opens the project's Store (`LayerFS.open` and +`LayerFS.openReadOnly` also make one `project.list_branches` call to check the project exists); a Workspace call also connects to the Sandbox's daemon. An SDK script pays this once for many calls. This cost has not been measured. For a loop of many calls, a script is diff --git a/api/runtime/src/worker/call.js b/api/runtime/src/worker/call.js index 3bc573e02..3dfb78793 100644 --- a/api/runtime/src/worker/call.js +++ b/api/runtime/src/worker/call.js @@ -5,7 +5,12 @@ const PAGE = 1 << 20; /** The Store a call names, as the binding's storeOpen and storeClose take it. */ -export const storeOf = (args) => ({ project: args.project, home: args.home, storePath: args.storePath }); +export const storeOf = (args) => ({ + project: args.project, home: args.home, storePath: args.storePath, + ...(args.readOnly ? { readOnly: true } : {}), +}); +/** One worker Store slot: path and whether it was opened read-only. */ +export const storeKey = (args) => `${args.storePath}\0${args.readOnly ? "1" : "0"}`; /** Makes the call once: its answer, `{ value }` or `{ refused }`. */ export async function made(binding, fn, op, args) { diff --git a/api/runtime/src/worker/server.js b/api/runtime/src/worker/server.js index 036d86903..fcc9714c6 100644 --- a/api/runtime/src/worker/server.js +++ b/api/runtime/src/worker/server.js @@ -33,7 +33,7 @@ import { tmpdir } from "node:os"; import { join, posix, resolve, sep } from "node:path"; import { promisify } from "node:util"; import { decode, frame, readFrames } from "@layerfs/agent/internal"; -import { made, storeOf } from "./call.js"; +import { made, storeKey, storeOf } from "./call.js"; const IDLE_MS = 60_000; /** The unauthenticated secret frame: its largest length, and how long a connection may wait to send it. */ @@ -107,46 +107,47 @@ mkdirSync(temp, { mode: 0o700 }); process.env.TMPDIR = temp; const binding = createRequire(import.meta.url)(process.env.LAYERFS_NATIVE || join(config.bundle, "layerfs.node")); -/** Open Stores by path: { opening, users, timer, args, closing }. */ +/** Open Stores by path and open mode: { opening, users, timer, args, closing }. */ const stores = new Map(), sockets = new Set(); let active = 0, closing = false, finishing = false; -async function closeStore(path) { - const held = stores.get(path); +async function closeStore(key) { + const held = stores.get(key); if (!held) return { value: null }; if (held.users) return { refused: { code: "Busy", message: "the project's Store has active calls", detail: null } }; clearTimeout(held.timer); held.closing ??= held.opening.then(() => binding.storeClose(storeOf(held.args))).then((answer) => { if (answer?.refused) throw new Error(JSON.stringify(answer.refused)); - }).finally(() => { if (stores.get(path) === held) stores.delete(path); }); + }).finally(() => { if (stores.get(key) === held) stores.delete(key); }); await held.closing; return { value: null }; } /** Runs `work` with the call's Store open: a Store that does not open answers its refusal. */ async function withStore(args, work) { - let held = stores.get(args.storePath); - while (held?.closing) { await held.closing.catch(() => {}); held = stores.get(args.storePath); } - if (!held) stores.set(args.storePath, (held = { opening: binding.storeOpen(storeOf(args)), users: 0, timer: null, args })); + const key = storeKey(args); + let held = stores.get(key); + while (held?.closing) { await held.closing.catch(() => {}); held = stores.get(key); } + if (!held) stores.set(key, (held = { opening: binding.storeOpen(storeOf(args)), users: 0, timer: null, args })); held.users++; clearTimeout(held.timer); try { const opened = await held.opening.catch((error) => ({ thrown: error })); if (opened?.refused || opened?.thrown) { - if (stores.get(args.storePath) === held) stores.delete(args.storePath); + if (stores.get(key) === held) stores.delete(key); if (opened.thrown) throw opened.thrown; return opened; } return await work(); } finally { - if (--held.users === 0 && stores.get(args.storePath) === held) { - held.timer = setTimeout(() => closeStore(args.storePath).catch((error) => console.error(`layerfs worker: closing ${args.storePath}: ${error}`)), IDLE_MS); + if (--held.users === 0 && stores.get(key) === held) { + held.timer = setTimeout(() => closeStore(key).catch((error) => console.error(`layerfs worker: closing ${args.storePath}: ${error}`)), IDLE_MS); } } } async function answer({ fn, op, args, open }) { - if (fn === "releaseStore") return closeStore(args.storePath); + if (fn === "releaseStore") return closeStore(storeKey(args)); const sent = stdin && fn === "call" && op === "project.init" && typeof args.path === "string" ? { ...args, path: await linuxPath(args.path) } : args; const call = () => made(binding, fn, op, sent); return open ? withStore(sent, call) : call(); diff --git a/engine/crates/layerfs-api/src/base/dispatch.rs b/engine/crates/layerfs-api/src/base/dispatch.rs index 0db081d12..5dfd4f2ae 100644 --- a/engine/crates/layerfs-api/src/base/dispatch.rs +++ b/engine/crates/layerfs-api/src/base/dispatch.rs @@ -5,8 +5,27 @@ use crate::{branch, fs, project}; use layerfs_bridge::control::{SdkCall, SdkValue}; use layerfs_store_service::StoreService; +fn writes(method: &str) -> bool { + matches!( + method, + "branch.fork" + | "branch.merge" + | "branch.publish" + | "fs.write" + | "fs.edit" + | "fs.mkdir" + | "fs.rm" + | "fs.cp" + | "fs.mv" + | "fs.apply" + ) +} + /// Runs one public Store method once, in this process, on `service`. pub fn call(service: &StoreService, call: &SdkCall) -> Answer { + if writes(&call.method) { + service.require_write()?; + } let args = &call.args; if let Some(value) = args.get("message") { if !matches!(value, SdkValue::Null) { diff --git a/engine/crates/layerfs-api/tests/store_read_only.rs b/engine/crates/layerfs-api/tests/store_read_only.rs new file mode 100644 index 000000000..cc68d1ab8 --- /dev/null +++ b/engine/crates/layerfs-api/tests/store_read_only.rs @@ -0,0 +1,162 @@ +//! A read-only Store open answers Store reads and refuses mutation `ReadOnly` +//! before it binds or writes (#485). +#[path = "support/installed_store.rs"] +#[allow(dead_code)] +mod installed; +#[path = "support/sdk.rs"] +#[allow(dead_code)] +mod support; + +use layerfs_bridge::control::{SdkCall, SdkRefusal, SdkValue}; +use layerfs_store_service::{bootstrap::open_store_read_only, StoreService}; +use support::Fixture; + +fn t(text: &str) -> SdkValue { + SdkValue::text(text) +} + +fn try_call( + service: &StoreService, + method: &str, + mut fields: Vec<(&str, SdkValue)>, +) -> Result { + fields.push(("project", t("installed"))); + layerfs_api::call( + service, + &SdkCall { + method: method.into(), + args: SdkValue::Map( + fields + .into_iter() + .map(|(name, value)| (name.to_owned(), value)) + .collect(), + ), + }, + ) +} + +fn call(service: &StoreService, method: &str, fields: Vec<(&str, SdkValue)>) -> SdkValue { + try_call(service, method, fields) + .unwrap_or_else(|refusal| panic!("{method} refused: {refusal:?}")) +} + +fn refused(service: &StoreService, method: &str, fields: Vec<(&str, SdkValue)>) -> SdkRefusal { + try_call(service, method, fields).expect_err("the call is refused") +} + +fn read_only_is(code: &str, message: &str) { + assert_eq!( + (code, message), + ("ReadOnly", "this Store is open read-only") + ); +} + +#[test] +fn a_read_only_store_serves_reads_and_refuses_writes() { + let fixture = Fixture::new("store-read-only", |source| { + std::fs::write(source.join("hello.txt"), b"hello\n").unwrap(); + 1 + }); + fixture.fork("feat"); + fixture.call( + "fs.write", + vec![ + ("branch", t("feat")), + ("path", t("/hello.txt")), + ("content", t("hello\nworld\n")), + ], + ); + let host = fixture.close(); + + let opened = open_store_read_only( + host.config.clone(), + installed::BINDING, + installed::CURSOR, + 1, + 64 * 1024, + layerfs_storage::ReservationBlocks::default(), + ) + .expect("read-only Store sessions"); + let service = StoreService::from_read_only(opened); + assert!(service.is_read_only()); + + let read = call( + &service, + "fs.read", + vec![ + ("branch", t("feat")), + ("path", t("/hello.txt")), + ("startLine", SdkValue::Int(1)), + ("endLine", SdkValue::Int(20)), + ], + ); + assert_eq!(read.get_text("text"), Some("hello\nworld\n")); + + let listed = call(&service, "project.list_branches", vec![]); + let names: Vec<_> = support::items(&listed) + .iter() + .map(|item| item.get_text("name").unwrap()) + .collect(); + assert!( + names.contains(&"main") && names.contains(&"feat"), + "{names:?}" + ); + + let commits = call(&service, "branch.list_commits", vec![("branch", t("feat"))]); + assert_eq!(support::items(&commits).len(), 1); + + let write = refused( + &service, + "fs.write", + vec![ + ("branch", t("feat")), + ("path", t("/hello.txt")), + ("content", t("nope\n")), + ], + ); + read_only_is(&write.code, &write.message); + let mkdir = refused( + &service, + "fs.mkdir", + vec![("branch", t("feat")), ("path", t("/nope"))], + ); + read_only_is(&mkdir.code, &mkdir.message); + let fork = refused(&service, "branch.fork", vec![("name", t("other"))]); + read_only_is(&fork.code, &fork.message); + let merge = refused( + &service, + "branch.merge", + vec![ + ("branch", t("feat")), + ("with", t("main")), + ("name", t("merged")), + ], + ); + read_only_is(&merge.code, &merge.message); + let publish = refused(&service, "branch.publish", vec![("branch", t("feat"))]); + read_only_is(&publish.code, &publish.message); + + let again = call( + &service, + "fs.read", + vec![ + ("branch", t("feat")), + ("path", t("/hello.txt")), + ("startLine", SdkValue::Int(1)), + ("endLine", SdkValue::Int(20)), + ], + ); + assert_eq!(again.get_text("text"), Some("hello\nworld\n")); + assert_eq!( + support::items(&call( + &service, + "branch.list_commits", + vec![("branch", t("feat"))] + )) + .len(), + 1 + ); + + service.close().unwrap(); + host.cleanup(); +} diff --git a/engine/crates/layerfs-node/src/direct.rs b/engine/crates/layerfs-node/src/direct.rs index 0b47e3dae..617efbb6c 100644 --- a/engine/crates/layerfs-node/src/direct.rs +++ b/engine/crates/layerfs-node/src/direct.rs @@ -1,12 +1,14 @@ //! Store calls in this process (#352 F-020): the only route for fs, branch, //! project and backend Store calls. //! -//! One `StoreService` per Store path per process, opened by the first client -//! that names the path and closed when the last one releases it. It is a live -//! opener of the installed Store, as a daemon is: never sealed here. The -//! kernel guard runs before any file is opened; where it refuses (macOS, a -//! filesystem that does not carry SQLite locks), every Store call fails -//! `Unsupported` with its reason. There is no other route. +//! One `StoreService` per Store path and open mode per process, opened by +//! the first client that names the path (writable, or read-only) and closed +//! when the last one of that mode releases it. A read-only open holds no +//! writable catalog handle and creates no overlay. It is a live opener of +//! the installed Store, as a daemon is: never sealed here. The kernel guard +//! runs before any file is opened; where it refuses (macOS, a filesystem +//! that does not carry SQLite locks), every Store call fails `Unsupported` +//! with its reason. There is no other route. //! //! The private overlays file-changing calls construct through live in this //! process's temporary directory, under a random name so a directory left by @@ -27,11 +29,11 @@ use std::sync::{Arc, LazyLock, Mutex}; struct Opened { service: Arc, clients: usize, - /// This service's private overlay directory. - directory: PathBuf, + /// This service's private overlay directory; none when opened read-only. + directory: Option, } -static STORES: LazyLock>> = LazyLock::new(Default::default); +static STORES: LazyLock>> = LazyLock::new(Default::default); /// The Store path a call names. fn path(args: &SdkValue) -> Result { @@ -44,47 +46,67 @@ fn path(args: &SdkValue) -> Result { } } +fn key(args: &SdkValue) -> Result<(PathBuf, bool), SdkRefusal> { + Ok((path(args)?, args.get_bool("readOnly"))) +} + +fn open_refusal(refusal: OpenRefusal) -> SdkRefusal { + match refusal { + OpenRefusal::Unsupported(reason) => refused("Unsupported", reason.0), + OpenRefusal::Missing(reason) => refused("NoStore", reason), + OpenRefusal::Failed(reason) => refused("Failed", reason), + } +} + /// Opens the Store at `store` for this process, or refuses. fn open(store: &Path, args: &SdkValue) -> Result { let project = crate::discovery::project(args)?; + if args.get_bool("readOnly") { + return StoreService::open_read_only( + store, + &project.manifest.binding, + project.manifest.cursor_key, + ) + .map(|service| Opened { + service: Arc::new(service), + clients: 0, + directory: None, + }) + .map_err(open_refusal); + } let directory = std::env::temp_dir().join(format!( "layerfs-store-{}-{}", std::process::id(), layerfs_bridge::sdk_values::hex(&crate::registry::random()?[..12]) )); std::fs::create_dir(&directory).map_err(failed)?; - let opened = StoreService::open_direct( + match StoreService::open_direct( store, &project.manifest.binding, project.manifest.cursor_key, &directory.join("overlay.sqlite"), - ); - match opened { + ) { Ok(service) => Ok(Opened { service: Arc::new(service), clients: 0, - directory, + directory: Some(directory), }), Err(refusal) => { let _ = std::fs::remove_dir_all(&directory); - Err(match refusal { - OpenRefusal::Unsupported(reason) => refused("Unsupported", reason.0), - OpenRefusal::Missing(reason) => refused("NoStore", reason), - OpenRefusal::Failed(reason) => refused("Failed", reason), - }) + Err(open_refusal(refusal)) } } } /// `storeOpen`: one more client of the Store a call names, opening it first. pub(crate) fn acquire(args: &SdkValue) -> Outcome { - let store = path(args)?; + let key = key(args)?; let mut stores = lock(&STORES)?; - if !stores.contains_key(&store) { - let opened = open(&store, args)?; - stores.insert(store.clone(), opened); + if !stores.contains_key(&key) { + let opened = open(&key.0, args)?; + stores.insert(key.clone(), opened); } - let opened = stores.get_mut(&store).expect("opened above"); + let opened = stores.get_mut(&key).expect("opened above"); opened.clients += 1; Ok(SdkValue::map([( "clients", @@ -94,11 +116,12 @@ pub(crate) fn acquire(args: &SdkValue) -> Outcome { /// `storeClose`: one client less; the last one closes the Store and removes /// its overlay directory. A call still running keeps the service alive until -/// it returns, and the service closes when it is dropped then. +/// it returns, and the service closes when it is dropped then. A read-only +/// open has no overlay directory. pub(crate) fn release(args: &SdkValue) -> Outcome { - let store = path(args)?; + let key = key(args)?; let mut stores = lock(&STORES)?; - let Some(opened) = stores.get_mut(&store) else { + let Some(opened) = stores.get_mut(&key) else { return Err(refused("InvalidInput", "this process holds no such Store")); }; opened.clients = opened.clients.saturating_sub(1); @@ -108,22 +131,25 @@ pub(crate) fn release(args: &SdkValue) -> Outcome { SdkValue::Int(opened.clients as i64), )])); } - let opened = stores.remove(&store).expect("present above"); + let opened = stores.remove(&key).expect("present above"); drop(stores); let closed = match Arc::try_unwrap(opened.service) { Ok(service) => service.close().map_err(failed), Err(_) => Ok(()), }; - let removed = std::fs::remove_dir_all(&opened.directory).map_err(failed); + let removed = match opened.directory { + Some(directory) => std::fs::remove_dir_all(&directory).map_err(failed), + None => Ok(()), + }; closed.and(removed)?; Ok(SdkValue::map([("clients", SdkValue::Int(0))])) } /// The open service of the Store a call names. fn service(args: &SdkValue) -> Result, SdkRefusal> { - let store = path(args)?; + let key = key(args)?; lock(&STORES)? - .get(&store) + .get(&key) .map(|opened| opened.service.clone()) .ok_or_else(|| refused("InvalidInput", "the client has not opened its Store")) } @@ -136,7 +162,10 @@ pub(crate) fn call(method: &str, args: SdkValue, backend: bool) -> Outcome { fields .into_iter() .filter(|(name, _)| { - !matches!(name.as_str(), "storePath" | "sandbox" | "deploy" | "home") + !matches!( + name.as_str(), + "storePath" | "sandbox" | "deploy" | "home" | "readOnly" + ) }) .collect(), ), diff --git a/engine/crates/layerfs-node/src/lib.rs b/engine/crates/layerfs-node/src/lib.rs index 298d5020a..de98b935d 100644 --- a/engine/crates/layerfs-node/src/lib.rs +++ b/engine/crates/layerfs-node/src/lib.rs @@ -33,6 +33,7 @@ pub fn exec_output( } /// One more client of the Store at `args.storePath` in this process. +/// `readOnly: true` opens it for reads only and creates no overlay. #[napi] pub fn store_open(args: JsUnknown) -> napi::Result> { dispatch::store(args, direct::acquire) diff --git a/engine/crates/layerfs-store-service/src/bootstrap.rs b/engine/crates/layerfs-store-service/src/bootstrap.rs index db9093f69..b7ca82ffc 100644 --- a/engine/crates/layerfs-store-service/src/bootstrap.rs +++ b/engine/crates/layerfs-store-service/src/bootstrap.rs @@ -121,12 +121,58 @@ pub fn open_store_observed( workspace_capacity: usize, cache_bytes: usize, reservations: ReservationBlocks, +) -> StorageResult { + open_store_sessions( + config, + binding, + cursor_key, + workspace_capacity, + cache_bytes, + reservations, + true, + ) +} + +/// The same sessions as [`open_store_observed`], with the catalog handle +/// opened read-only: mutation is refused before BEGIN, and merge scratch is +/// not installed. +pub fn open_store_read_only( + config: PersistenceConfig, + binding: &[u8], + cursor_key: [u8; 32], + workspace_capacity: usize, + cache_bytes: usize, + reservations: ReservationBlocks, +) -> StorageResult { + open_store_sessions( + config, + binding, + cursor_key, + workspace_capacity, + cache_bytes, + reservations, + false, + ) +} + +fn open_store_sessions( + config: PersistenceConfig, + binding: &[u8], + cursor_key: [u8; 32], + workspace_capacity: usize, + cache_bytes: usize, + reservations: ReservationBlocks, + writable: bool, ) -> StorageResult { if workspace_capacity == 0 { return Err(StorageError::Integrity("empty Workspace reader capacity")); } reservations.validate()?; - let writer = Handles::open_writable(config.clone(), binding, cursor_key)?; + let writer = if writable { + Handles::open_writable(config.clone(), binding, cursor_key)? + } else { + Handles::open_read_only(config.clone(), binding, cursor_key)? + }; let sqlite_version = writer.profile().sqlite_version.clone(); let observations = Arc::new(Mutex::new(ReaderObservations { live: BTreeMap::new(), @@ -161,18 +207,20 @@ pub fn open_store_observed( })) }); let control = factory(0)?; - let store = Arc::new( - Store::new( - writer.storage.clone(), - Arc::new(writer.history), - control, - factory, - cache_bytes, - reservations, - workspace_capacity, - )? - .with_merge_scratch(writer.storage.merge_scratch()), - ); + let store = Store::new( + writer.storage.clone(), + Arc::new(writer.history), + control, + factory, + cache_bytes, + reservations, + workspace_capacity, + )?; + let store = Arc::new(if writable { + store.with_merge_scratch(writer.storage.merge_scratch()) + } else { + store + }); Ok(OpenedStore { store, sqlite_version, diff --git a/engine/crates/layerfs-store-service/src/store_service.rs b/engine/crates/layerfs-store-service/src/store_service.rs index 791755cb8..ca447a4cd 100644 --- a/engine/crates/layerfs-store-service/src/store_service.rs +++ b/engine/crates/layerfs-store-service/src/store_service.rs @@ -7,7 +7,7 @@ //! outcome is uncertain keeps its Workspace here, with the original failure, //! instead of closing it: the call is answered `Unknown` and nothing is //! resent or discarded. -use crate::bootstrap::{open_store_observed, OpenedStore}; +use crate::bootstrap::{open_store_observed, open_store_read_only, OpenedStore}; use crate::kernel_guard::{self, Unsupported}; use crate::store::{BindRequest, BoundWorkspace, CommitFailure, Store}; use crate::{Owner, OwnerClient, OwnerConfig}; @@ -51,10 +51,11 @@ pub struct StoreService { pub opened: OpenedStore, pub store: Arc, owned: Option, - owner: OwnerClient, + owner: Option, capacity: usize, bound: AtomicUsize, retained: Mutex>, + read_only: bool, } impl StoreService { @@ -64,10 +65,11 @@ impl StoreService { store: opened.store.clone(), opened, owned: None, - owner: owner.client(), + owner: Some(owner.client()), capacity: owner.configuration().namespaces, bound: AtomicUsize::new(0), retained: Mutex::new(Vec::new()), + read_only: false, } } @@ -114,6 +116,71 @@ impl StoreService { Ok(service) } + /// Opens the installed Store at `store` for reads only. The kernel guard + /// runs first; then the Store, selected Disposable explicitly, with the + /// catalog handle opened read-only. No overlay template is created, and + /// file-changing and history-mutating calls are refused `ReadOnly` before + /// they bind or write. + pub fn open_read_only( + store: &Path, + binding: &[u8], + cursor_key: [u8; 32], + ) -> Result { + kernel_guard::check(store).map_err(OpenRefusal::Unsupported)?; + if !store.is_file() { + return Err(OpenRefusal::Missing(format!( + "no installed Store at {}", + store.display() + ))); + } + let opened = open_store_read_only( + PersistenceConfig::sqlite(store) + .with_sqlite_profile(SqlitePersistenceProfile::Disposable), + binding, + cursor_key, + 1, + CACHE_BYTES, + ReservationBlocks::default(), + ) + .map_err(|error| OpenRefusal::Failed(format!("Store open: {error}")))?; + Ok(Self::from_read_only(opened)) + } + + /// Serves Store reads over `opened` (from + /// [`crate::bootstrap::open_store_read_only`]). No overlay owner: + /// file-changing and history-mutating calls are refused `ReadOnly` + /// before they bind or write. + pub fn from_read_only(opened: OpenedStore) -> Self { + Self { + store: opened.store.clone(), + opened, + owned: None, + owner: None, + capacity: 1, + bound: AtomicUsize::new(0), + retained: Mutex::new(Vec::new()), + read_only: true, + } + } + + /// Refuses when this service opened the Store read-only. + pub fn require_write(&self) -> Result<(), SdkRefusal> { + if self.read_only { + Err(crate::history::lookup::refuse( + "ReadOnly", + "this Store is open read-only", + SdkValue::Null, + )) + } else { + Ok(()) + } + } + + /// Whether this service opened the Store read-only. + pub fn is_read_only(&self) -> bool { + self.read_only + } + /// Stops the overlay owner this service started. Private Workspaces kept /// with an uncertain Commit stay with it until then. pub fn close(mut self) -> Result<(), String> { @@ -130,11 +197,13 @@ impl StoreService { branch: BranchId, timing: &TimingScope<'_, Active>, ) -> Result, String> { - if let Some(stopped) = self + self.require_write() + .map_err(|error| format!("{}: {}", error.code, error.message))?; + let owner = self .owner - .maintenance_failure() - .map_err(|e| e.to_string())? - { + .as_ref() + .ok_or_else(|| "ReadOnly: this Store is open read-only".to_owned())?; + if let Some(stopped) = owner.maintenance_failure().map_err(|e| e.to_string())? { return Err(format!("overlay maintenance stopped: {stopped}")); } let admitted = self @@ -147,7 +216,7 @@ impl StoreService { } let bound = crate::telemetry::phase(timing, "store.bind", || { self.store - .bind_private(self.owner.clone(), BindRequest { workspace, branch }) + .bind_private(owner.clone(), BindRequest { workspace, branch }) }); match bound { Ok(success) => Ok(Arc::new(success.workspace)), @@ -162,9 +231,12 @@ impl StoreService { pub fn release(&self, workspace: &BoundWorkspace) -> Result<(), String> { workspace.close().map_err(|e| format!("{e:?}"))?; self.bound.fetch_sub(1, Ordering::AcqRel); - self.owner - .retire_namespace(workspace.route().namespace()) - .map_err(|e| e.to_string()) + match self.owner.as_ref() { + Some(owner) => owner + .retire_namespace(workspace.route().namespace()) + .map_err(|e| e.to_string()), + None => Ok(()), + } } /// Keeps a private Workspace whose Commit outcome is uncertain, with its diff --git a/engine/crates/layerfs-store-service/src/write/files.rs b/engine/crates/layerfs-store-service/src/write/files.rs index 195efecfc..c4c0ae32d 100644 --- a/engine/crates/layerfs-store-service/src/write/files.rs +++ b/engine/crates/layerfs-store-service/src/write/files.rs @@ -65,6 +65,7 @@ pub fn commit( changes: &[Arg>], message: Option<&str>, ) -> Answer { + service.require_write()?; let store = &service.store; let (branch_name, branch) = crate::telemetry::timed("files.resolve", |_| { let stack = args::project(store, project)?; diff --git a/engine/crates/layerfs-store-service/tests/kernel_guard.rs b/engine/crates/layerfs-store-service/tests/kernel_guard.rs index d9050f370..67db015b5 100644 --- a/engine/crates/layerfs-store-service/tests/kernel_guard.rs +++ b/engine/crates/layerfs-store-service/tests/kernel_guard.rs @@ -49,3 +49,48 @@ fn direct_access_is_unsupported_on_a_filesystem_outside_the_allowlist() { assert_eq!(before, after); std::fs::remove_dir_all(directory).unwrap(); } + +#[cfg(target_os = "linux")] +#[test] +fn a_read_only_open_is_unsupported_on_a_filesystem_outside_the_allowlist() { + let directory = scratch(Path::new("/dev/shm"), "tmpfs-ro"); + let store = directory.join("store.sqlite"); + std::fs::write(&store, b"not opened").unwrap(); + let before = std::fs::read(&store).unwrap(); + let outcome = StoreService::open_read_only(&store, b"binding", [7; 32]); + let reason = match outcome { + Err(OpenRefusal::Unsupported(reason)) => reason.0, + Err(other) => panic!("expected Unsupported, got {other:?}"), + Ok(_) => panic!("expected Unsupported, the Store opened"), + }; + assert!(reason.contains("ext4, xfs or btrfs"), "{reason}"); + assert_eq!(before, std::fs::read(&store).unwrap()); + let extras: Vec<_> = std::fs::read_dir(&directory) + .unwrap() + .map(|entry| entry.unwrap().file_name()) + .collect(); + assert_eq!(extras, [std::ffi::OsString::from("store.sqlite")]); + std::fs::remove_dir_all(directory).unwrap(); +} + +#[cfg(target_os = "linux")] +#[test] +fn a_read_only_open_names_a_missing_store() { + let directory = Path::new(env!("CARGO_TARGET_TMPDIR")) + .join(format!("layerfs-missing-ro-{}", std::process::id())); + std::fs::create_dir_all(&directory).unwrap(); + let store = directory.join("store.sqlite"); + match StoreService::open_read_only(&store, b"binding", [7; 32]) { + Err(OpenRefusal::Missing(reason)) => { + assert!(reason.contains("no installed Store"), "{reason}"); + } + Err(OpenRefusal::Unsupported(_)) => { + // The target directory is not on ext4/xfs/btrfs; the guard still + // ran before any file was created. + } + Err(other) => panic!("expected Missing or Unsupported, got {other:?}"), + Ok(_) => panic!("expected Missing or Unsupported, the Store opened"), + } + assert!(!store.exists()); + std::fs::remove_dir_all(directory).unwrap(); +}