Skip to main content

ikigai_script/
backend.rs

1//! Where the atoms' state lives: a [`Backend`] the host chooses.
2//!
3//! # Why the state is held here, and not composed over `ikigai-store` or `urn:file:`
4//!
5//! The obvious design is the ledger's: author every read and write as a sub-request to a
6//! store's named graph (or to the host's file workspace through `urn:file:*`), and own no
7//! bytes. It does not work for scripts, and the reason is the kernel's own rule: **a
8//! sub-request carries the caller's capability unchanged** (`Invocation::issue`). Whatever
9//! this crate writes on a caller's behalf, the caller must hold the grant to write
10//! directly — and here the record IS the authority:
11//!
12//! - every **publisher** would hold write over the graph (or file) that stores a script's
13//!   granted-at-publish snapshot, so a publisher could rewrite their own snapshot through
14//!   `urn:iki:store:graph-update` and have their script run with grants they never held.
15//!   That is exactly the elevation this crate exists to rule out.
16//! - every **runner** records a run, so every runner — the anonymous principal included,
17//!   for a public script — would hold store write. An unauthenticated door holding write
18//!   over the run log can forge the log.
19//!
20//! So the atoms hold their own state, behind this trait, and nothing but this crate's
21//! endpoints write it. No caller needs a storage grant, and no storage grant reaches an
22//! authority record. The golden threads are still the kernel's: a Sink or Delete to
23//! `urn:script:{name}` cuts the thread named after it, and every derived read hangs from
24//! that thread.
25//!
26//! # Out-of-band edits are survivable
27//!
28//! [`DirBackend`] keeps plain files a person can read and edit, and assumes an editor got
29//! there first:
30//!
31//! - a head is replaced by compare-and-set against the head the writer READ, so a hand
32//!   edit in between is a typed `Conflict`, never a lost update;
33//! - a version file is checked against the digest it is named by on every read, so an
34//!   edited version is refused by name ("edited out of band") and every other version and
35//!   script still works;
36//! - a malformed head fails that script alone, with the file named; the catalog lists it
37//!   as broken rather than failing whole.
38//!
39//! What it cannot do is SEE an edit: a read the kernel cached before the edit is served
40//! until the host cuts `urn:script:{name}` (a watcher on the directory, or
41//! `sink urn:kernel:cut urn:script:{name}`), the same contract every file-backed resource
42//! has. A hand edit is the host operator's act — the same trust as the ceiling files in
43//! the config home — and the run-time intersection with the stored snapshot and the
44//! host's ceiling still holds whatever the files say.
45
46use std::collections::BTreeMap;
47use std::fs;
48use std::io::Write;
49use std::path::{Path, PathBuf};
50use std::sync::Mutex;
51
52use ikigai_core::{Error, Result};
53use serde::{Deserialize, Serialize};
54
55use crate::model::{Head, Language, Run, Version, SCHEMA};
56
57/// Where a host keeps its scripts. Every method is called from inside an endpoint, so an
58/// implementation must not block for long; both built-ins are synchronous and local.
59pub trait Backend: Send + Sync {
60    /// The head of `name`, or `None` when no such script exists.
61    fn head(&self, name: &str) -> Result<Option<Head>>;
62
63    /// Replace the head of `name` with `new`, **if** it is still `expected` (`None`: if no
64    /// head exists yet). Otherwise a typed `Conflict`: someone got there first.
65    fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()>;
66
67    /// Store a version under `name`. Idempotent: a version is its content.
68    fn put_version(&self, name: &str, version: &Version) -> Result<()>;
69
70    /// The version `digest` of `name`, or `None`. An implementation verifies the content
71    /// against the digest and refuses a mismatch.
72    fn version(&self, name: &str, digest: &str) -> Result<Option<Version>>;
73
74    /// Every script name that has a head, sorted.
75    fn names(&self) -> Result<Vec<String>>;
76
77    /// Record a run that is starting, under a fresh id (the `id` field is ignored), and
78    /// return the id.
79    fn start_run(&self, name: &str, run: &Run) -> Result<u64>;
80
81    /// Replace the record of a run already started.
82    fn finish_run(&self, name: &str, run: &Run) -> Result<()>;
83
84    /// Run `id` of `name`, or `None`.
85    fn run(&self, name: &str, id: u64) -> Result<Option<Run>>;
86
87    /// The run of `name` with the highest id, or `None`.
88    fn last_run(&self, name: &str) -> Result<Option<Run>>;
89}
90
91fn conflict(name: &str) -> Error {
92    Error::Conflict(format!(
93        "urn:script:{name} changed while this write was being prepared (another writer, \
94         or an edit out of band); read it again and retry"
95    ))
96}
97
98/// Every script in memory: for tests, and for hosts whose scripts are configuration that
99/// is published again at every start.
100#[derive(Default)]
101pub struct MemoryBackend {
102    scripts: Mutex<BTreeMap<String, Entry>>,
103}
104
105#[derive(Default)]
106struct Entry {
107    head: Option<Head>,
108    versions: BTreeMap<String, Version>,
109    runs: BTreeMap<u64, Run>,
110}
111
112impl MemoryBackend {
113    /// An empty backend.
114    pub fn new() -> MemoryBackend {
115        MemoryBackend::default()
116    }
117
118    fn lock(&self) -> std::sync::MutexGuard<'_, BTreeMap<String, Entry>> {
119        // A panic while holding the lock leaves plain data behind, never a half-written
120        // invariant (every mutation is a single insert), so a poisoned lock is still good.
121        self.scripts.lock().unwrap_or_else(|p| p.into_inner())
122    }
123}
124
125impl Backend for MemoryBackend {
126    fn head(&self, name: &str) -> Result<Option<Head>> {
127        Ok(self.lock().get(name).and_then(|e| e.head.clone()))
128    }
129
130    fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
131        let mut scripts = self.lock();
132        let entry = scripts.entry(name.to_string()).or_default();
133        if entry.head.as_ref() != expected {
134            return Err(conflict(name));
135        }
136        entry.head = Some(new.clone());
137        Ok(())
138    }
139
140    fn put_version(&self, name: &str, version: &Version) -> Result<()> {
141        self.lock()
142            .entry(name.to_string())
143            .or_default()
144            .versions
145            .insert(version.digest(), version.clone());
146        Ok(())
147    }
148
149    fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
150        Ok(self
151            .lock()
152            .get(name)
153            .and_then(|e| e.versions.get(digest).cloned()))
154    }
155
156    fn names(&self) -> Result<Vec<String>> {
157        Ok(self
158            .lock()
159            .iter()
160            .filter(|(_, e)| e.head.is_some())
161            .map(|(n, _)| n.clone())
162            .collect())
163    }
164
165    fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
166        let mut scripts = self.lock();
167        let runs = &mut scripts.entry(name.to_string()).or_default().runs;
168        let id = runs.keys().next_back().map_or(1, |last| last + 1);
169        runs.insert(id, Run { id, ..run.clone() });
170        Ok(id)
171    }
172
173    fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
174        self.lock()
175            .entry(name.to_string())
176            .or_default()
177            .runs
178            .insert(run.id, run.clone());
179        Ok(())
180    }
181
182    fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
183        Ok(self.lock().get(name).and_then(|e| e.runs.get(&id).cloned()))
184    }
185
186    fn last_run(&self, name: &str) -> Result<Option<Run>> {
187        Ok(self
188            .lock()
189            .get(name)
190            .and_then(|e| e.runs.values().next_back().cloned()))
191    }
192}
193
194/// Every script as plain files under one directory the host chooses:
195///
196/// ```text
197/// <root>/<name>/head.json               the head (compare-and-set on every write)
198/// <root>/<name>/versions/<hex>.json     one immutable version, named by its sha256
199/// <root>/<name>/runs/<id>.json          one run record
200/// ```
201///
202/// **One host process per directory**, the rule `ikigai-store` states for RocksDB: writes
203/// are serialized by a lock in this process, and a second process writing the same
204/// directory would race it. People editing files by hand are expected and survivable; see
205/// the [module documentation](self).
206pub struct DirBackend {
207    root: PathBuf,
208    write: Mutex<()>,
209}
210
211/// A version as it is written to disk.
212#[derive(Serialize, Deserialize)]
213struct VersionFile {
214    schema: u32,
215    language: Language,
216    requires: std::collections::BTreeSet<String>,
217    source: String,
218}
219
220impl DirBackend {
221    /// Open (creating it if needed) the directory `root`.
222    pub fn open(root: impl Into<PathBuf>) -> Result<DirBackend> {
223        let root = root.into();
224        fs::create_dir_all(&root).map_err(|e| io_error(&root, "create", &e))?;
225        Ok(DirBackend {
226            root,
227            write: Mutex::new(()),
228        })
229    }
230
231    /// The directory.
232    pub fn root(&self) -> &Path {
233        &self.root
234    }
235
236    fn dir(&self, name: &str) -> PathBuf {
237        self.root.join(name)
238    }
239
240    fn head_path(&self, name: &str) -> PathBuf {
241        self.dir(name).join("head.json")
242    }
243
244    fn version_path(&self, name: &str, digest: &str) -> Result<PathBuf> {
245        let hex = digest
246            .strip_prefix("sha256:")
247            .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
248            .ok_or_else(|| Error::InvalidArgument {
249                name: "digest".to_string(),
250                detail: format!("`{digest}` is not `sha256:` and 64 hex digits"),
251            })?;
252        Ok(self
253            .dir(name)
254            .join("versions")
255            .join(format!("{}.json", hex.to_ascii_lowercase())))
256    }
257
258    fn run_path(&self, name: &str, id: u64) -> PathBuf {
259        self.dir(name).join("runs").join(format!("{id}.json"))
260    }
261
262    fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
263        self.write.lock().unwrap_or_else(|p| p.into_inner())
264    }
265
266    fn read_head(&self, name: &str) -> Result<Option<Head>> {
267        let path = self.head_path(name);
268        match read_json::<Head>(&path)? {
269            Some(head) if head.name != name => Err(Error::Endpoint(format!(
270                "{} names the script `{}`, not `{name}` (edited or moved out of band)",
271                path.display(),
272                head.name
273            ))),
274            other => Ok(other),
275        }
276    }
277
278    /// The highest run id on disk for `name` (0 when none).
279    fn last_run_id(&self, name: &str) -> Result<u64> {
280        let dir = self.dir(name).join("runs");
281        let entries = match fs::read_dir(&dir) {
282            Ok(entries) => entries,
283            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
284            Err(e) => return Err(io_error(&dir, "list", &e)),
285        };
286        let mut last = 0;
287        for entry in entries {
288            let entry = entry.map_err(|e| io_error(&dir, "list", &e))?;
289            let file = entry.file_name();
290            if let Some(id) = file
291                .to_str()
292                .and_then(|f| f.strip_suffix(".json"))
293                .and_then(|stem| stem.parse::<u64>().ok())
294            {
295                last = last.max(id);
296            }
297        }
298        Ok(last)
299    }
300}
301
302impl Backend for DirBackend {
303    fn head(&self, name: &str) -> Result<Option<Head>> {
304        self.read_head(name)
305    }
306
307    fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
308        let _guard = self.lock();
309        // Read again under the lock: whatever is on disk NOW is what an editor left.
310        let current = match self.read_head(name) {
311            Ok(current) => current,
312            // A head someone broke by hand cannot match what the writer read.
313            Err(_) if expected.is_some() => return Err(conflict(name)),
314            Err(e) => return Err(e),
315        };
316        if current.as_ref() != expected {
317            return Err(conflict(name));
318        }
319        write_json(&self.head_path(name), new)
320    }
321
322    fn put_version(&self, name: &str, version: &Version) -> Result<()> {
323        let path = self.version_path(name, &version.digest())?;
324        let _guard = self.lock();
325        // Rewritten even when present: a version file someone damaged by hand is repaired
326        // by publishing the same content again, which is the natural thing to try.
327        write_json(
328            &path,
329            &VersionFile {
330                schema: SCHEMA,
331                language: version.language,
332                requires: version.requires.clone(),
333                source: version.source.clone(),
334            },
335        )
336    }
337
338    fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
339        let path = self.version_path(name, digest)?;
340        let Some(file) = read_json::<VersionFile>(&path)? else {
341            return Ok(None);
342        };
343        let version = Version {
344            language: file.language,
345            requires: file.requires,
346            source: file.source,
347        };
348        if version.digest() != digest.to_ascii_lowercase() {
349            return Err(Error::Endpoint(format!(
350                "{} does not match the digest it is named by (edited out of band); this \
351                 version cannot be trusted. Publish the content again to restore it",
352                path.display()
353            )));
354        }
355        Ok(Some(version))
356    }
357
358    fn names(&self) -> Result<Vec<String>> {
359        let entries = fs::read_dir(&self.root).map_err(|e| io_error(&self.root, "list", &e))?;
360        let mut names = Vec::new();
361        for entry in entries {
362            let entry = entry.map_err(|e| io_error(&self.root, "list", &e))?;
363            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
364                continue;
365            };
366            // Anything that is not a script directory (a stray file, an editor's backup)
367            // is not a script; it is skipped, not an error.
368            if crate::name::validate(&name).is_ok() && self.head_path(&name).is_file() {
369                names.push(name);
370            }
371        }
372        names.sort();
373        Ok(names)
374    }
375
376    fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
377        let _guard = self.lock();
378        let mut id = self.last_run_id(name)? + 1;
379        loop {
380            let path = self.run_path(name, id);
381            if let Some(parent) = path.parent() {
382                fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
383            }
384            let bytes = to_json(&Run { id, ..run.clone() })?;
385            // `create_new`: an id is taken by whoever creates its file first, so a record
386            // written by hand (or by another process, against the rule) is never
387            // overwritten; the next id is tried instead.
388            match fs::OpenOptions::new()
389                .write(true)
390                .create_new(true)
391                .open(&path)
392            {
393                Ok(mut file) => {
394                    file.write_all(&bytes)
395                        .map_err(|e| io_error(&path, "write", &e))?;
396                    return Ok(id);
397                }
398                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => id += 1,
399                Err(e) => return Err(io_error(&path, "create", &e)),
400            }
401        }
402    }
403
404    fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
405        let _guard = self.lock();
406        write_json(&self.run_path(name, run.id), run)
407    }
408
409    fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
410        read_json(&self.run_path(name, id))
411    }
412
413    fn last_run(&self, name: &str) -> Result<Option<Run>> {
414        match self.last_run_id(name)? {
415            0 => Ok(None),
416            id => self.run(name, id),
417        }
418    }
419}
420
421fn io_error(path: &Path, what: &str, e: &std::io::Error) -> Error {
422    Error::Unavailable(format!("could not {what} {}: {e}", path.display()))
423}
424
425fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
426    let mut bytes = serde_json::to_vec_pretty(value)
427        .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
428    bytes.push(b'\n');
429    Ok(bytes)
430}
431
432/// Read and parse `path`; `None` when it does not exist. A file that does not parse is an
433/// error naming the file, never a silent absence.
434fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
435    let bytes = match fs::read(path) {
436        Ok(bytes) => bytes,
437        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
438        Err(e) => return Err(io_error(path, "read", &e)),
439    };
440    serde_json::from_slice(&bytes).map(Some).map_err(|e| {
441        Error::Endpoint(format!(
442            "{} is not a record this crate wrote (edited out of band?): {e}",
443            path.display()
444        ))
445    })
446}
447
448/// Write `value` to `path` atomically: a sibling temporary file, then a rename, so a
449/// reader (or a crash) never sees half a record.
450fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
451    let parent = path
452        .parent()
453        .ok_or_else(|| Error::Endpoint(format!("{} has no parent directory", path.display())))?;
454    fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
455    let tmp = path.with_extension("json.tmp");
456    fs::write(&tmp, to_json(value)?).map_err(|e| io_error(&tmp, "write", &e))?;
457    fs::rename(&tmp, path).map_err(|e| io_error(path, "replace", &e))
458}