Skip to main content

helixforge_engine/
trust.rs

1//! The trust inspector: where an entry's installer comes from, what checks
2//! the integrity of what it installs, what it downloads unchecked, what
3//! privileges it uses and how far it has been tested. It reads files and
4//! asks package managers read-only questions; it changes nothing.
5//!
6//! Three kinds of assurance are kept apart: the provenance of the HelixForge
7//! release the installer ships in, the integrity checks package managers
8//! make, and downloads that nothing checks.
9
10use crate::{
11    doctor::System,
12    history,
13    reliability::{Assessment, Evidence},
14    updates::Install,
15};
16use helixforge_core::{Action, Entry, NixAction, PackageManager, printable};
17use helixforge_platform::LinuxInfo;
18use serde::Serialize;
19use sha2::{Digest, Sha256};
20use std::path::{Path, PathBuf};
21
22/// How much a fact assures.
23#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
24#[serde(rename_all = "kebab-case")]
25pub enum Standing {
26    /// Signed build provenance that can be verified.
27    Provenance,
28    /// A checksum or signature that a tool checks before using it.
29    Checked,
30    /// Fixed to one revision by its hash.
31    Pinned,
32    /// Nothing checks it.
33    Unverified,
34    /// Cannot be told from here.
35    Unknown,
36}
37
38impl Standing {
39    pub fn label(self) -> &'static str {
40        match self {
41            Standing::Provenance => "Release provenance",
42            Standing::Checked => "Integrity checked",
43            Standing::Pinned => "Pinned revision",
44            Standing::Unverified => "Unverified",
45            Standing::Unknown => "Unknown",
46        }
47    }
48}
49
50/// One thing the inspector found.
51#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
52pub struct Fact {
53    pub standing: Standing,
54    /// What it is about: "HelixForge", "pacman", "Download".
55    pub subject: String,
56    pub text: String,
57}
58
59fn fact(standing: Standing, subject: impl Into<String>, text: impl Into<String>) -> Fact {
60    Fact {
61        standing,
62        subject: subject.into(),
63        text: printable(&text.into()),
64    }
65}
66
67/// What this copy of HelixForge is, for the provenance of what it ships.
68#[derive(Clone, Debug)]
69pub struct Build {
70    pub version: String,
71    /// Built by the release workflow (the version was stamped in).
72    pub release: bool,
73    /// The commit it was built from, when stamped.
74    pub commit: Option<String>,
75    pub install: Install,
76}
77
78/// Everything the inspector found about one entry.
79#[derive(Clone, Debug, Serialize)]
80pub struct Inspection {
81    pub id: String,
82    pub name: String,
83    /// What runs: "packages", "script", "command", "nix", "native", "profile".
84    pub kind: &'static str,
85    /// Where the installer comes from.
86    pub origin: String,
87    /// The installer file, relative to the scripts folder.
88    pub file: Option<String>,
89    pub sha256: Option<String>,
90    /// The installer's licence.
91    pub licence: Option<String>,
92    /// The software's licence, as its package manager reports it.
93    pub software_licence: Option<String>,
94    /// The release HelixForge build the installer ships in.
95    pub provenance: Vec<Fact>,
96    /// What checks the packages it installs.
97    pub integrity: Vec<Fact>,
98    /// What the installer downloads itself.
99    pub downloads: Vec<Fact>,
100    /// What it does with elevated privileges.
101    pub privileges: Vec<String>,
102    pub validation: Assessment,
103}
104
105/// Inspect an entry. `root` holds the catalog and scripts this build ships.
106pub fn inspect(
107    entry: &Entry,
108    root: &Path,
109    platform: &LinuxInfo,
110    build: &Build,
111    system: &dyn System,
112    evidence: &Evidence,
113    history: &[history::Record],
114) -> Inspection {
115    let mut inspection = Inspection {
116        id: entry.id.clone(),
117        name: entry.name.clone(),
118        kind: "command",
119        origin: "the HelixForge catalog".to_string(),
120        file: None,
121        sha256: None,
122        licence: Some("MIT (HelixForge)".to_string()),
123        software_licence: None,
124        provenance: provenance(build),
125        integrity: Vec::new(),
126        downloads: Vec::new(),
127        privileges: privileges(entry),
128        validation: evidence.assess(entry, history),
129    };
130    match &entry.action {
131        Action::Package { .. } => {
132            inspection.kind = "packages";
133            inspection.origin =
134                "commands HelixForge builds from the package names in its catalog".to_string();
135        }
136        Action::Script { file, .. } => {
137            inspection.kind = "script";
138            let scripts = root.join("scripts");
139            let relative = file
140                .strip_prefix(&scripts)
141                .unwrap_or(file)
142                .display()
143                .to_string();
144            inspection.sha256 = sum(file);
145            let (origin, licence) = script_origin(&relative);
146            inspection.origin = origin;
147            inspection.licence = Some(licence);
148            inspection.file = Some(printable(&relative));
149            let texts = script_texts(file);
150            inspection.downloads = downloads(&texts, &scripts);
151            inspection.privileges.extend(
152                elevated_commands(&texts)
153                    .into_iter()
154                    .map(|command| format!("runs {command} as root")),
155            );
156        }
157        Action::Command { line } => {
158            inspection.kind = "command";
159            inspection.origin = format!("a command line in the catalog: {}", printable(line));
160        }
161        Action::Nix { action } => {
162            inspection.kind = "nix";
163            let (what, text) = match action {
164                NixAction::ProfileInstall { installable } => (
165                    installable,
166                    "Nix builds or downloads it and checks every store path's hash; a flake's lock file pins its inputs",
167                ),
168                NixAction::HomeManagerSwitch { flake } => (
169                    flake,
170                    "Home Manager switches to this flake; Nix checks every store path's hash, and the flake's lock file pins its inputs",
171                ),
172            };
173            inspection.origin = format!("Nix: {}", printable(what));
174            inspection
175                .integrity
176                .push(fact(Standing::Checked, "Nix", text));
177        }
178        Action::Native { action } => {
179            inspection.kind = "native";
180            inspection.origin = format!(
181                "HelixForge itself (helixforge native {}), reading the system",
182                action.id()
183            );
184        }
185        Action::Profile { entries } => {
186            inspection.kind = "profile";
187            inspection.origin = format!(
188                "a profile of {} entries; inspect each one: {}",
189                entries.len(),
190                printable(&entries.join(", "))
191            );
192        }
193    }
194    if let Some(identity) = entry.identity() {
195        inspection.integrity.extend(integrity(
196            identity.packages.for_manager_any(platform.manager),
197            identity.aur,
198            identity.flatpak,
199            platform,
200            system,
201        ));
202        inspection.software_licence = software_licence(
203            identity.packages.for_manager_any(platform.manager),
204            identity.flatpak,
205            platform,
206            system,
207        );
208    }
209    inspection.integrity.sort_by_key(|fact| fact.standing);
210    inspection
211        .downloads
212        .sort_by_key(|fact| std::cmp::Reverse(fact.standing));
213    inspection
214}
215
216/// What vouches for the HelixForge build the installer ships in.
217fn provenance(build: &Build) -> Vec<Fact> {
218    let subject = format!("HelixForge {}", build.version);
219    let commit = build
220        .commit
221        .as_deref()
222        .map(|commit| format!(" of commit {}", commit.chars().take(12).collect::<String>()))
223        .unwrap_or_default();
224    if !build.release {
225        return vec![fact(
226            Standing::Unknown,
227            subject,
228            match build.install {
229                Install::Cargo => "built on this machine by cargo install; crates.io checked the source's checksums, and the catalog and scripts came with it, but there is no build provenance for this copy".to_string(),
230                _ => "built from source, not by the release workflow, so there is no build provenance; the catalog and scripts are those of the checkout".to_string(),
231            },
232        )];
233    }
234    let mut facts = vec![fact(
235        Standing::Provenance,
236        subject.clone(),
237        format!(
238            "a release build{commit}: the catalog and every installer ship inside it, and each release file has a signed build provenance attestation; gh attestation verify <file> --repo Commanderx-code/Helix-Forge shows which workflow built it"
239        ),
240    )];
241    match build.install {
242        Install::Launcher | Install::Download => facts.push(fact(
243            Standing::Checked,
244            subject,
245            "run from where the launcher or start.sh keeps it; both check the download against the release's SHA256SUMS before running it",
246        )),
247        Install::Package => facts.push(fact(
248            Standing::Checked,
249            subject,
250            "installed from the release's package; its checksum is in the release's SHA256SUMS",
251        )),
252        _ => {}
253    }
254    facts
255}
256
257/// Where a script under `scripts/` comes from, and its licence.
258fn script_origin(relative: &str) -> (String, String) {
259    if relative.starts_with("installers/commander/") {
260        (
261            "Commander Toolbox's installers, maintained in the HelixForge repository (github.com/Commanderx-code/Helix-Forge)".to_string(),
262            "MIT (HelixForge)".to_string(),
263        )
264    } else if relative.starts_with("installers/") {
265        (
266            "Linutil's catalog (github.com/ChrisTitusTech/linutil), carried into the HelixForge repository".to_string(),
267            "MIT (Linutil; scripts/installers/LICENSE-linutil)".to_string(),
268        )
269    } else {
270        (
271            "the HelixForge repository".to_string(),
272            "MIT (HelixForge)".to_string(),
273        )
274    }
275}
276
277fn sum(path: &Path) -> Option<String> {
278    let bytes = std::fs::read(path).ok()?;
279    Some(
280        Sha256::digest(&bytes)
281            .iter()
282            .map(|byte| format!("{byte:02x}"))
283            .collect(),
284    )
285}
286
287/// The script's text and that of the scripts beside it that it runs, one
288/// level deep; the shared helpers are left out.
289fn script_texts(file: &Path) -> Vec<String> {
290    let mut texts = Vec::new();
291    let Ok(text) = std::fs::read_to_string(file) else {
292        return texts;
293    };
294    let directory = file.parent().unwrap_or(Path::new("."));
295    let mut seen: Vec<PathBuf> = vec![file.to_path_buf()];
296    for word in text
297        .split(|character: char| !(character.is_ascii_alphanumeric() || "-_.".contains(character)))
298    {
299        if !word.ends_with(".sh") || word.starts_with("common") {
300            continue;
301        }
302        let other = directory.join(word);
303        if !seen.contains(&other)
304            && other.is_file()
305            && let Ok(more) = std::fs::read_to_string(&other)
306        {
307            seen.push(other);
308            texts.push(more);
309        }
310    }
311    texts.insert(0, text);
312    texts
313}
314
315/// The pinned sources `fetch_source` knows, from the shared helpers.
316fn pins(scripts: &Path) -> Vec<(String, String, String)> {
317    let Ok(text) = std::fs::read_to_string(scripts.join("installers/commander/common.sh")) else {
318        return Vec::new();
319    };
320    text.lines()
321        .filter_map(|line| {
322            let (name, rest) = line.trim().split_once(") ")?;
323            let revision = rest
324                .split("revision=")
325                .nth(1)?
326                .split(|c: char| !c.is_ascii_hexdigit())
327                .next()?;
328            if revision.len() != 40 || name.contains(['*', '|', ' ']) {
329                return None;
330            }
331            let repository = rest
332                .split("source_repo=")
333                .nth(1)
334                .and_then(|value| value.split(';').next())
335                .map(str::to_string)
336                .unwrap_or_else(|| format!("Commanderx-code/{name}"));
337            Some((name.to_string(), repository, revision.to_string()))
338        })
339        .collect()
340}
341
342/// What a script downloads, and whether anything checks it.
343fn downloads(texts: &[String], scripts: &Path) -> Vec<Fact> {
344    let mut facts = Vec::new();
345    let all = texts.join("\n");
346    let pins = pins(scripts);
347    for (name, repository, revision) in &pins {
348        if all.contains(&format!("fetch_source {name}"))
349            || (name == "dotfiles" || name == "Myfish")
350                && all.contains("fetch_source \"$source_name\"")
351        {
352            facts.push(fact(
353                Standing::Pinned,
354                "Download",
355                format!(
356                    "github.com/{repository} at commit {}, checked after the download",
357                    &revision[..12]
358                ),
359            ));
360        }
361    }
362    let checks = [
363        "sha256sum",
364        "sha512sum",
365        "gpg --verify",
366        "minisign",
367        "cosign verify",
368    ]
369    .into_iter()
370    .filter(|tool| all.contains(tool))
371    .collect::<Vec<_>>();
372    let mut urls: Vec<String> = Vec::new();
373    for line in all.lines() {
374        let line = line.trim();
375        if line.starts_with('#') {
376            continue;
377        }
378        for (start, _) in line.match_indices("http") {
379            let rest = &line[start..];
380            if !(rest.starts_with("https://") || rest.starts_with("http://")) {
381                continue;
382            }
383            let url: String = rest
384                .chars()
385                .take_while(|character| {
386                    !character.is_whitespace() && !"\"'()<>|;`".contains(*character)
387                })
388                .collect();
389            if url.len() < 12 || urls.contains(&url) {
390                continue;
391            }
392            urls.push(url.clone());
393            let piped = line.contains("| sh") || line.contains("| bash") || line.contains("| sudo");
394            let (standing, text) = if url.starts_with("http://") {
395                (
396                    Standing::Unverified,
397                    format!("{url} over plain HTTP, which anyone on the network can change"),
398                )
399            } else if piped {
400                (
401                    Standing::Unverified,
402                    format!("{url}, run straight into a shell without a check"),
403                )
404            } else if !checks.is_empty() {
405                (
406                    Standing::Checked,
407                    format!(
408                        "{url}; the script checks downloads with {}",
409                        checks.join(", ")
410                    ),
411                )
412            } else {
413                (
414                    Standing::Unverified,
415                    format!("{url}, over HTTPS but with no checksum or signature check"),
416                )
417            };
418            facts.push(fact(standing, "Download", text));
419        }
420    }
421    if all.contains("git clone") && !all.contains("fetch_source") {
422        facts.push(fact(
423            Standing::Unverified,
424            "Download",
425            "clones a Git repository at whatever its branch holds when it runs",
426        ));
427    }
428    facts
429}
430
431/// What a script runs through sudo or the escalation tool.
432fn elevated_commands(texts: &[String]) -> Vec<String> {
433    let mut commands: Vec<String> = Vec::new();
434    for text in texts {
435        for line in text.lines() {
436            if line.trim_start().starts_with('#') {
437                continue;
438            }
439            for marker in ["sudo ", "\"$ESCALATION_TOOL\" ", "$ESCALATION_TOOL "] {
440                for (start, _) in line.match_indices(marker) {
441                    let word: String = line[start + marker.len()..]
442                        .trim_start()
443                        .trim_start_matches('"')
444                        .chars()
445                        .take_while(|character| {
446                            character.is_ascii_alphanumeric() || "-_.$".contains(*character)
447                        })
448                        .collect();
449                    let word = match word.as_str() {
450                        "$PACKAGER" | "${PACKAGER}" => "the package manager".to_string(),
451                        _ => word,
452                    };
453                    if !word.is_empty() && !word.starts_with('-') && !commands.contains(&word) {
454                        commands.push(word);
455                    }
456                }
457            }
458        }
459    }
460    commands.sort();
461    commands
462}
463
464/// Privileges, from the entry's flags.
465fn privileges(entry: &Entry) -> Vec<String> {
466    let mut found = Vec::new();
467    if helixforge_core::privileged(&entry.flags, &entry.action) {
468        found.push("asks for your password (sudo) while it runs".to_string());
469    } else {
470        found.push("runs as your user, without asking for a password".to_string());
471    }
472    let flags: Vec<&str> = entry.flags.split_whitespace().collect();
473    for (flag, text) in [
474        ("D", "changes disks, such as partitions"),
475        ("K", "changes the kernel"),
476        ("SI", "installs a whole system"),
477        ("SS", "starts, stops or enables services"),
478        ("PFM", "changes system files"),
479        ("FM", "changes your files"),
480        ("RP", "removes packages"),
481    ] {
482        if flags.contains(&flag) {
483            found.push(text.to_string());
484        }
485    }
486    for path in &entry.writes {
487        found.push(format!("writes {}", printable(path)));
488    }
489    found
490}
491
492/// What checks the packages an entry installs on this machine.
493fn integrity(
494    native: Option<(PackageManager, &str)>,
495    aur: Option<&str>,
496    flatpak: Option<&str>,
497    platform: &LinuxInfo,
498    system: &dyn System,
499) -> Vec<Fact> {
500    let mut facts = Vec::new();
501    if let Some((manager, name)) = native {
502        facts.push(match manager {
503            PackageManager::Arch => {
504                let level = system
505                    .read(Path::new("/etc/pacman.conf"))
506                    .and_then(|text| sig_level(&text))
507                    .unwrap_or_else(|| "Required DatabaseOptional".to_string());
508                if level.contains("Never") || level.contains("TrustAll") {
509                    fact(
510                        Standing::Unverified,
511                        format!("pacman ({name})"),
512                        format!(
513                            "pacman.conf has SigLevel = {level}, so package signatures are not required"
514                        ),
515                    )
516                } else {
517                    fact(
518                        Standing::Checked,
519                        format!("pacman ({name})"),
520                        format!(
521                            "pacman checks each package's signature against the distribution's keyring (SigLevel = {level})"
522                        ),
523                    )
524                }
525            }
526            PackageManager::Debian => {
527                let trusted = ["/etc/apt/sources.list"]
528                    .iter()
529                    .map(PathBuf::from)
530                    .chain(
531                        system
532                            .list(Path::new("/etc/apt/sources.list.d"))
533                            .into_iter()
534                            .map(|name| PathBuf::from("/etc/apt/sources.list.d").join(name)),
535                    )
536                    .filter_map(|path| system.read(&path))
537                    .any(|text| text.contains("trusted=yes") || text.contains("Trusted: yes"));
538                if trusted {
539                    fact(
540                        Standing::Unverified,
541                        format!("apt ({name})"),
542                        "a repository is marked trusted=yes, so apt does not check its signature",
543                    )
544                } else {
545                    fact(
546                        Standing::Checked,
547                        format!("apt ({name})"),
548                        "apt checks the repository's signed Release file and each package's checksum",
549                    )
550                }
551            }
552            PackageManager::Fedora => {
553                let off = system
554                    .read(Path::new("/etc/dnf/dnf.conf"))
555                    .is_some_and(|text| {
556                        text.lines().any(|line| {
557                            let line = line.replace(' ', "").to_lowercase();
558                            line == "gpgcheck=0" || line == "gpgcheck=false"
559                        })
560                    });
561                if off {
562                    fact(
563                        Standing::Unverified,
564                        format!("dnf ({name})"),
565                        "dnf.conf turns gpgcheck off, so package signatures are not checked",
566                    )
567                } else {
568                    fact(
569                        Standing::Checked,
570                        format!("dnf ({name})"),
571                        "dnf checks each package's GPG signature (gpgcheck)",
572                    )
573                }
574            }
575            PackageManager::Suse => fact(
576                Standing::Checked,
577                format!("zypper ({name})"),
578                "zypper checks the repository's and each package's GPG signature",
579            ),
580            other => fact(
581                Standing::Checked,
582                format!("{} ({name})", other.executable()),
583                "the distribution's package manager checks its packages' signatures",
584            ),
585        });
586    }
587    if let Some(name) = aur.filter(|_| platform.manager == Some(PackageManager::Arch)) {
588        facts.push(fact(
589            Standing::Unverified,
590            format!("AUR ({name})"),
591            "if pacman has no package, the AUR's build script is used: users submit them and nobody reviews them; paru or yay shows it before building, and checks the sums it lists for its downloads",
592        ));
593    }
594    if let Some(id) = flatpak {
595        facts.push(fact(
596            Standing::Checked,
597            format!("Flathub ({id})"),
598            "Flatpak checks Flathub's signed commits; who publishes the app on Flathub is shown on its Flathub page",
599        ));
600    }
601    facts
602}
603
604/// The SigLevel in pacman.conf's [options].
605fn sig_level(text: &str) -> Option<String> {
606    let mut options = false;
607    for line in text.lines() {
608        let line = line.trim();
609        if line.starts_with('[') {
610            options = line == "[options]";
611        } else if options
612            && let Some(value) = line.strip_prefix("SigLevel")
613            && let Some(value) = value.trim_start().strip_prefix('=')
614        {
615            return Some(value.trim().to_string());
616        }
617    }
618    None
619}
620
621/// The software's licence from its package manager's metadata, read-only.
622fn software_licence(
623    native: Option<(PackageManager, &str)>,
624    flatpak: Option<&str>,
625    _platform: &LinuxInfo,
626    system: &dyn System,
627) -> Option<String> {
628    let field = |text: &str, key: &str| {
629        text.lines().find_map(|line| {
630            let (name, value) = line.split_once(':')?;
631            (name.trim() == key).then(|| printable(value.trim()))
632        })
633    };
634    if let Some((manager, name)) = native {
635        let found = match manager {
636            PackageManager::Arch => system
637                .run(&["pacman", "-Qi", name])
638                .filter(|(ok, _)| *ok)
639                .or_else(|| system.run(&["pacman", "-Si", name]).filter(|(ok, _)| *ok))
640                .and_then(|(_, text)| field(&text, "Licenses")),
641            PackageManager::Fedora | PackageManager::Suse => system
642                .run(&["rpm", "-q", "--qf", "%{LICENSE}", name])
643                .filter(|(ok, _)| *ok)
644                .map(|(_, text)| printable(text.trim())),
645            PackageManager::Debian => {
646                let copyright = PathBuf::from(format!("/usr/share/doc/{name}/copyright"));
647                system
648                    .exists(&copyright)
649                    .then(|| format!("in {}", copyright.display()))
650            }
651            _ => None,
652        };
653        if found.as_deref().is_some_and(|text| !text.is_empty()) {
654            return found;
655        }
656    }
657    let id = flatpak?;
658    system
659        .run(&["flatpak", "info", id])
660        .filter(|(ok, _)| *ok)
661        .and_then(|(_, text)| field(&text, "License"))
662}
663
664/// The family's package name with its manager.
665trait ForManagerAny {
666    fn for_manager_any(&self, manager: Option<PackageManager>) -> Option<(PackageManager, &str)>;
667}
668
669impl ForManagerAny for helixforge_core::PackageNames {
670    fn for_manager_any(&self, manager: Option<PackageManager>) -> Option<(PackageManager, &str)> {
671        let manager = manager?;
672        self.for_manager(manager).map(|name| (manager, name))
673    }
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679    use helixforge_catalog::Catalog;
680    use std::collections::HashMap;
681
682    /// A machine with fixed files and command answers.
683    #[derive(Default)]
684    struct Fake {
685        files: HashMap<PathBuf, String>,
686        commands: HashMap<String, String>,
687    }
688
689    impl System for Fake {
690        fn read(&self, path: &Path) -> Option<String> {
691            self.files.get(path).cloned()
692        }
693        fn exists(&self, path: &Path) -> bool {
694            self.files.contains_key(path)
695        }
696        fn list(&self, _: &Path) -> Vec<String> {
697            Vec::new()
698        }
699        fn link(&self, _: &Path) -> Option<PathBuf> {
700            None
701        }
702        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
703            self.commands
704                .get(&command.join(" "))
705                .map(|text| (true, text.clone()))
706        }
707        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
708            Err("offline".into())
709        }
710        fn space(&self, _: &Path) -> Option<(u64, u64)> {
711            None
712        }
713        fn owner(&self, _: &Path) -> Option<u32> {
714            None
715        }
716        fn is_link(&self, _: &Path) -> bool {
717            false
718        }
719        fn canonical(&self, _: &Path) -> Option<PathBuf> {
720            None
721        }
722        fn user(&self) -> (u32, u32, PathBuf) {
723            (1000, 1000, PathBuf::from("/home/me"))
724        }
725        fn on_path(&self, _: &str) -> bool {
726            false
727        }
728        fn processes(&self) -> Vec<String> {
729            Vec::new()
730        }
731    }
732
733    fn arch() -> LinuxInfo {
734        LinuxInfo {
735            id: "arch".into(),
736            pretty_name: "Arch Linux".into(),
737            id_like: vec![],
738            manager: Some(PackageManager::Arch),
739        }
740    }
741
742    fn release() -> Build {
743        Build {
744            version: "1.2.0".into(),
745            release: true,
746            commit: Some("0123456789abcdef0123456789abcdef01234567".into()),
747            install: Install::Package,
748        }
749    }
750
751    #[test]
752    fn a_package_entry_shows_each_sources_checks() {
753        let catalog = Catalog::builtin(false).unwrap();
754        let mut fake = Fake::default();
755        fake.files.insert(
756            PathBuf::from("/etc/pacman.conf"),
757            "[options]\nSigLevel = Required DatabaseOptional\n[core]\n".into(),
758        );
759        fake.commands.insert(
760            "pacman -Qi ripgrep".into(),
761            "Name            : ripgrep\nLicenses        : MIT  Unlicense\n".into(),
762        );
763        let found = inspect(
764            catalog.find("ripgrep").unwrap(),
765            catalog.root(),
766            &arch(),
767            &release(),
768            &fake,
769            &Evidence::default(),
770            &[],
771        );
772        assert_eq!(found.kind, "packages");
773        assert_eq!(found.software_licence.as_deref(), Some("MIT  Unlicense"));
774        assert_eq!(found.integrity[0].standing, Standing::Checked);
775        assert!(found.integrity[0].text.contains("SigLevel = Required"));
776        assert_eq!(found.provenance[0].standing, Standing::Provenance);
777        assert!(found.provenance[0].text.contains("0123456789ab"));
778        assert!(found.privileges[0].contains("password"));
779
780        // A system that turns signatures off is told apart.
781        fake.files.insert(
782            PathBuf::from("/etc/pacman.conf"),
783            "[options]\nSigLevel = Never\n".into(),
784        );
785        let found = inspect(
786            catalog.find("ripgrep").unwrap(),
787            catalog.root(),
788            &arch(),
789            &release(),
790            &fake,
791            &Evidence::default(),
792            &[],
793        );
794        assert_eq!(found.integrity[0].standing, Standing::Unverified);
795    }
796
797    #[test]
798    fn scripts_show_their_origin_downloads_and_root_commands() {
799        let catalog = Catalog::builtin(false).unwrap();
800        let fake = Fake::default();
801        let inspect_id = |id: &str, build: &Build| {
802            inspect(
803                catalog.find(id).unwrap(),
804                catalog.root(),
805                &arch(),
806                build,
807                &fake,
808                &Evidence::default(),
809                &[],
810            )
811        };
812        let sddm = inspect_id("sddm", &release());
813        assert_eq!(sddm.kind, "script");
814        assert!(sddm.origin.contains("Commander"));
815        assert_eq!(sddm.sha256.as_ref().map(String::len), Some(64));
816        assert!(
817            sddm.downloads
818                .iter()
819                .any(|fact| fact.standing == Standing::Pinned
820                    && fact.text.contains("uiriansan/SilentSDDM")),
821            "{:?}",
822            sddm.downloads
823        );
824        // The dotfiles entries reach their pinned source through dotfiles.sh.
825        let ghostty = inspect_id("dotfiles-ghostty", &release());
826        assert!(
827            ghostty
828                .downloads
829                .iter()
830                .any(|fact| fact.text.contains("Commanderx-code/dotfiles")),
831            "{:?}",
832            ghostty.downloads
833        );
834        // A piped install script is unverified, and listed first.
835        let dank = inspect_id("dank-shell", &release());
836        assert!(dank.origin.contains("Linutil"));
837        assert_eq!(dank.downloads[0].standing, Standing::Unverified);
838        assert!(dank.downloads[0].text.contains("install.danklinux.com"));
839        assert!(dank.downloads[0].text.contains("shell"));
840        // A local build has no provenance to offer.
841        let local = Build {
842            release: false,
843            commit: None,
844            install: Install::Other,
845            ..release()
846        };
847        let resolve = inspect_id("resolve-native", &local);
848        assert_eq!(resolve.provenance[0].standing, Standing::Unknown);
849        assert!(!resolve.privileges.is_empty());
850    }
851
852    #[test]
853    fn elevated_commands_are_read_from_the_script() {
854        let texts = vec![
855            "sudo pacman -S x\n\"$ESCALATION_TOOL\" systemctl enable sddm\n# sudo rm -rf /\n$ESCALATION_TOOL \"$PACKAGER\" install y\n".to_string(),
856        ];
857        assert_eq!(
858            elevated_commands(&texts),
859            ["pacman", "systemctl", "the package manager"]
860        );
861    }
862}