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}