Skip to main content

capsule_runtime/
state_store.rs

1//! Durable, capsule-scoped state stores: `~/.murmur/state/<store>/`, granted per artifact by
2//! `capabilities.state` and mounted into a guest as a second WASI preopen named
3//! [`STATE_PREOPEN_NAME`].
4//!
5//! Two properties decide the layout, and both are load-bearing:
6//!
7//! * **Outside every workdir.** A subdirectory of the session workdir is reachable by any artifact
8//!   that holds the workdir preopen, and `capabilities.filesystem.scope` cannot protect it —
9//!   `scope` is a single path prefix, so hiding one subtree from one artifact would mean narrowing
10//!   every *other* artifact to some sibling prefix. A path the workdir preopen cannot name is the
11//!   only version of this that holds.
12//! * **Keyed by capsule, not by workdir.** A workdir-keyed store would be an undeclared sharing
13//!   channel: two capsules launched in the same directory would read each other's state with no
14//!   grant on either side. Sharing between capsules goes over A2A, with a grant on both ends, or
15//!   it does not happen. A capsule wanting per-project notes writes them into the workdir — the
16//!   workdir already *is* the project. `state/` is for what outlives one.
17//!
18//! Resolution and creation are deliberately separate calls. [`state_store_reports`] resolves and
19//! validates without touching the filesystem, so `mur run --explain-scope` can print exactly what
20//! a launch would open while remaining the read-only diagnostic it claims to be; only
21//! [`ensure_state_store`], on the real staging path, creates anything.
22
23use std::path::{Component, Path, PathBuf};
24
25use crate::{containment::StateStoreReport, errors::RuntimeError};
26
27/// The guest path a granted state directory is preopened at. A guest reaches its store by writing
28/// `state/<file>` — a relative path resolved against this preopen rather than against the workdir
29/// preopen mounted as `"."`.
30///
31/// One stable literal, shared by both `build_wasi_ctx` functions and by the docs, so a guest never
32/// has to know which role it is running as to find its own store.
33pub const STATE_PREOPEN_NAME: &str = "state";
34
35/// Directory under the murmur home that holds every store, one subdirectory per store name.
36const STATE_ROOT_DIR: &str = "state";
37
38/// Mode applied to the state root and to each store directory: owner-only, because a store is
39/// exactly the durable half of one capsule's private working set.
40///
41/// Every platform murmur targets is Unix, so this is stated once here rather than behind a `cfg`
42/// fork — a future non-Unix target has one call site to handle, not one per directory.
43const STATE_DIR_MODE: u32 = 0o700;
44
45/// Whether `store` is a single usable directory segment under `~/.murmur/state/`.
46///
47/// A store name is one path segment: non-empty, no `/`, no `.` or `..` component, not absolute,
48/// and not beginning with `.`. The rule is deliberately stricter than
49/// [`crate::network_policy::validate_filesystem_scope`]'s, which permits nesting: a store name is
50/// an *identifier* for a capsule's state, and a nested one would let two declarations that read
51/// differently (`a/b` and `a`) resolve into overlapping trees.
52///
53/// The leading-dot ban subsumes `.` and `..`, but is kept as its own arm because the hazard is
54/// wider than escaping: a store called `.config` is a hidden directory under a root an operator
55/// inspects with `ls`, which is not somewhere state should be able to put itself.
56pub fn validate_store_name(store: &str) -> Result<(), RuntimeError> {
57    let refuse = |message: &str| {
58        Err(RuntimeError::InvalidStateStore {
59            store: store.to_string(),
60            message: message.to_string(),
61        })
62    };
63
64    if store.is_empty() {
65        return refuse("a store name must not be empty");
66    }
67    if store.contains('/') {
68        return refuse("a store name is a single path segment and must not contain '/'");
69    }
70    if store.starts_with('.') {
71        return refuse("a store name must not begin with '.'");
72    }
73
74    let path = Path::new(store);
75    if path.is_absolute() {
76        return refuse("a store name is a single path segment and must not be absolute");
77    }
78
79    let mut segments = path.components();
80    match (segments.next(), segments.next()) {
81        (Some(Component::Normal(_)), None) => Ok(()),
82        // `.` and `..` are already caught by the leading-dot arm; this arm catches whatever a
83        // future platform's path grammar admits (a prefix, a bare root) rather than letting it
84        // through by omission.
85        _ => refuse("a store name is a single path segment and must not contain '.' or '..'"),
86    }
87}
88
89/// Resolve `store`, create both the state root and the store directory at `0700`, and return the
90/// store's host path.
91///
92/// The only function here with side effects, and it is called only from the staging path — never
93/// from a diagnostic. A capsule that fails to launch for any other reason therefore leaves no
94/// directory behind, and an undeclared capability creates nothing at all.
95///
96/// `~/.murmur` itself is held at `0700` before the root beneath it is created.
97pub fn ensure_state_store(store: &str) -> Result<PathBuf, RuntimeError> {
98    validate_store_name(store)?;
99    let home = crate::murmur_home::ensure_murmur_home().map_err(|message| {
100        RuntimeError::StateStoreUnavailable {
101            store: store.to_string(),
102            path: "~/.murmur".to_string(),
103            message,
104        }
105    })?;
106    ensure_state_store_in(&home, store)
107}
108
109/// Every state store the given artifacts declare, resolved and validated but not created.
110///
111/// Takes `(artifact name, that artifact's operator-declared capabilities)` pairs rather than a
112/// concrete artifact type so the two callers that must agree can both satisfy it: `stage_session`
113/// holds [`crate::types::ArtifactRequest`]s, and `mur run --explain-scope` holds
114/// [`murmur_artifact::RuntimeArtifact`]s and has staged nothing. One resolution shared by both is
115/// what makes the report a description of the launch rather than a second opinion about it.
116///
117/// `capsule_name` supplies the default store name. It comes from the capsule operator's own
118/// manifest, never from an artifact's bundled one, so a tool pulled from a registry under a
119/// familiar name cannot claim a store that already holds someone else's state.
120///
121/// The home directory is looked up only once a declaration is actually found, so a capsule that
122/// declares no state anywhere still reports (and still launches) on a host where `HOME` is unset.
123pub fn state_store_reports<'a, I>(
124    artifacts: I,
125    capsule_name: &str,
126) -> Result<Vec<StateStoreReport>, RuntimeError>
127where
128    I: IntoIterator<Item = (&'a str, Option<&'a murmur_artifact::Capabilities>)>,
129{
130    let mut reports = Vec::new();
131    let mut home: Option<PathBuf> = None;
132
133    for (name, capabilities) in artifacts {
134        let Some(state) = capabilities.and_then(|capabilities| capabilities.state.as_ref()) else {
135            continue;
136        };
137        let store = resolve_store_name(state, capsule_name);
138        // Name shape first: a malformed declaration is refused identically on a host that cannot
139        // resolve a home directory at all, so the operator is told about the line they wrote
140        // rather than about their environment.
141        validate_store_name(&store)?;
142        let home = match home.as_deref() {
143            Some(home) => home.to_path_buf(),
144            None => home.insert(murmur_home(&store)?).clone(),
145        };
146        reports.push(StateStoreReport {
147            artifact: name.to_string(),
148            host_path: state_store_path_in(&home, &store)?.display().to_string(),
149            store,
150        });
151    }
152
153    Ok(reports)
154}
155
156/// The store name a declaration resolves to: what it named, or the capsule name when it named
157/// nothing. Shared by the reporting path and by both grant `derive` functions so a report can
158/// never name a different directory than the one a guest is handed.
159pub(crate) fn resolve_store_name(
160    state: &murmur_artifact::StateCapabilities,
161    capsule_name: &str,
162) -> String {
163    state
164        .store
165        .clone()
166        .unwrap_or_else(|| capsule_name.to_string())
167}
168
169/// Where `store` lives under a supplied murmur home. Resolves only — nothing is created and
170/// nothing is checked for existence — so `mur run --explain-scope` can print the path a launch
171/// would open without becoming a command that has side effects.
172///
173/// `store` is validated here rather than assumed, so no caller can turn an unvalidated name into
174/// a path by reaching for this function instead of [`validate_store_name`].
175fn state_store_path_in(murmur_home: &Path, store: &str) -> Result<PathBuf, RuntimeError> {
176    validate_store_name(store)?;
177    Ok(murmur_home.join(STATE_ROOT_DIR).join(store))
178}
179
180/// [`ensure_state_store`] against a supplied murmur home rather than one read from the
181/// environment — the seam the unit tests resolve through, so none of them has to mutate the test
182/// process's `HOME` and race every other test in the binary.
183fn ensure_state_store_in(murmur_home: &Path, store: &str) -> Result<PathBuf, RuntimeError> {
184    let store_path = state_store_path_in(murmur_home, store)?;
185    // The root is created (and its mode asserted) before the store beneath it, so a store
186    // directory is never reachable through a root a wider mode left open.
187    let root = store_path
188        .parent()
189        .expect("a store path always has the state root as its parent");
190    create_private_dir(store, root)?;
191    create_private_dir(store, &store_path)?;
192    Ok(store_path)
193}
194
195/// `~/.murmur`, or a refusal naming the store that needed it.
196///
197/// Reported as [`RuntimeError::StateStoreUnavailable`] rather than as a generic home-directory
198/// error because the operator's question is "why did my capsule not launch?", and the answer is
199/// the pairing of the two: a declaration that needs a durable location, and a host that cannot say
200/// where the user's home is.
201fn murmur_home(store: &str) -> Result<PathBuf, RuntimeError> {
202    murmur_home_dir().map_err(|message| RuntimeError::StateStoreUnavailable {
203        store: store.to_string(),
204        path: "~/.murmur".to_string(),
205        message,
206    })
207}
208
209/// `~/.murmur`, or why it could not be resolved.
210///
211/// Shared with [`crate::conversation`], so a host whose `HOME` is unusable says the same thing
212/// about a state store and about a conversation record. Each caller wraps the reason in its own
213/// error type.
214pub(crate) fn murmur_home_dir() -> Result<PathBuf, String> {
215    use crate::murmur_home::HomeUnusable;
216    let home = crate::murmur_home::home_path().map_err(|error| match error {
217        HomeUnusable::NotSet => {
218            "the home directory could not be resolved: HOME is not set in the environment"
219                .to_string()
220        }
221        HomeUnusable::NotAbsolute(_) => {
222            "the home directory could not be resolved: HOME is not an absolute path".to_string()
223        }
224        other @ HomeUnusable::CannotHold { .. } => other.to_string(),
225    })?;
226    Ok(home.join(".murmur"))
227}
228
229/// Create `path` if missing and hold it at [`STATE_DIR_MODE`].
230///
231/// The mode is applied whether or not this call created the directory: `0700` is what a store
232/// *is*, so a run must not silently continue against one an earlier umask, a restore or a stray
233/// `chmod` left group- or world-readable.
234fn create_private_dir(store: &str, path: &Path) -> Result<(), RuntimeError> {
235    ensure_private_dir(path, STATE_DIR_MODE).map_err(|message| {
236        RuntimeError::StateStoreUnavailable {
237            store: store.to_string(),
238            path: path.display().to_string(),
239            message,
240        }
241    })
242}
243
244/// Create `path` if missing and hold it at `mode`, reporting why it could not be done.
245///
246/// The one owner-only-directory helper in the runtime, shared with [`crate::conversation`]. The
247/// mode is applied whether or not this call created the directory: an earlier umask, a restore or
248/// a stray `chmod` must not leave a private directory group- or world-readable. Each caller wraps
249/// the reason in its own error type.
250pub(crate) fn ensure_private_dir(path: &Path, mode: u32) -> Result<(), String> {
251    use std::os::unix::fs::PermissionsExt;
252
253    std::fs::create_dir_all(path)
254        .map_err(|err| format!("failed to create the directory: {err}"))?;
255    std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
256        .map_err(|err| format!("failed to set mode {mode:04o}: {err}"))
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262    use murmur_artifact::{Capabilities, StateCapabilities};
263
264    fn state_capabilities(store: Option<&str>) -> Capabilities {
265        Capabilities {
266            network: None,
267            peer_fetch: None,
268            filesystem: None,
269            shell: None,
270            spawn: None,
271            env: None,
272            limits: None,
273            resources: None,
274            state: Some(StateCapabilities {
275                store: store.map(str::to_string),
276            }),
277            task_io: None,
278            conversation: None,
279            plan: None,
280            install: None,
281            harness: None,
282            containment: None,
283        }
284    }
285
286    #[test]
287    fn a_single_segment_is_the_only_accepted_store_name() {
288        for store in ["shey", "murmur-tool-corpus", "notes_2026", "a"] {
289            validate_store_name(store).unwrap_or_else(|err| panic!("{store} must be valid: {err}"));
290        }
291    }
292
293    /// Every rejection names the offending value, because the operator's next action is to find
294    /// that string in their own manifest.
295    #[test]
296    fn a_store_name_that_is_not_a_single_segment_is_refused_by_name() {
297        for store in [
298            "",
299            "../escape",
300            "/abs/path",
301            "a/b",
302            ".",
303            "..",
304            ".hidden",
305            "a/",
306        ] {
307            let err = match validate_store_name(store) {
308                Ok(()) => panic!("'{store}' must be refused"),
309                Err(err) => err,
310            };
311            assert!(
312                matches!(err, RuntimeError::InvalidStateStore { .. }),
313                "'{store}' must refuse as InvalidStateStore, got: {err}"
314            );
315            assert!(
316                err.to_string().contains(&format!("'{store}'")),
317                "the refusal must quote the offending value: {err}"
318            );
319        }
320    }
321
322    /// Resolution creates nothing. `--explain-scope` prints host paths and must not be a command
323    /// that leaves a directory tree behind.
324    #[test]
325    fn resolving_a_path_creates_nothing() {
326        let home = tempfile::tempdir().unwrap();
327        let murmur_home = home.path().join(".murmur");
328
329        let path = state_store_path_in(&murmur_home, "shey").unwrap();
330
331        assert_eq!(path, murmur_home.join("state/shey"));
332        assert!(!murmur_home.exists());
333    }
334
335    #[test]
336    fn ensuring_a_store_creates_the_root_and_the_store_at_0700() {
337        use std::os::unix::fs::PermissionsExt;
338
339        let home = tempfile::tempdir().unwrap();
340        let murmur_home = home.path().join(".murmur");
341
342        let path = ensure_state_store_in(&murmur_home, "shey").unwrap();
343        let root = murmur_home.join("state");
344
345        assert_eq!(path, root.join("shey"));
346        for dir in [&root, &path] {
347            assert!(dir.is_dir(), "{} must exist", dir.display());
348            let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
349            assert_eq!(
350                mode,
351                0o700,
352                "{} must be 0700, got {mode:04o}",
353                dir.display()
354            );
355        }
356
357        // Idempotent, and state written by an earlier launch survives a later one.
358        std::fs::write(path.join("notes.jsonl"), b"one\n").unwrap();
359        assert_eq!(ensure_state_store_in(&murmur_home, "shey").unwrap(), path);
360        assert_eq!(
361            std::fs::read_to_string(path.join("notes.jsonl")).unwrap(),
362            "one\n"
363        );
364    }
365
366    /// A store directory a previous run (or a restore) left group-readable is brought back to
367    /// `0700` rather than used as found.
368    #[test]
369    fn ensuring_a_store_reasserts_the_mode_on_an_existing_directory() {
370        use std::os::unix::fs::PermissionsExt;
371
372        let home = tempfile::tempdir().unwrap();
373        let murmur_home = home.path().join(".murmur");
374        let path = murmur_home.join("state/shey");
375        std::fs::create_dir_all(&path).unwrap();
376        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
377
378        ensure_state_store_in(&murmur_home, "shey").unwrap();
379
380        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
381        assert_eq!(mode, 0o700);
382    }
383
384    #[test]
385    fn ensuring_a_store_holds_a_wide_murmur_home_owner_only() {
386        let home = tempfile::tempdir().unwrap();
387        crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
388        crate::murmur_home::run_with_home(
389            "state_store::tests::inner_ensure_state_store_under_scratch_home",
390            home.path(),
391        );
392        assert_eq!(
393            crate::murmur_home::mode_of(&home.path().join(".murmur")),
394            0o700
395        );
396        assert_eq!(
397            crate::murmur_home::mode_of(&home.path().join(".murmur/state/shey")),
398            0o700
399        );
400    }
401
402    #[test]
403    #[ignore = "run by ensuring_a_store_holds_a_wide_murmur_home_owner_only"]
404    fn inner_ensure_state_store_under_scratch_home() {
405        if !crate::murmur_home::in_scratch_home() {
406            return;
407        }
408        ensure_state_store("shey").unwrap();
409    }
410
411    /// Two capsules, one host: each name resolves to its own directory, and neither can read the
412    /// other's. The isolation is the naming, so it is asserted at the naming layer too.
413    #[test]
414    fn two_store_names_never_resolve_into_one_directory() {
415        let home = tempfile::tempdir().unwrap();
416        let murmur_home = home.path().join(".murmur");
417
418        let first = ensure_state_store_in(&murmur_home, "capsule-one").unwrap();
419        let second = ensure_state_store_in(&murmur_home, "capsule-two").unwrap();
420
421        assert_ne!(first, second);
422        std::fs::write(first.join("notes.jsonl"), b"one\n").unwrap();
423        assert!(!second.join("notes.jsonl").exists());
424    }
425
426    #[test]
427    fn an_unresolvable_home_names_the_store_that_needed_it() {
428        let err = RuntimeError::StateStoreUnavailable {
429            store: "shey".to_string(),
430            path: "~/.murmur".to_string(),
431            message: "the home directory could not be resolved: HOME is not set in the environment"
432                .to_string(),
433        };
434        let message = err.to_string();
435        assert!(message.contains("shey"), "must name the store: {message}");
436        assert!(
437            message.contains("home directory could not be resolved"),
438            "must say what could not be resolved: {message}"
439        );
440    }
441
442    /// The name check runs before the host is consulted, so a malformed declaration is refused as
443    /// `InvalidStateStore` even when the home lookup would also have failed.
444    #[test]
445    fn a_malformed_name_is_refused_before_the_home_lookup() {
446        let declared = state_capabilities(Some("a/b"));
447        assert!(matches!(
448            state_store_reports([("notes-tool", Some(&declared))], "state-capsule"),
449            Err(RuntimeError::InvalidStateStore { .. })
450        ));
451    }
452
453    #[test]
454    fn reports_default_the_store_name_to_the_capsule_name() {
455        let declared = state_capabilities(None);
456        let named = state_capabilities(Some("shey"));
457
458        let reports = state_store_reports(
459            [
460                ("notes-tool", Some(&declared)),
461                ("corpus-tool", Some(&named)),
462                ("plain-tool", None),
463            ],
464            "state-capsule",
465        )
466        .unwrap();
467
468        assert_eq!(reports.len(), 2, "an undeclared artifact reports nothing");
469        assert_eq!(reports[0].artifact, "notes-tool");
470        assert_eq!(reports[0].store, "state-capsule");
471        assert!(reports[0]
472            .host_path
473            .ends_with(".murmur/state/state-capsule"));
474        assert_eq!(reports[1].artifact, "corpus-tool");
475        assert_eq!(reports[1].store, "shey");
476        assert!(reports[1].host_path.ends_with(".murmur/state/shey"));
477    }
478
479    /// No declaration anywhere means no home lookup, so the report is empty rather than a refusal
480    /// on a host that cannot resolve one. This is the half that proves an absent-`HOME` failure
481    /// comes from the declaration and not from the variable.
482    #[test]
483    fn an_undeclared_artifact_set_reports_nothing_and_resolves_no_home() {
484        let plain = Capabilities {
485            state: None,
486            ..state_capabilities(None)
487        };
488        assert!(state_store_reports(
489            [("plain-tool", Some(&plain)), ("bare-tool", None)],
490            "capsule"
491        )
492        .unwrap()
493        .is_empty());
494    }
495}