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    /// Absent when the version declares no budget, so a file written before `budget` existed
218    /// reads as it did, and one that declares none is written as it was.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    budget: Option<u64>,
221    source: String,
222}
223
224impl DirBackend {
225    /// Open (creating it if needed) the directory `root`.
226    pub fn open(root: impl Into<PathBuf>) -> Result<DirBackend> {
227        let root = root.into();
228        fs::create_dir_all(&root).map_err(|e| io_error(&root, "create", &e))?;
229        Ok(DirBackend {
230            root,
231            write: Mutex::new(()),
232        })
233    }
234
235    /// The directory.
236    pub fn root(&self) -> &Path {
237        &self.root
238    }
239
240    fn dir(&self, name: &str) -> PathBuf {
241        self.root.join(name)
242    }
243
244    fn head_path(&self, name: &str) -> PathBuf {
245        self.dir(name).join("head.json")
246    }
247
248    fn version_path(&self, name: &str, digest: &str) -> Result<PathBuf> {
249        let hex = digest
250            .strip_prefix("sha256:")
251            .filter(|h| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()))
252            .ok_or_else(|| Error::InvalidArgument {
253                name: "digest".to_string(),
254                detail: format!("`{digest}` is not `sha256:` and 64 hex digits"),
255            })?;
256        Ok(self
257            .dir(name)
258            .join("versions")
259            .join(format!("{}.json", hex.to_ascii_lowercase())))
260    }
261
262    fn run_path(&self, name: &str, id: u64) -> PathBuf {
263        self.dir(name).join("runs").join(format!("{id}.json"))
264    }
265
266    fn lock(&self) -> std::sync::MutexGuard<'_, ()> {
267        self.write.lock().unwrap_or_else(|p| p.into_inner())
268    }
269
270    fn read_head(&self, name: &str) -> Result<Option<Head>> {
271        let path = self.head_path(name);
272        match read_json::<Head>(&path)? {
273            Some(head) if head.name != name => Err(Error::Endpoint(format!(
274                "{} names the script `{}`, not `{name}` (edited or moved out of band)",
275                path.display(),
276                head.name
277            ))),
278            other => Ok(other),
279        }
280    }
281
282    /// The highest run id on disk for `name` (0 when none).
283    fn last_run_id(&self, name: &str) -> Result<u64> {
284        let dir = self.dir(name).join("runs");
285        let entries = match fs::read_dir(&dir) {
286            Ok(entries) => entries,
287            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0),
288            Err(e) => return Err(io_error(&dir, "list", &e)),
289        };
290        let mut last = 0;
291        for entry in entries {
292            let entry = entry.map_err(|e| io_error(&dir, "list", &e))?;
293            let file = entry.file_name();
294            if let Some(id) = file
295                .to_str()
296                .and_then(|f| f.strip_suffix(".json"))
297                .and_then(|stem| stem.parse::<u64>().ok())
298            {
299                last = last.max(id);
300            }
301        }
302        Ok(last)
303    }
304}
305
306impl Backend for DirBackend {
307    fn head(&self, name: &str) -> Result<Option<Head>> {
308        self.read_head(name)
309    }
310
311    fn swap_head(&self, name: &str, expected: Option<&Head>, new: &Head) -> Result<()> {
312        let _guard = self.lock();
313        // Read again under the lock: whatever is on disk NOW is what an editor left.
314        let current = match self.read_head(name) {
315            Ok(current) => current,
316            // A head someone broke by hand cannot match what the writer read.
317            Err(_) if expected.is_some() => return Err(conflict(name)),
318            Err(e) => return Err(e),
319        };
320        if current.as_ref() != expected {
321            return Err(conflict(name));
322        }
323        write_json(&self.head_path(name), new)
324    }
325
326    fn put_version(&self, name: &str, version: &Version) -> Result<()> {
327        let path = self.version_path(name, &version.digest())?;
328        let _guard = self.lock();
329        // Rewritten even when present: a version file someone damaged by hand is repaired
330        // by publishing the same content again, which is the natural thing to try.
331        write_json(
332            &path,
333            &VersionFile {
334                schema: SCHEMA,
335                language: version.language,
336                requires: version.requires.clone(),
337                budget: version.budget,
338                source: version.source.clone(),
339            },
340        )
341    }
342
343    fn version(&self, name: &str, digest: &str) -> Result<Option<Version>> {
344        let path = self.version_path(name, digest)?;
345        let Some(file) = read_json::<VersionFile>(&path)? else {
346            return Ok(None);
347        };
348        let version = Version {
349            language: file.language,
350            requires: file.requires,
351            budget: file.budget,
352            source: file.source,
353        };
354        if version.digest() != digest.to_ascii_lowercase() {
355            return Err(Error::Endpoint(format!(
356                "{} does not match the digest it is named by (edited out of band); this \
357                 version cannot be trusted. Publish the content again to restore it",
358                path.display()
359            )));
360        }
361        Ok(Some(version))
362    }
363
364    fn names(&self) -> Result<Vec<String>> {
365        let entries = fs::read_dir(&self.root).map_err(|e| io_error(&self.root, "list", &e))?;
366        let mut names = Vec::new();
367        for entry in entries {
368            let entry = entry.map_err(|e| io_error(&self.root, "list", &e))?;
369            let Some(name) = entry.file_name().to_str().map(str::to_string) else {
370                continue;
371            };
372            // Anything that is not a script directory (a stray file, an editor's backup)
373            // is not a script; it is skipped, not an error.
374            if crate::name::validate(&name).is_ok() && self.head_path(&name).is_file() {
375                names.push(name);
376            }
377        }
378        names.sort();
379        Ok(names)
380    }
381
382    fn start_run(&self, name: &str, run: &Run) -> Result<u64> {
383        let _guard = self.lock();
384        let mut id = self.last_run_id(name)? + 1;
385        loop {
386            let path = self.run_path(name, id);
387            if let Some(parent) = path.parent() {
388                fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
389            }
390            let bytes = to_json(&Run { id, ..run.clone() })?;
391            // `create_new`: an id is taken by whoever creates its file first, so a record
392            // written by hand (or by another process, against the rule) is never
393            // overwritten; the next id is tried instead.
394            match fs::OpenOptions::new()
395                .write(true)
396                .create_new(true)
397                .open(&path)
398            {
399                Ok(mut file) => {
400                    file.write_all(&bytes)
401                        .map_err(|e| io_error(&path, "write", &e))?;
402                    return Ok(id);
403                }
404                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => id += 1,
405                Err(e) => return Err(io_error(&path, "create", &e)),
406            }
407        }
408    }
409
410    fn finish_run(&self, name: &str, run: &Run) -> Result<()> {
411        let _guard = self.lock();
412        write_json(&self.run_path(name, run.id), run)
413    }
414
415    fn run(&self, name: &str, id: u64) -> Result<Option<Run>> {
416        read_json(&self.run_path(name, id))
417    }
418
419    fn last_run(&self, name: &str) -> Result<Option<Run>> {
420        match self.last_run_id(name)? {
421            0 => Ok(None),
422            id => self.run(name, id),
423        }
424    }
425}
426
427fn io_error(path: &Path, what: &str, e: &std::io::Error) -> Error {
428    Error::Unavailable(format!("could not {what} {}: {e}", path.display()))
429}
430
431fn to_json<T: Serialize>(value: &T) -> Result<Vec<u8>> {
432    let mut bytes = serde_json::to_vec_pretty(value)
433        .map_err(|e| Error::Endpoint(format!("could not serialize a record: {e}")))?;
434    bytes.push(b'\n');
435    Ok(bytes)
436}
437
438/// Read and parse `path`; `None` when it does not exist. A file that does not parse is an
439/// error naming the file, never a silent absence.
440fn read_json<T: for<'de> Deserialize<'de>>(path: &Path) -> Result<Option<T>> {
441    let bytes = match fs::read(path) {
442        Ok(bytes) => bytes,
443        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
444        Err(e) => return Err(io_error(path, "read", &e)),
445    };
446    serde_json::from_slice(&bytes).map(Some).map_err(|e| {
447        Error::Endpoint(format!(
448            "{} is not a record this crate wrote (edited out of band?): {e}",
449            path.display()
450        ))
451    })
452}
453
454/// Write `value` to `path` atomically: a sibling temporary file, then a rename, so a
455/// reader (or a crash) never sees half a record.
456fn write_json<T: Serialize>(path: &Path, value: &T) -> Result<()> {
457    let parent = path
458        .parent()
459        .ok_or_else(|| Error::Endpoint(format!("{} has no parent directory", path.display())))?;
460    fs::create_dir_all(parent).map_err(|e| io_error(parent, "create", &e))?;
461    let tmp = path.with_extension("json.tmp");
462    fs::write(&tmp, to_json(value)?).map_err(|e| io_error(&tmp, "write", &e))?;
463    fs::rename(&tmp, path).map_err(|e| io_error(path, "replace", &e))
464}