Skip to main content

capsule_runtime/
declared_needs.rs

1//! What the host programs a capsule's tools run need from the host, as each tool's own
2//! `murmur.yaml` declares it ([`murmur_artifact::parse_program_needs`]), resolved against this
3//! host at staging.
4//!
5//! A tool declares needs per program, by soname, by file name beside a declared library, relative
6//! to the program's own folder, by a named system location, or by a name under `/etc`, and never
7//! by host path. A need reaches a session only when the capsule makes both declarations: its
8//! `artifacts:` pins the tool with an `operator` lock origin (or there is no lock), and its
9//! `capabilities.shell.allow` names the program verbatim. A runtime pin and an artifact added
10//! mid-session contribute nothing: the enforcement is resolved once, at launch.
11//!
12//! | Form | Resolves to | Grant |
13//! |---|---|---|
14//! | `library: S` | `S` as the loader resolves it for each binary the program resolves to on `PATH`, then that library's `DT_NEEDED` closure | each file read |
15//! | `file: F, beside: S` | `F` in the folder `S` resolved to, a regular file | read |
16//! | `program_folder: R` | `R` under the folder of each binary the program resolves to | read, and list for a folder |
17//! | `system: fonts` | each existing [`FONT_LOCATIONS`] entry | read and list |
18//! | `system: ca_bundle` | the trust-store locations [`crate::trust_store`] checks, with no network gate | as the trust store |
19//! | `etc: N` | `/etc/N` | read, and list for a folder |
20//!
21//! Every path is canonical, and no grant is executable ([`crate::sandbox::resolve_declared_need_grants`]).
22//! A resolved path that is `/`, or is, lies beneath or holds `/proc`, `/sys`, `/dev`, the home
23//! folder, a tree the capsule's commands can write or an `/etc` credential location
24//! ([`murmur_artifact::ETC_CREDENTIAL_LOCATIONS`]) is reported missing instead. A missing need
25//! never refuses a launch.
26//!
27//! The grants join the session's one command scope, as the `DT_NEEDED` closure of a granted
28//! program does: every command of the session, and every program a WASM tool runs, can read them.
29//! "For this program" is the gate and the attribution, not a per-executable rule.
30//!
31//! Off Linux nothing is derived: commands' file reads are not path-scoped there.
32
33#[cfg(target_os = "linux")]
34use std::collections::BTreeSet;
35use std::path::Path;
36#[cfg(target_os = "linux")]
37use std::path::PathBuf;
38
39use murmur_artifact::{DeclaredNeed, LockOrigin, ProgramNeeds};
40#[cfg(target_os = "linux")]
41use murmur_artifact::{NeedForm, SystemLocation};
42
43/// The `missing` reason of a need that resolves to nothing on this host.
44pub const NOT_FOUND: &str = "not found on this host";
45
46/// The `missing` reason of a need relative to a program `PATH` resolves to no binary for.
47pub const PROGRAM_NOT_ON_PATH: &str = "program not found on PATH";
48
49/// The host locations `system: fonts` resolves to, each one this host has.
50pub const FONT_LOCATIONS: &[&str] = &[
51    "/etc/fonts",
52    "/usr/share/fonts",
53    "/usr/local/share/fonts",
54    "/usr/share/fontconfig",
55    "/var/cache/fontconfig",
56];
57
58/// The places os-release is read from, in order; the first that reads wins.
59const OS_RELEASE_PATHS: &[&str] = &["/etc/os-release", "/usr/lib/os-release"];
60
61/// One staged tool's `programs:` block, with the lock origin of its pin.
62#[derive(Debug, Clone, PartialEq, Eq)]
63pub struct DeclaredToolNeeds {
64    /// The artifact as the capsule's manifest names it, scope included.
65    pub artifact: String,
66    pub origin: LockOrigin,
67    pub programs: Vec<ProgramNeeds>,
68}
69
70/// What one artifact's needs for one program resolved to on this host.
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct ResolvedProgramNeeds {
73    pub artifact: String,
74    pub program: String,
75    /// Each need with at least one grantable path, in declaration order.
76    pub granted: Vec<ResolvedNeed>,
77    /// Each need with a path that was not granted, or with none at all, in declaration order. A
78    /// need some of whose paths were granted and some moved here is in both lists.
79    pub missing: Vec<MissingNeed>,
80}
81
82/// A need and the host paths it is granted.
83#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct ResolvedNeed {
85    pub need: DeclaredNeed,
86    /// Canonical, sorted and distinct.
87    pub paths: Vec<NeedPath>,
88}
89
90/// One granted path: canonical, listable when a directory. The trust store's path type, since a
91/// `system: ca_bundle` need resolves to exactly the trust store's paths.
92pub type NeedPath = crate::trust_store::TrustStorePath;
93
94/// A need, or part of one, that is not granted, and why.
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct MissingNeed {
97    pub need: DeclaredNeed,
98    /// [`NOT_FOUND`], [`PROGRAM_NOT_ON_PATH`], or the location a resolved path may not reach.
99    pub reason: String,
100}
101
102/// The needs `tools` declare for the programs `shell_allow` grants, resolved against this host.
103///
104/// Only a tool whose `origin` is [`LockOrigin::Operator`] contributes, and only for a program key
105/// equal to an entry of `shell_allow`. One element per (artifact, program) with at least one
106/// need, tools in the order given and programs in declaration order. `capsule_writable` are the
107/// trees the capsule's commands can write ([`crate::interpreter_runtime::capsule_writable_roots`]).
108/// `pinned` is staging's source choice for pinned program downloads
109/// ([`crate::pinned_programs::derive_pinned_programs`]): a program is looked up on `PATH` and then
110/// in each pinned copy's `bin/` folder, and a need resolving at or under a pinned copy's store
111/// entry is granted, though that entry lies under the home folder.
112///
113/// Reads the filesystem only, runs nothing, and fails nothing. Off Linux this returns nothing.
114pub fn derive_declared_needs(
115    tools: &[DeclaredToolNeeds],
116    shell_allow: &[String],
117    capsule_writable: &[&Path],
118    pinned: &[crate::pinned_programs::ResolvedPinnedProgram],
119) -> Vec<ResolvedProgramNeeds> {
120    #[cfg(target_os = "linux")]
121    {
122        let mut path_dirs = crate::pinned_programs::host_path_dirs();
123        path_dirs.extend(crate::pinned_programs::pinned_bin_dirs(pinned));
124        let host = NeedHost {
125            path_dirs,
126            search_dirs: crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
127                .iter()
128                .map(PathBuf::from)
129                .collect(),
130            font_locations: FONT_LOCATIONS.iter().map(PathBuf::from).collect(),
131            ca_candidates: crate::trust_store::TRUST_STORE_CANDIDATES,
132            etc_root: PathBuf::from("/etc"),
133            home: std::env::var_os("HOME")
134                .filter(|home| !home.is_empty())
135                .map(PathBuf::from),
136            capsule_writable: capsule_writable
137                .iter()
138                .map(|root| root.to_path_buf())
139                .collect(),
140            pinned_trees: crate::pinned_programs::pinned_trees(pinned),
141        };
142        derive_in(tools, shell_allow, &host)
143    }
144    #[cfg(not(target_os = "linux"))]
145    {
146        let _ = (tools, shell_allow, capsule_writable, pinned);
147        Vec::new()
148    }
149}
150
151/// Everything [`derive_in`] reads from the host, injected.
152#[cfg(target_os = "linux")]
153pub(crate) struct NeedHost<'a> {
154    /// `PATH`, split, then each pinned program copy's `bin/` folder.
155    pub(crate) path_dirs: Vec<PathBuf>,
156    /// The soname fallback directories ([`crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS`]).
157    pub(crate) search_dirs: Vec<PathBuf>,
158    /// [`FONT_LOCATIONS`].
159    pub(crate) font_locations: Vec<PathBuf>,
160    /// [`crate::trust_store::TRUST_STORE_CANDIDATES`].
161    pub(crate) ca_candidates: &'a [crate::trust_store::TrustStoreCandidate<'a>],
162    /// `/etc`: where an `etc:` need and the credential table are rooted.
163    pub(crate) etc_root: PathBuf,
164    pub(crate) home: Option<PathBuf>,
165    pub(crate) capsule_writable: Vec<PathBuf>,
166    /// The store entry of each pinned program copy: a need at or under one is granted even where
167    /// the home-folder rule would move it to `missing`.
168    pub(crate) pinned_trees: Vec<PathBuf>,
169}
170
171/// [`derive_declared_needs`] with the host injected.
172#[cfg(target_os = "linux")]
173pub(crate) fn derive_in(
174    tools: &[DeclaredToolNeeds],
175    shell_allow: &[String],
176    host: &NeedHost<'_>,
177) -> Vec<ResolvedProgramNeeds> {
178    let unreachable = Unreachable::of(host);
179    let mut resolved = Vec::new();
180    for tool in tools {
181        if tool.origin != LockOrigin::Operator {
182            continue;
183        }
184        for program in &tool.programs {
185            if program.needs.is_empty() || !shell_allow.contains(&program.program) {
186                continue;
187            }
188            resolved.push(resolve_program(&tool.artifact, program, host, &unreachable));
189        }
190    }
191    resolved
192}
193
194#[cfg(target_os = "linux")]
195fn resolve_program(
196    artifact: &str,
197    program: &ProgramNeeds,
198    host: &NeedHost<'_>,
199    unreachable: &Unreachable,
200) -> ResolvedProgramNeeds {
201    let binaries = crate::sandbox::resolve_exec_allowlist_in(
202        std::slice::from_ref(&program.program),
203        &host.path_dirs,
204    );
205    let mut granted = Vec::new();
206    let mut missing = Vec::new();
207    for need in &program.needs {
208        let found = match &need.form {
209            NeedForm::Library(soname) => {
210                let direct = resolve_library(soname, &binaries, host);
211                let seeds: Vec<PathBuf> = direct
212                    .iter()
213                    .filter(|path| unreachable.reason(path).is_none())
214                    .cloned()
215                    .collect();
216                let Ok(closure) = crate::sandbox::walk_dependency_closure(
217                    &seeds,
218                    &host.search_dirs,
219                    &unreachable.walk_excluded,
220                    |path| -> Result<_, std::convert::Infallible> {
221                        Ok(crate::sandbox::read_elf_dependencies(path))
222                    },
223                );
224                // The libraries `unreachable` refused as seeds, so they are reported below.
225                let refused = direct.into_iter().filter(|path| !seeds.contains(path));
226                Ok(closure
227                    .into_iter()
228                    .chain(refused)
229                    .map(|path| NeedPath {
230                        path,
231                        directory: false,
232                    })
233                    .collect::<Vec<_>>())
234            }
235            NeedForm::File { file, beside } => Ok(resolve_library(beside, &binaries, host)
236                .iter()
237                .filter_map(|library| library.parent())
238                .filter_map(|dir| existing(&dir.join(file)))
239                .filter(|path| !path.directory)
240                .collect()),
241            NeedForm::ProgramFolder(relative) => {
242                if binaries.is_empty() {
243                    Err(PROGRAM_NOT_ON_PATH.to_string())
244                } else {
245                    Ok(binaries
246                        .iter()
247                        .filter_map(|binary| binary.parent())
248                        .filter_map(|dir| existing(&dir.join(relative)))
249                        .collect())
250                }
251            }
252            NeedForm::System(SystemLocation::Fonts) => Ok(host
253                .font_locations
254                .iter()
255                .filter_map(|location| existing(location))
256                .collect()),
257            NeedForm::System(SystemLocation::CaBundle) => {
258                let writable: Vec<&Path> =
259                    host.capsule_writable.iter().map(PathBuf::as_path).collect();
260                Ok(crate::trust_store::select_in(host.ca_candidates, &writable))
261            }
262            NeedForm::Etc(name) => Ok(existing(&host.etc_root.join(name)).into_iter().collect()),
263        };
264        let paths = match found {
265            Ok(paths) if !paths.is_empty() => paths,
266            Ok(_) => {
267                missing.push(MissingNeed {
268                    need: need.clone(),
269                    reason: NOT_FOUND.to_string(),
270                });
271                continue;
272            }
273            Err(reason) => {
274                missing.push(MissingNeed {
275                    need: need.clone(),
276                    reason,
277                });
278                continue;
279            }
280        };
281        let mut kept: BTreeSet<NeedPath> = BTreeSet::new();
282        let mut refusal: Option<String> = None;
283        for path in paths {
284            match unreachable.reason(&path.path) {
285                None => {
286                    kept.insert(path);
287                }
288                Some(reason) => {
289                    refusal.get_or_insert(reason);
290                }
291            }
292        }
293        if !kept.is_empty() {
294            granted.push(ResolvedNeed {
295                need: need.clone(),
296                paths: kept.into_iter().collect(),
297            });
298        }
299        if let Some(reason) = refusal {
300            missing.push(MissingNeed {
301                need: need.clone(),
302                reason,
303            });
304        }
305    }
306    ResolvedProgramNeeds {
307        artifact: artifact.to_string(),
308        program: program.program.clone(),
309        granted,
310        missing,
311    }
312}
313
314/// Where `soname` resolves for the program: for each of its `binaries`, from that binary's folder
315/// and `DT_RUNPATH`, then the search directories; from the search directories alone when it has
316/// none. Canonical and distinct.
317#[cfg(target_os = "linux")]
318fn resolve_library(soname: &str, binaries: &[PathBuf], host: &NeedHost<'_>) -> Vec<PathBuf> {
319    let mut found = BTreeSet::new();
320    if binaries.is_empty() {
321        found.extend(crate::sandbox::resolve_soname(
322            soname,
323            Path::new("/"),
324            &[],
325            &host.search_dirs,
326        ));
327    }
328    for binary in binaries {
329        let runpaths = crate::sandbox::read_elf_dependencies(binary)
330            .map(|deps| deps.runpaths)
331            .unwrap_or_default();
332        let origin = binary.parent().unwrap_or_else(|| Path::new("/"));
333        found.extend(crate::sandbox::resolve_soname(
334            soname,
335            origin,
336            &runpaths,
337            &host.search_dirs,
338        ));
339    }
340    found.into_iter().collect()
341}
342
343/// `path` canonicalized, when it is a directory or a regular file.
344#[cfg(target_os = "linux")]
345fn existing(path: &Path) -> Option<NeedPath> {
346    let canonical = std::fs::canonicalize(path).ok()?;
347    let metadata = std::fs::metadata(&canonical).ok()?;
348    (metadata.is_dir() || metadata.is_file()).then(|| NeedPath {
349        path: canonical,
350        directory: metadata.is_dir(),
351    })
352}
353
354/// The locations no need may reach, each with how a reason names it.
355#[cfg(target_os = "linux")]
356struct Unreachable {
357    /// Canonical; a path equal to, beneath or holding one is refused.
358    roots: Vec<PathBuf>,
359    labels: Vec<String>,
360    /// Pinned program store entries: a path at or under one is never refused.
361    pinned: Vec<PathBuf>,
362    /// `roots` minus those holding a pinned entry: where a library closure walk stops.
363    walk_excluded: Vec<PathBuf>,
364}
365
366#[cfg(target_os = "linux")]
367impl Unreachable {
368    fn of(host: &NeedHost<'_>) -> Self {
369        let mut unreachable = Self {
370            roots: Vec::new(),
371            labels: Vec::new(),
372            pinned: host
373                .pinned_trees
374                .iter()
375                .flat_map(|tree| {
376                    [
377                        tree.clone(),
378                        crate::protected_paths::canonicalize_existing_prefix(tree),
379                    ]
380                })
381                .collect(),
382            walk_excluded: Vec::new(),
383        };
384        let mut add = |path: &Path, label: &str| {
385            for root in [
386                path.to_path_buf(),
387                crate::protected_paths::canonicalize_existing_prefix(path),
388            ] {
389                if !unreachable.roots.contains(&root) {
390                    unreachable
391                        .labels
392                        .push(format!("{label} {}", root.display()));
393                    unreachable.roots.push(root);
394                }
395            }
396        };
397        for kernel in ["/proc", "/sys", "/dev"] {
398            add(Path::new(kernel), "the kernel's");
399        }
400        if let Some(home) = &host.home {
401            add(home, "the home folder");
402        }
403        for root in &host.capsule_writable {
404            add(root, "the capsule-writable tree");
405        }
406        for location in murmur_artifact::ETC_CREDENTIAL_LOCATIONS {
407            add(&host.etc_root.join(location), "the credential location");
408        }
409        unreachable.walk_excluded = unreachable
410            .roots
411            .iter()
412            .filter(|root| !unreachable.pinned.iter().any(|tree| tree.starts_with(root)))
413            .cloned()
414            .collect();
415        unreachable
416    }
417
418    /// Why `path` may not be granted, or `None` when it may.
419    fn reason(&self, path: &Path) -> Option<String> {
420        if path == Path::new("/") {
421            return Some("resolved to /, the filesystem root".to_string());
422        }
423        if self.pinned.iter().any(|tree| path.starts_with(tree)) {
424            return None;
425        }
426        self.roots
427            .iter()
428            .zip(&self.labels)
429            .find_map(|(root, label)| {
430                let relation = if path == root {
431                    "which is"
432                } else if path.starts_with(root) {
433                    "beneath"
434                } else if root.starts_with(path) {
435                    "which holds"
436                } else {
437                    return None;
438                };
439                Some(format!(
440                    "resolved to {}, {relation} {label}, which no need reaches",
441                    path.display()
442                ))
443            })
444    }
445}
446
447/// This host's distribution IDs, most specific first: os-release's `ID`, then each word of its
448/// `ID_LIKE`, read from `/etc/os-release`, else `/usr/lib/os-release`. Empty when neither reads.
449#[must_use]
450pub fn host_distributions() -> Vec<String> {
451    OS_RELEASE_PATHS
452        .iter()
453        .find_map(|path| std::fs::read_to_string(path).ok())
454        .map(|text| distributions_from_os_release(&text))
455        .unwrap_or_default()
456}
457
458/// [`host_distributions`] from os-release text: `ID`, then each whitespace-separated word of
459/// `ID_LIKE`, unquoted, each once.
460#[must_use]
461pub fn distributions_from_os_release(text: &str) -> Vec<String> {
462    let value = |key: &str| {
463        text.lines().find_map(|line| {
464            let (name, value) = line.trim().split_once('=')?;
465            (name.trim() == key).then(|| {
466                let value = value.trim();
467                value
468                    .strip_prefix('"')
469                    .and_then(|v| v.strip_suffix('"'))
470                    .or_else(|| value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')))
471                    .unwrap_or(value)
472                    .to_string()
473            })
474        })
475    };
476    let mut distributions: Vec<String> = Vec::new();
477    let id = value("ID");
478    let like = value("ID_LIKE");
479    for word in id
480        .iter()
481        .chain(like.iter())
482        .flat_map(|value| value.split_whitespace())
483    {
484        if !distributions.iter().any(|known| known == word) {
485            distributions.push(word.to_string());
486        }
487    }
488    distributions
489}
490
491/// The package `need` declares for the first of `distributions` it names one for, with that
492/// distribution.
493#[must_use]
494pub fn package_for<'a>(
495    need: &'a DeclaredNeed,
496    distributions: &'a [String],
497) -> Option<(&'a str, &'a str)> {
498    distributions.iter().find_map(|distribution| {
499        need.packages
500            .get(distribution)
501            .map(|package| (package.as_str(), distribution.as_str()))
502    })
503}
504
505#[cfg(all(test, target_os = "linux"))]
506mod tests {
507    use super::*;
508    use murmur_artifact::{parse_program_needs, NeedForm};
509    use std::os::unix::fs::symlink;
510
511    /// A scratch host: `bin/fixprog`, `lib/` with a fixture library, fonts and an `/etc`.
512    struct Scratch {
513        _temp: tempfile::TempDir,
514        root: PathBuf,
515    }
516
517    impl Scratch {
518        fn new() -> Self {
519            let temp = tempfile::tempdir().unwrap();
520            let root = std::fs::canonicalize(temp.path()).unwrap();
521            for dir in [
522                "bin/share",
523                "lib",
524                "fonts",
525                "home/user",
526                "workdir",
527                "etc/ssl/private",
528                "etc/ssl/certs",
529                "etc/fonts",
530            ] {
531                std::fs::create_dir_all(root.join(dir)).unwrap();
532            }
533            std::fs::copy(host_binary("true"), root.join("bin/fixprog")).unwrap();
534            std::fs::copy(host_library("libm.so.6"), root.join("lib/libfix.so.1")).unwrap();
535            std::fs::write(root.join("lib/libfix.chk"), b"chk").unwrap();
536            std::fs::copy(host_library("libm.so.6"), root.join("lib/libother.so.1")).unwrap();
537            std::fs::write(root.join("etc/fonts/fonts.conf"), b"<fontconfig/>").unwrap();
538            std::fs::write(root.join("etc/ssl/certs/ca.pem"), b"pem").unwrap();
539            Self { _temp: temp, root }
540        }
541
542        fn host(&self) -> NeedHost<'static> {
543            let mut search_dirs = vec![self.root.join("lib")];
544            search_dirs.extend(
545                crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
546                    .iter()
547                    .map(PathBuf::from),
548            );
549            NeedHost {
550                path_dirs: vec![self.root.join("bin")],
551                search_dirs,
552                font_locations: vec![self.root.join("fonts"), self.root.join("no-fonts")],
553                ca_candidates: &[],
554                etc_root: self.root.join("etc"),
555                home: Some(self.root.join("home/user")),
556                capsule_writable: vec![self.root.join("workdir")],
557                pinned_trees: Vec::new(),
558            }
559        }
560    }
561
562    fn host_binary(name: &str) -> PathBuf {
563        ["/bin", "/usr/bin"]
564            .iter()
565            .map(|dir| Path::new(dir).join(name))
566            .find(|path| path.is_file())
567            .expect("the host has the binary")
568    }
569
570    fn host_library(soname: &str) -> PathBuf {
571        let dirs: Vec<PathBuf> = crate::sandbox::DEFAULT_LIBRARY_SEARCH_DIRS
572            .iter()
573            .map(PathBuf::from)
574            .collect();
575        crate::sandbox::resolve_soname(soname, Path::new("/"), &[], &dirs)
576            .expect("the host has the library")
577    }
578
579    fn tool(artifact: &str, origin: LockOrigin, block: &str) -> DeclaredToolNeeds {
580        DeclaredToolNeeds {
581            artifact: artifact.to_string(),
582            origin,
583            programs: parse_program_needs(&format!(
584                "name: t\nversion: 0.1.0\nruntime: tool\n{block}"
585            ))
586            .unwrap(),
587        }
588    }
589
590    fn allow(names: &[&str]) -> Vec<String> {
591        names.iter().map(|name| (*name).to_string()).collect()
592    }
593
594    fn granted_paths(resolved: &ResolvedProgramNeeds) -> BTreeSet<PathBuf> {
595        resolved
596            .granted
597            .iter()
598            .flat_map(|need| need.paths.iter().map(|path| path.path.clone()))
599            .collect()
600    }
601
602    const FIXPROG: &str = "programs:
603  fixprog:
604    needs:
605      - library: libfix.so.1
606      - file: libfix.chk
607        beside: libfix.so.1
608      - program_folder: share
609      - system: fonts
610";
611
612    /// A program found only through a pinned copy's `bin/` folder (after `PATH`) resolves its
613    /// `program_folder: .` need to the copy's folder, and keeps it granted though the store lies
614    /// under the home folder; a home path outside the copy is still moved to `missing`.
615    #[test]
616    fn declared_needs_a_pinned_only_program_resolves_into_its_store_copy_under_home() {
617        let scratch = Scratch::new();
618        let tree = scratch.root.join("home/user/.murmur/programs/abc");
619        std::fs::create_dir_all(tree.join("files/fixture")).unwrap();
620        std::fs::create_dir_all(tree.join("bin")).unwrap();
621        std::fs::copy(host_binary("true"), tree.join("files/fixture/pinprog")).unwrap();
622        symlink("../files/fixture/pinprog", tree.join("bin/pinprog")).unwrap();
623        std::fs::create_dir_all(scratch.root.join("home/user/elsewhere")).unwrap();
624        symlink(
625            scratch.root.join("home/user/elsewhere"),
626            tree.join("files/fixture/outside"),
627        )
628        .unwrap();
629        let block = "programs:\n  pinprog:\n    needs:\n      - program_folder: .\n      - program_folder: outside\n";
630        let tools = [tool("local/pinned-tool", LockOrigin::Operator, block)];
631
632        // Without the pinned folder the program resolves to no binary.
633        let unpinned = derive_in(&tools, &allow(&["pinprog"]), &scratch.host());
634        assert_eq!(unpinned[0].missing[0].reason, PROGRAM_NOT_ON_PATH);
635
636        let mut host = scratch.host();
637        host.path_dirs.push(tree.join("bin"));
638        host.pinned_trees = vec![tree.clone()];
639        let resolved = derive_in(&tools, &allow(&["pinprog"]), &host);
640        assert_eq!(
641            granted_paths(&resolved[0]),
642            BTreeSet::from([tree.join("files/fixture")])
643        );
644        assert_eq!(resolved[0].missing.len(), 1);
645        assert_eq!(
646            resolved[0].missing[0].need.form,
647            NeedForm::ProgramFolder("outside".to_string())
648        );
649        assert!(
650            resolved[0].missing[0]
651                .reason
652                .contains("beneath the home folder"),
653            "{}",
654            resolved[0].missing[0].reason
655        );
656    }
657
658    #[test]
659    fn declared_needs_resolve_to_exactly_the_declared_paths_read_only() {
660        let scratch = Scratch::new();
661        let host = scratch.host();
662        let resolved = derive_in(
663            &[tool("local/needs-tool", LockOrigin::Operator, FIXPROG)],
664            &allow(&["fixprog"]),
665            &host,
666        );
667        assert_eq!(resolved.len(), 1);
668        let program = &resolved[0];
669        assert_eq!(
670            (program.artifact.as_str(), program.program.as_str()),
671            ("local/needs-tool", "fixprog")
672        );
673        assert!(program.missing.is_empty(), "{:?}", program.missing);
674
675        let library = scratch.root.join("lib/libfix.so.1");
676        let Ok(closure) = crate::sandbox::walk_dependency_closure(
677            std::slice::from_ref(&library),
678            &host.search_dirs,
679            &[],
680            |path| -> Result<_, std::convert::Infallible> {
681                Ok(crate::sandbox::read_elf_dependencies(path))
682            },
683        );
684        assert!(closure.len() > 1, "libm's own closure: {closure:?}");
685        let mut expected: BTreeSet<PathBuf> = closure.into_iter().collect();
686        expected.insert(scratch.root.join("lib/libfix.chk"));
687        expected.insert(scratch.root.join("bin/share"));
688        expected.insert(scratch.root.join("fonts"));
689        assert_eq!(granted_paths(program), expected);
690        assert!(!expected.contains(&scratch.root.join("lib/libother.so.1")));
691        assert!(!expected.contains(&scratch.root.join("lib")));
692
693        let labels: Vec<String> = program
694            .granted
695            .iter()
696            .map(|need| need.need.form.to_string())
697            .collect();
698        assert_eq!(
699            labels,
700            [
701                "library libfix.so.1",
702                "file libfix.chk beside libfix.so.1",
703                "program_folder share",
704                "system fonts",
705            ]
706        );
707
708        let grants = crate::sandbox::resolve_declared_need_grants(&resolved);
709        assert_eq!(grants.len(), expected.len());
710        for grant in &grants {
711            assert!(!grant.executable, "{grant:?}");
712            let directory = grant.path == scratch.root.join("bin/share")
713                || grant.path == scratch.root.join("fonts");
714            assert_eq!(grant.list_dir, directory, "{grant:?}");
715        }
716    }
717
718    #[test]
719    fn declared_needs_an_unreachable_location_is_moved_to_missing() {
720        let scratch = Scratch::new();
721        let bin = scratch.root.join("bin");
722        for (link, target) in [
723            ("root_link", PathBuf::from("/")),
724            ("proc_link", PathBuf::from("/proc")),
725            ("home_link", scratch.root.join("home/user")),
726            ("home_parent", scratch.root.join("home")),
727            ("writable_link", scratch.root.join("workdir")),
728            ("cred_link", scratch.root.join("etc/ssl/private")),
729        ] {
730            symlink(&target, bin.join(link)).unwrap();
731        }
732        symlink(
733            scratch.root.join("etc/ssl/private"),
734            scratch.root.join("etc/innocent"),
735        )
736        .unwrap();
737        let block = "programs:
738  fixprog:
739    needs:
740      - program_folder: root_link
741      - program_folder: proc_link
742      - program_folder: home_link
743      - program_folder: home_parent
744      - program_folder: writable_link
745      - program_folder: cred_link
746      - etc: innocent
747      - library: libmurmur-absent.so.9
748      - program_folder: share
749  ghostprog:
750    needs:
751      - program_folder: .
752      - library: libfix.so.1
753";
754        let resolved = derive_in(
755            &[tool("local/needs-tool", LockOrigin::Operator, block)],
756            &allow(&["fixprog", "ghostprog"]),
757            &scratch.host(),
758        );
759        let fixprog = &resolved[0];
760        assert_eq!(
761            granted_paths(fixprog),
762            BTreeSet::from([scratch.root.join("bin/share")])
763        );
764        let reasons: Vec<(String, String)> = fixprog
765            .missing
766            .iter()
767            .map(|missing| (missing.need.form.to_string(), missing.reason.clone()))
768            .collect();
769        let root = scratch.root.display();
770        assert_eq!(
771            reasons,
772            [
773                (
774                    "program_folder root_link".to_string(),
775                    "resolved to /, the filesystem root".to_string()
776                ),
777                (
778                    "program_folder proc_link".to_string(),
779                    "resolved to /proc, which is the kernel's /proc, which no need reaches"
780                        .to_string()
781                ),
782                (
783                    "program_folder home_link".to_string(),
784                    format!(
785                        "resolved to {root}/home/user, which is the home folder \
786                         {root}/home/user, which no need reaches"
787                    )
788                ),
789                (
790                    "program_folder home_parent".to_string(),
791                    format!(
792                        "resolved to {root}/home, which holds the home folder {root}/home/user, \
793                         which no need reaches"
794                    )
795                ),
796                (
797                    "program_folder writable_link".to_string(),
798                    format!(
799                        "resolved to {root}/workdir, which is the capsule-writable tree \
800                         {root}/workdir, which no need reaches"
801                    )
802                ),
803                (
804                    "program_folder cred_link".to_string(),
805                    format!(
806                        "resolved to {root}/etc/ssl/private, which is the credential location \
807                         {root}/etc/ssl/private, which no need reaches"
808                    )
809                ),
810                (
811                    "etc innocent".to_string(),
812                    format!(
813                        "resolved to {root}/etc/ssl/private, which is the credential location \
814                         {root}/etc/ssl/private, which no need reaches"
815                    )
816                ),
817                (
818                    "library libmurmur-absent.so.9".to_string(),
819                    NOT_FOUND.to_string()
820                ),
821            ]
822        );
823
824        // A program PATH does not resolve: its folder is unknown, and a library resolves from the
825        // search directories alone.
826        let ghost = &resolved[1];
827        assert_eq!(ghost.missing.len(), 1);
828        assert_eq!(ghost.missing[0].reason, PROGRAM_NOT_ON_PATH);
829        assert!(granted_paths(ghost).contains(&scratch.root.join("lib/libfix.so.1")));
830    }
831
832    #[test]
833    fn declared_needs_reach_only_an_operator_pin_and_a_granted_program() {
834        let scratch = Scratch::new();
835        let bin = scratch.root.join("bin");
836        std::fs::copy(host_binary("true"), bin.join("cat")).unwrap();
837        std::fs::copy(host_binary("true"), bin.join("ls")).unwrap();
838        let block = "programs:
839  cat:
840    needs:
841      - program_folder: share
842      - etc: fonts/fonts.conf
843  ls:
844    needs: []
845";
846        let host = scratch.host();
847        let operator = [tool("local/needs-tool", LockOrigin::Operator, block)];
848
849        // (a) The program with needs is not granted.
850        assert_eq!(derive_in(&operator, &allow(&["ls"]), &host), Vec::new());
851
852        // (b) Both granted: only cat's needs, under cat.
853        let both = derive_in(&operator, &allow(&["cat", "ls"]), &host);
854        assert_eq!(both.len(), 1);
855        assert_eq!(both[0].program, "cat");
856        assert_eq!(
857            granted_paths(&both[0]),
858            BTreeSet::from([
859                scratch.root.join("bin/share"),
860                scratch.root.join("etc/fonts/fonts.conf"),
861            ])
862        );
863
864        // (c) A runtime pin contributes nothing.
865        let runtime = [tool(
866            "local/needs-tool",
867            LockOrigin::Runtime {
868                session: "ses_x".to_string(),
869            },
870            block,
871        )];
872        assert_eq!(derive_in(&runtime, &allow(&["cat"]), &host), Vec::new());
873
874        // (d) An operator pin and a granted program.
875        assert_eq!(derive_in(&operator, &allow(&["cat"]), &host), both);
876
877        // The key must equal an allowlist entry verbatim.
878        assert_eq!(
879            derive_in(&operator, &allow(&["CAT", "cat2"]), &host),
880            Vec::new()
881        );
882
883        // Two tools declaring needs for one program each contribute, each under its own artifact.
884        let two = derive_in(
885            &[
886                tool("local/needs-tool", LockOrigin::Operator, block),
887                tool(
888                    "local/second",
889                    LockOrigin::Operator,
890                    "programs:\n  cat:\n    needs:\n      - system: fonts\n",
891                ),
892            ],
893            &allow(&["cat"]),
894            &host,
895        );
896        let owners: Vec<(&str, &str)> = two
897            .iter()
898            .map(|entry| (entry.artifact.as_str(), entry.program.as_str()))
899            .collect();
900        assert_eq!(
901            owners,
902            [("local/needs-tool", "cat"), ("local/second", "cat")]
903        );
904    }
905
906    #[test]
907    fn declared_needs_ca_bundle_is_the_trust_store_without_its_network_gate() {
908        let scratch = Scratch::new();
909        let bundle = scratch.root.join("etc/ssl/certs/ca.pem");
910        let bundle_text = bundle.to_string_lossy().into_owned();
911        let certs_text = scratch
912            .root
913            .join("etc/ssl/certs")
914            .to_string_lossy()
915            .into_owned();
916        let candidates = [
917            crate::trust_store::TrustStoreCandidate {
918                path: &bundle_text,
919                directory: false,
920            },
921            crate::trust_store::TrustStoreCandidate {
922                path: &certs_text,
923                directory: true,
924            },
925        ];
926        let mut host = scratch.host();
927        host.ca_candidates = &candidates;
928        let resolved = derive_in(
929            &[tool(
930                "local/needs-tool",
931                LockOrigin::Operator,
932                "programs:\n  fixprog:\n    needs:\n      - system: ca_bundle\n",
933            )],
934            &allow(&["fixprog"]),
935            &host,
936        );
937        assert_eq!(
938            resolved[0].granted[0].paths,
939            [NeedPath {
940                path: scratch.root.join("etc/ssl/certs"),
941                directory: true,
942            }]
943        );
944        // The trust store itself stays gated on the network.
945        assert_eq!(
946            crate::trust_store::derive_in(&candidates, &allow(&["fixprog"]), &[], &[]),
947            Vec::new()
948        );
949    }
950
951    #[test]
952    fn declared_needs_os_release_names_id_then_id_like() {
953        assert_eq!(
954            distributions_from_os_release("NAME=\"Ubuntu\"\nID=ubuntu\nID_LIKE=debian\n"),
955            ["ubuntu", "debian"]
956        );
957        assert_eq!(
958            distributions_from_os_release("ID=\"rocky\"\nID_LIKE=\"rhel centos fedora\"\n"),
959            ["rocky", "rhel", "centos", "fedora"]
960        );
961        assert_eq!(
962            distributions_from_os_release("ID_LIKE=\"rhel fedora\"\nID='almalinux'\n"),
963            ["almalinux", "rhel", "fedora"]
964        );
965        assert_eq!(distributions_from_os_release(""), Vec::<String>::new());
966        assert_eq!(
967            distributions_from_os_release("ID=fedora\nVERSION_ID=40\n"),
968            ["fedora"]
969        );
970    }
971
972    #[test]
973    fn declared_needs_package_for_takes_the_first_named_distribution() {
974        let need = DeclaredNeed {
975            form: NeedForm::Library("libx.so.1".to_string()),
976            packages: [("debian", "libx1"), ("fedora", "x-libs")]
977                .into_iter()
978                .map(|(k, v)| (k.to_string(), v.to_string()))
979                .collect(),
980        };
981        let ubuntu = allow(&["ubuntu", "debian"]);
982        assert_eq!(package_for(&need, &ubuntu), Some(("libx1", "debian")));
983        assert_eq!(package_for(&need, &allow(&["arch"])), None);
984    }
985}