Skip to main content

capsule_runtime/
guest_roots.rs

1//! The host folders a WASM tool is handed beyond the work folder: its *extra roots*.
2//!
3//! A WASM tool reaches the host only through the directories preopened into its WASI context.
4//! Every session hands an unscoped `runtime: tool` the work folder at `.`, `murmur-runtime/`
5//! read-only and `state/` when its entry grants a store. A session the operator gave more of the
6//! host hands it more:
7//!
8//! | Session | Extra roots |
9//! |---|---|
10//! | Unconfined ([`ContainmentClass::Unconfined`]) | The host's `/`, read-write, at `murmur-host` |
11//! | Contained, with `host_paths` | Each entry at `murmur-host` + its canonical path, with its access |
12//! | Contained, no `host_paths` | None |
13//!
14//! Every root sits under the one reserved guest folder [`HOST_ROOT_GUEST_PATH`] rather than at its
15//! own absolute path. wasi-libc strips a leading `/` from both preopen names and paths and resolves
16//! a relative path as if it were absolute, so a `/` preopen would collide with `.` and catch every
17//! relative path of every tool, and a root at `/data` would catch a relative `data/out.csv`. Under
18//! one reserved name, a relative path still resolves in the work folder, and only a work-folder
19//! folder named `murmur-host` is shadowed for a tool holding a root.
20//!
21//! A guest learns which roots it holds from `wasi:filesystem/preopens.get-directories`, whose names
22//! are the guest paths built here.
23//!
24//! Who gets roots is [`crate::runtime_dir::gets_runtime_dir_preopen`]'s set: a tool narrowed to a
25//! `capabilities.filesystem.scope` keeps exactly its subtree, and drivers, hooks, the capsule's own
26//! store and a script capsule's component get none.
27
28use std::path::{Component, Path, PathBuf};
29
30use murmur_artifact::ContainmentClass;
31use serde::Serialize;
32
33use crate::host_paths::{HostPath, HostPathAccess};
34use crate::types::CapabilityPolicy;
35
36/// The guest folder every extra root sits under. A root at host path `P` is at this name followed
37/// by `P`; the host's `/` is this name itself.
38pub const HOST_ROOT_GUEST_PATH: &str = "murmur-host";
39
40/// Why a session hands its WASM tools a root, as a stable wire name.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
42#[serde(rename_all = "snake_case")]
43pub enum GuestRootSource {
44    /// The session is unconfined: the root is the host's `/`.
45    Unconfined,
46    /// A `host_paths` entry of `~/.murmur/config.yaml`.
47    HostPaths,
48}
49
50/// One host directory preopened into a WASM tool beyond its usual folders.
51#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct GuestRoot {
53    /// The preopen's name, which is the path the guest opens it at: [`guest_path_for`] of
54    /// [`Self::host_path`].
55    pub guest_path: String,
56    /// The canonical host directory preopened.
57    pub host_path: PathBuf,
58    /// `read` preopens it read-only; `read-write` read and write. Never executable: a WASM guest
59    /// cannot exec.
60    pub access: HostPathAccess,
61    pub source: GuestRootSource,
62}
63
64/// The extra roots of every tool [`crate::runtime_dir::gets_runtime_dir_preopen`] admits, in
65/// preopen order, for a session run under `policy`.
66#[must_use]
67pub fn extra_guest_roots(policy: &CapabilityPolicy) -> Vec<GuestRoot> {
68    extra_guest_roots_for(policy.containment_floor, &policy.host_paths)
69}
70
71/// [`extra_guest_roots`] from its two inputs, for a report that holds them without a policy.
72///
73/// Unconfined gives the host's `/` alone: a host path nested under it would add nothing, and a
74/// `read` one would narrow the tool, since the longest matching preopen wins. Otherwise each host
75/// path gives one root, in config order, on every contained tier: a WASM tool runs inside `mur`,
76/// which no tier confines, so its preopens are its only fence on every host.
77#[must_use]
78pub fn extra_guest_roots_for(
79    containment_floor: ContainmentClass,
80    host_paths: &[HostPath],
81) -> Vec<GuestRoot> {
82    if containment_floor == ContainmentClass::Unconfined {
83        let root = Path::new("/");
84        return vec![GuestRoot {
85            guest_path: guest_path_for(root),
86            host_path: root.to_path_buf(),
87            access: HostPathAccess::ReadWrite,
88            source: GuestRootSource::Unconfined,
89        }];
90    }
91    host_paths
92        .iter()
93        .map(|host_path| GuestRoot {
94            guest_path: guest_path_for(&host_path.path),
95            host_path: host_path.path.clone(),
96            access: host_path.access,
97            source: GuestRootSource::HostPaths,
98        })
99        .collect()
100}
101
102/// The extra roots of tool `runtime`'s guest under `policy`: [`extra_guest_roots`] when
103/// [`crate::runtime_dir::gets_runtime_dir_preopen`] holds for it, none otherwise. The one gate the
104/// tool dispatch and every report share.
105#[must_use]
106pub(crate) fn tool_extra_roots(
107    runtime: &murmur_artifact::ArtifactRuntime,
108    declares_filesystem_scope: bool,
109    policy: &CapabilityPolicy,
110) -> Vec<GuestRoot> {
111    if crate::runtime_dir::gets_runtime_dir_preopen(runtime, declares_filesystem_scope) {
112        extra_guest_roots(policy)
113    } else {
114        Vec::new()
115    }
116}
117
118/// One directory a guest's WASI context preopens.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub(crate) struct PlannedPreopen {
121    pub(crate) host_path: PathBuf,
122    /// The preopen's name, which `wasi:filesystem/preopens.get-directories` hands the guest.
123    pub(crate) guest_path: String,
124    pub(crate) read_only: bool,
125}
126
127/// Every directory a guest's WASI context preopens, in registration order: the work folder (or the
128/// entry's scope) at `.`, then [`crate::state_store::STATE_PREOPEN_NAME`] when a store is granted,
129/// then [`crate::runtime_dir::RUNTIME_DIR_GUEST_PATH`] read-only, then each extra root.
130///
131/// Extra roots come last so the first three keep their positions; nested roots resolve by longest
132/// prefix in the guest whatever their order.
133pub(crate) fn planned_preopens(
134    preopen_root: &Path,
135    state_dir: Option<&Path>,
136    runtime_dir: Option<&Path>,
137    extra_roots: &[GuestRoot],
138) -> Vec<PlannedPreopen> {
139    let mut planned = vec![PlannedPreopen {
140        host_path: preopen_root.to_path_buf(),
141        guest_path: ".".to_string(),
142        read_only: false,
143    }];
144    if let Some(state_dir) = state_dir {
145        planned.push(PlannedPreopen {
146            host_path: state_dir.to_path_buf(),
147            guest_path: crate::state_store::STATE_PREOPEN_NAME.to_string(),
148            read_only: false,
149        });
150    }
151    // Read-only: the guest reads `MURMUR.md`, `task.md`, plans and installed tools there and
152    // changes none of them.
153    if let Some(runtime_dir) = runtime_dir {
154        planned.push(PlannedPreopen {
155            host_path: runtime_dir.to_path_buf(),
156            guest_path: crate::runtime_dir::RUNTIME_DIR_GUEST_PATH.to_string(),
157            read_only: true,
158        });
159    }
160    planned.extend(extra_roots.iter().map(|root| PlannedPreopen {
161        host_path: root.host_path.clone(),
162        guest_path: root.guest_path.clone(),
163        read_only: root.access == HostPathAccess::Read,
164    }));
165    planned
166}
167
168/// The guest path of a root at absolute host path `host_path`: [`HOST_ROOT_GUEST_PATH`] joined
169/// with it, `/`-separated, no trailing `/`. `/` is `murmur-host`; `/home/op/.cargo` is
170/// `murmur-host/home/op/.cargo`.
171#[must_use]
172pub fn guest_path_for(host_path: &Path) -> String {
173    let mut guest = HOST_ROOT_GUEST_PATH.to_string();
174    for component in host_path.components() {
175        if let Component::Normal(name) = component {
176            guest.push('/');
177            guest.push_str(&name.to_string_lossy());
178        }
179    }
180    guest
181}
182
183/// The host path a guest path names through `roots`, or `None` when it names none.
184///
185/// Read the way wasi-libc reads it: leading `/` and `.` components dropped, `.` and `..` folded,
186/// then the root with the longest component-wise prefix wins. A path whose first folded component
187/// is not [`HOST_ROOT_GUEST_PATH`], one that `..` takes above its start, and one no root covers
188/// all give `None` — the guest's libc resolves each of those into the work folder or nowhere.
189#[must_use]
190pub fn map_guest_path(roots: &[GuestRoot], guest: &str) -> Option<PathBuf> {
191    let components = fold_guest_path(guest)?;
192    if components.first().map(String::as_str) != Some(HOST_ROOT_GUEST_PATH) {
193        return None;
194    }
195    let (root, depth) = roots
196        .iter()
197        .filter_map(|root| {
198            let prefix = fold_guest_path(&root.guest_path)?;
199            components
200                .starts_with(&prefix)
201                .then_some((root, prefix.len()))
202        })
203        .max_by_key(|(_, depth)| *depth)?;
204    let mut host = root.host_path.clone();
205    for name in &components[depth..] {
206        host.push(name);
207    }
208    Some(host)
209}
210
211/// `guest` as a list of names: leading `/` dropped, `.` skipped, `..` popping the previous name.
212/// `None` when `..` pops past the start.
213fn fold_guest_path(guest: &str) -> Option<Vec<String>> {
214    let mut names: Vec<String> = Vec::new();
215    for component in Path::new(guest).components() {
216        match component {
217            Component::RootDir | Component::CurDir | Component::Prefix(_) => {}
218            Component::ParentDir => {
219                names.pop()?;
220            }
221            Component::Normal(name) => names.push(name.to_string_lossy().into_owned()),
222        }
223    }
224    Some(names)
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    fn host_path(path: &str, access: HostPathAccess) -> HostPath {
232        HostPath {
233            declared: path.to_string(),
234            path: PathBuf::from(path),
235            access,
236            credential_locations: Vec::new(),
237        }
238    }
239
240    fn policy(floor: ContainmentClass, host_paths: Vec<HostPath>) -> CapabilityPolicy {
241        CapabilityPolicy {
242            containment_floor: floor,
243            host_paths,
244            ..CapabilityPolicy::default()
245        }
246    }
247
248    fn summary(roots: &[GuestRoot]) -> Vec<(String, String, &'static str, String)> {
249        roots
250            .iter()
251            .map(|root| {
252                (
253                    root.guest_path.clone(),
254                    root.host_path.display().to_string(),
255                    root.access.wire_name(),
256                    serde_json::to_value(root.source)
257                        .unwrap()
258                        .as_str()
259                        .unwrap()
260                        .to_string(),
261                )
262            })
263            .collect()
264    }
265
266    /// `(guest path, read-only)` of each planned preopen of a tool with runtime `runtime`.
267    fn tool_plan(
268        runtime: &murmur_artifact::ArtifactRuntime,
269        scoped: bool,
270        state: bool,
271        policy: &CapabilityPolicy,
272    ) -> Vec<(String, bool)> {
273        let roots = tool_extra_roots(runtime, scoped, policy);
274        let runtime_dir = crate::runtime_dir::gets_runtime_dir_preopen(runtime, scoped)
275            .then_some(Path::new("/runtime"));
276        planned_preopens(
277            Path::new("/work"),
278            state.then_some(Path::new("/state")),
279            runtime_dir,
280            &roots,
281        )
282        .into_iter()
283        .map(|preopen| (preopen.guest_path, preopen.read_only))
284        .collect()
285    }
286
287    fn owned(list: &[(&str, bool)]) -> Vec<(String, bool)> {
288        list.iter()
289            .map(|(guest, read_only)| (guest.to_string(), *read_only))
290            .collect()
291    }
292
293    #[test]
294    fn guest_roots_contained_plan_is_todays_preopens() {
295        use murmur_artifact::ArtifactRuntime;
296        let contained = policy(ContainmentClass::Scoped, Vec::new());
297        assert_eq!(
298            tool_plan(&ArtifactRuntime::Tool, false, false, &contained),
299            owned(&[(".", false), ("murmur-runtime", true)])
300        );
301        assert_eq!(
302            tool_plan(&ArtifactRuntime::Tool, false, true, &contained),
303            owned(&[(".", false), ("state", false), ("murmur-runtime", true)])
304        );
305    }
306
307    #[test]
308    fn guest_roots_unconfined_plan_adds_the_host_root_last() {
309        use murmur_artifact::ArtifactRuntime;
310        for host_paths in [Vec::new(), vec![host_path("/a", HostPathAccess::Read)]] {
311            let unconfined = policy(ContainmentClass::Unconfined, host_paths);
312            assert_eq!(
313                tool_plan(&ArtifactRuntime::Tool, false, true, &unconfined),
314                owned(&[
315                    (".", false),
316                    ("state", false),
317                    ("murmur-runtime", true),
318                    ("murmur-host", false),
319                ])
320            );
321        }
322    }
323
324    #[test]
325    fn guest_roots_host_paths_plan_follows_the_existing_three() {
326        use murmur_artifact::ArtifactRuntime;
327        let contained = policy(
328            ContainmentClass::Advisory,
329            vec![
330                host_path("/a", HostPathAccess::Read),
331                host_path("/a/b", HostPathAccess::ReadWrite),
332            ],
333        );
334        assert_eq!(
335            tool_plan(&ArtifactRuntime::Tool, false, true, &contained),
336            owned(&[
337                (".", false),
338                ("state", false),
339                ("murmur-runtime", true),
340                ("murmur-host/a", true),
341                ("murmur-host/a/b", false),
342            ])
343        );
344    }
345
346    #[test]
347    fn guest_roots_never_reach_a_scoped_tool_a_driver_or_a_hook() {
348        use murmur_artifact::ArtifactRuntime;
349        let policies = [
350            policy(ContainmentClass::Unconfined, Vec::new()),
351            policy(
352                ContainmentClass::Advisory,
353                vec![host_path("/a", HostPathAccess::ReadWrite)],
354            ),
355        ];
356        for policy in &policies {
357            assert!(tool_extra_roots(&ArtifactRuntime::Tool, true, policy).is_empty());
358            assert_eq!(
359                tool_plan(&ArtifactRuntime::Tool, true, false, policy),
360                owned(&[(".", false)])
361            );
362            for runtime in [
363                ArtifactRuntime::Driver,
364                ArtifactRuntime::Hook,
365                ArtifactRuntime::Skill,
366            ] {
367                for scoped in [false, true] {
368                    assert!(tool_extra_roots(&runtime, scoped, policy).is_empty());
369                }
370            }
371            // The capsule's own store and a script capsule's component are built with no roots.
372            assert_eq!(
373                planned_preopens(Path::new("/work"), None, None, &[]),
374                vec![PlannedPreopen {
375                    host_path: PathBuf::from("/work"),
376                    guest_path: ".".to_string(),
377                    read_only: false,
378                }]
379            );
380        }
381    }
382
383    #[test]
384    fn guest_roots_guest_path_is_murmur_host_and_the_absolute_path() {
385        assert_eq!(guest_path_for(Path::new("/")), "murmur-host");
386        assert_eq!(
387            guest_path_for(Path::new("/home/op/.cargo")),
388            "murmur-host/home/op/.cargo"
389        );
390        assert_eq!(guest_path_for(Path::new("/data/")), "murmur-host/data");
391    }
392
393    #[test]
394    fn guest_roots_contained_without_host_paths_has_none() {
395        for floor in [
396            ContainmentClass::Advisory,
397            ContainmentClass::Scoped,
398            ContainmentClass::Sealed,
399        ] {
400            assert!(extra_guest_roots(&policy(floor, Vec::new())).is_empty());
401        }
402    }
403
404    #[test]
405    fn guest_roots_unconfined_is_the_whole_host_read_write_whatever_host_paths_say() {
406        let expected = vec![(
407            "murmur-host".to_string(),
408            "/".to_string(),
409            "read-write",
410            "unconfined".to_string(),
411        )];
412        let plain = extra_guest_roots(&policy(ContainmentClass::Unconfined, Vec::new()));
413        assert_eq!(summary(&plain), expected);
414        let with_host_paths = extra_guest_roots(&policy(
415            ContainmentClass::Unconfined,
416            vec![host_path("/a", HostPathAccess::Read)],
417        ));
418        assert_eq!(summary(&with_host_paths), expected);
419    }
420
421    #[test]
422    fn guest_roots_host_paths_follow_config_order_with_their_access() {
423        for floor in [
424            ContainmentClass::Advisory,
425            ContainmentClass::Scoped,
426            ContainmentClass::Sealed,
427        ] {
428            let roots = extra_guest_roots(&policy(
429                floor,
430                vec![
431                    host_path("/a", HostPathAccess::Read),
432                    host_path("/a/b", HostPathAccess::ReadWrite),
433                ],
434            ));
435            assert_eq!(
436                summary(&roots),
437                vec![
438                    (
439                        "murmur-host/a".to_string(),
440                        "/a".to_string(),
441                        "read",
442                        "host_paths".to_string()
443                    ),
444                    (
445                        "murmur-host/a/b".to_string(),
446                        "/a/b".to_string(),
447                        "read-write",
448                        "host_paths".to_string()
449                    ),
450                ]
451            );
452        }
453    }
454
455    #[test]
456    fn guest_roots_map_by_longest_prefix() {
457        let roots = extra_guest_roots(&policy(
458            ContainmentClass::Advisory,
459            vec![
460                host_path("/a/b", HostPathAccess::ReadWrite),
461                host_path("/a", HostPathAccess::Read),
462            ],
463        ));
464        assert_eq!(
465            map_guest_path(&roots, "murmur-host/a/b/c"),
466            Some(PathBuf::from("/a/b/c"))
467        );
468        assert_eq!(
469            map_guest_path(&roots, "murmur-host/a/bc"),
470            Some(PathBuf::from("/a/bc"))
471        );
472        assert_eq!(
473            map_guest_path(&roots, "/murmur-host/a/x"),
474            Some(PathBuf::from("/a/x"))
475        );
476        assert_eq!(
477            map_guest_path(&roots, "./murmur-host/a/./x/../y"),
478            Some(PathBuf::from("/a/y"))
479        );
480        assert_eq!(map_guest_path(&roots, "murmur-host/other/x"), None);
481        assert_eq!(map_guest_path(&roots, "murmur-host"), None);
482        assert_eq!(map_guest_path(&roots, "src/murmur-host/a"), None);
483        assert_eq!(map_guest_path(&roots, "murmur-hostile/a"), None);
484        assert_eq!(map_guest_path(&roots, "../murmur-host/a"), None);
485        assert_eq!(map_guest_path(&[], "murmur-host/a/x"), None);
486    }
487
488    #[test]
489    fn guest_roots_whole_host_maps_every_murmur_host_path() {
490        let roots = extra_guest_roots(&policy(ContainmentClass::Unconfined, Vec::new()));
491        assert_eq!(
492            map_guest_path(&roots, "murmur-host/home/op/notes"),
493            Some(PathBuf::from("/home/op/notes"))
494        );
495        assert_eq!(
496            map_guest_path(&roots, "murmur-host"),
497            Some(PathBuf::from("/"))
498        );
499        assert_eq!(map_guest_path(&roots, "notes"), None);
500    }
501}