Skip to main content

helixforge_engine/
bundle.rs

1//! Offline installation bundles: a folder holding HelixForge itself (with
2//! its catalog inside), the packages chosen entries need for this machine's
3//! distribution and architecture, and a manifest of every file's checksum.
4//! On a machine of the same distribution without reliable internet, the
5//! bundle is checked, then its packages are installed by the package manager
6//! from the files, without downloading. What needs the network (Flatpaks,
7//! AUR builds, scripts) is named, never pretended to be offline.
8
9use crate::{Plan, Step, doctor::System, quote};
10use helixforge_catalog::Catalog;
11use helixforge_core::{Action, Entry, PackageManager, Risk, printable, valid_package_name};
12use helixforge_platform::LinuxInfo;
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256, Sha512};
15use std::path::{Component, Path, PathBuf};
16
17/// The manifest format this version writes.
18pub const FORMAT: u32 = 1;
19/// The manifest's file name in a bundle.
20pub const MANIFEST: &str = "bundle.toml";
21/// The checksums, for `sha256sum --check` by hand.
22pub const SUMS: &str = "SHA256SUMS";
23
24#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
25pub struct Manifest {
26    pub format: u32,
27    /// Unix seconds.
28    pub created: u64,
29    pub helixforge: String,
30    /// Whether the HelixForge in it is a release build.
31    #[serde(default)]
32    pub release: bool,
33    pub distribution: String,
34    /// arch, debian or fedora.
35    pub family: String,
36    /// x86_64, aarch64.
37    pub architecture: String,
38    /// Whether every dependency is in it, not only those this machine lacked.
39    #[serde(default)]
40    pub complete: bool,
41    #[serde(default, rename = "entry")]
42    pub entries: Vec<BundledEntry>,
43    #[serde(default, rename = "file")]
44    pub files: Vec<BundledFile>,
45}
46
47#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
48pub struct BundledEntry {
49    pub id: String,
50    pub name: String,
51    /// Whether it installs from the bundle alone.
52    pub offline: bool,
53    /// Its own packages, by name.
54    #[serde(default)]
55    pub packages: Vec<String>,
56    /// Why it needs the network, when it does.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub network: Option<String>,
59}
60
61#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
62pub struct BundledFile {
63    /// Relative to the bundle.
64    pub path: String,
65    pub sha256: String,
66    pub bytes: u64,
67    /// Where it was downloaded from.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub source: Option<String>,
70    /// What else vouches for it: a signature the package manager checks, or
71    /// the repository's signed index it was checked against.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub assurance: Option<String>,
74}
75
76/// A package to download.
77#[derive(Clone, Debug, Eq, PartialEq)]
78pub struct Download {
79    pub url: String,
80    pub file: String,
81    /// The checksum the repository's signed index gives, as `sha256:…` or
82    /// `sha512:…`, when the package manager says.
83    pub checksum: Option<String>,
84}
85
86/// What a bundle would hold.
87#[derive(Clone, Debug, Default)]
88pub struct Contents {
89    pub entries: Vec<BundledEntry>,
90    pub downloads: Vec<Download>,
91    /// Fedora's dnf downloads the packages itself: these names.
92    pub dnf: Vec<String>,
93}
94
95/// Why a bundle cannot be made, read or used.
96#[derive(Debug, thiserror::Error)]
97pub enum BundleError {
98    #[error("bundles are made with pacman, apt or dnf; {0} is not supported yet")]
99    Unsupported(String),
100    #[error("{0} already exists and is not empty; choose a new folder")]
101    NotEmpty(PathBuf),
102    #[error("the package manager could not resolve the packages: {0}")]
103    Resolve(String),
104    #[error("downloading {0} failed")]
105    Download(String),
106    #[error("{0} does not match the checksum its repository gives; it was deleted")]
107    Mismatch(String),
108    #[error("{0}")]
109    Io(String),
110    #[error("{0} is not a HelixForge bundle: {1}")]
111    Format(PathBuf, String),
112    #[error("the bundle was made for {0}; this machine is {1}")]
113    Machine(String, String),
114    #[error("the bundle is damaged: {0}")]
115    Damaged(String),
116    #[error(
117        "the bundle installs as a whole: its packages, shared between {0}, go in together; make a bundle of only the entries wanted"
118    )]
119    Partial(String),
120}
121
122/// The family a bundle is made for.
123fn family(manager: Option<PackageManager>) -> Result<(PackageManager, &'static str), BundleError> {
124    match manager {
125        Some(PackageManager::Arch) => Ok((PackageManager::Arch, "arch")),
126        Some(PackageManager::Debian) => Ok((PackageManager::Debian, "debian")),
127        Some(PackageManager::Fedora) => Ok((PackageManager::Fedora, "fedora")),
128        Some(other) => Err(BundleError::Unsupported(other.executable().to_string())),
129        None => Err(BundleError::Unsupported("this system".to_string())),
130    }
131}
132
133/// Which entries can go offline, and the packages they need. `complete`
134/// takes every dependency; otherwise those this machine lacks, which suits
135/// a machine set up like this one. `scratch` is a folder for pacman's empty
136/// database.
137pub fn contents(
138    ids: &[String],
139    catalog: &Catalog,
140    platform: &LinuxInfo,
141    complete: bool,
142    system: &dyn System,
143    scratch: &Path,
144) -> Result<Contents, BundleError> {
145    let (manager, _) = family(platform.manager)?;
146    let mut found = Contents::default();
147    let mut names: Vec<String> = Vec::new();
148    let mut seen: Vec<String> = Vec::new();
149    let mut queue: Vec<String> = ids.iter().rev().cloned().collect();
150    while let Some(id) = queue.pop() {
151        if seen.contains(&id) {
152            continue;
153        }
154        seen.push(id.clone());
155        let Some(entry) = catalog.find(&id) else {
156            found.entries.push(BundledEntry {
157                name: printable(&id),
158                id,
159                offline: false,
160                packages: Vec::new(),
161                network: Some("not in this catalog".to_string()),
162            });
163            continue;
164        };
165        if let Action::Profile { entries } = &entry.action {
166            queue.extend(entries.iter().rev().cloned());
167            continue;
168        }
169        let (offline, network) = classify(entry, manager);
170        let packages = offline.clone().into_iter().collect::<Vec<_>>();
171        for name in &packages {
172            if !names.contains(name) {
173                names.push(name.clone());
174            }
175        }
176        found.entries.push(BundledEntry {
177            id: entry.id.clone(),
178            name: entry.name.clone(),
179            offline: offline.is_some(),
180            packages,
181            network,
182        });
183    }
184    if names.is_empty() {
185        return Ok(found);
186    }
187    let words: Vec<&str> = names.iter().map(String::as_str).collect();
188    match manager {
189        PackageManager::Arch => {
190            // An empty cache, so pacman gives the mirrors' addresses rather
191            // than those of packages it keeps on this machine.
192            let cache = scratch.join("cache");
193            std::fs::create_dir_all(&cache).map_err(|error| BundleError::Io(error.to_string()))?;
194            let mut command: Vec<String> = vec![
195                "pacman".into(),
196                "-Sp".into(),
197                "--cachedir".into(),
198                cache.display().to_string(),
199            ];
200            if complete {
201                // An empty local database, so every dependency is listed; the
202                // sync databases are this machine's.
203                let local = scratch.join("db/local");
204                std::fs::create_dir_all(&local)
205                    .map_err(|error| BundleError::Io(error.to_string()))?;
206                let sync = scratch.join("db/sync");
207                if !sync.exists() {
208                    std::os::unix::fs::symlink("/var/lib/pacman/sync", &sync)
209                        .map_err(|error| BundleError::Io(error.to_string()))?;
210                }
211                command.push("--dbpath".into());
212                command.push(scratch.join("db").display().to_string());
213            }
214            command.extend(["--print-format".into(), "%f %l".into()]);
215            command.extend(names.iter().cloned());
216            let command: Vec<&str> = command.iter().map(String::as_str).collect();
217            let Some((true, text)) = system.run(&command) else {
218                return Err(BundleError::Resolve("pacman -Sp".to_string()));
219            };
220            for line in text.lines() {
221                let Some((file, url)) = line.trim().split_once(' ') else {
222                    continue;
223                };
224                found.downloads.push(Download {
225                    url: url.to_string(),
226                    file: file.to_string(),
227                    checksum: None,
228                });
229                // Arch signs every package: the signature goes with it.
230                found.downloads.push(Download {
231                    url: format!("{url}.sig"),
232                    file: format!("{file}.sig"),
233                    checksum: None,
234                });
235            }
236        }
237        PackageManager::Debian => {
238            // --reinstall lists packages this machine already has, too.
239            let mut command = vec!["apt-get", "--print-uris", "-qq", "--reinstall"];
240            if complete {
241                command.extend(["-o", "Dir::State::status=/dev/null"]);
242            }
243            command.push("install");
244            command.extend(words);
245            let Some((true, text)) = system.run(&command) else {
246                return Err(BundleError::Resolve("apt-get --print-uris".to_string()));
247            };
248            for line in text.lines() {
249                let parts: Vec<&str> = line.split_whitespace().collect();
250                if parts.len() < 4 {
251                    continue;
252                }
253                found.downloads.push(Download {
254                    url: parts[0].trim_matches('\'').to_string(),
255                    file: parts[1].to_string(),
256                    checksum: Some(parts[3].to_ascii_lowercase()),
257                });
258            }
259        }
260        _ => found.dnf = names,
261    }
262    for download in &found.downloads {
263        if !plain_file(&download.file) {
264            return Err(BundleError::Resolve(format!(
265                "the package manager named a file HelixForge does not accept: {}",
266                printable(&download.file)
267            )));
268        }
269    }
270    Ok(found)
271}
272
273/// The native package an entry installs offline, or why it needs the network.
274fn classify(entry: &Entry, manager: PackageManager) -> (Option<String>, Option<String>) {
275    match &entry.action {
276        Action::Package {
277            packages,
278            aur,
279            flatpak,
280            ..
281        } => match packages.for_manager(manager) {
282            Some(name) if valid_package_name(name) => (Some(name.to_string()), None),
283            _ if aur.is_some() => (None, Some("built from the AUR".to_string())),
284            _ if flatpak.is_some() => (None, Some("installed from Flathub".to_string())),
285            _ => (None, Some("no package for this distribution".to_string())),
286        },
287        Action::Script { .. } => (
288            None,
289            Some("its script may download what it installs".to_string()),
290        ),
291        Action::Command { .. } => (None, Some("its command may need the network".to_string())),
292        Action::Nix { .. } => (None, Some("Nix fetches from its caches".to_string())),
293        Action::Native { .. } => (
294            None,
295            Some("nothing to install; it runs anywhere".to_string()),
296        ),
297        Action::Profile { .. } => (None, None),
298    }
299}
300
301/// A file name a bundle may hold: one name, no path, plain characters.
302fn plain_file(name: &str) -> bool {
303    !name.is_empty()
304        && name.len() <= 255
305        && !name.starts_with('.')
306        && name
307            .bytes()
308            .all(|byte| byte.is_ascii_alphanumeric() || b"+._@:~-".contains(&byte))
309}
310
311fn sha256_file(path: &Path) -> Result<(String, u64), BundleError> {
312    let mut file = std::fs::File::open(path).map_err(|error| BundleError::Io(error.to_string()))?;
313    let mut hasher = Sha256::new();
314    let bytes = std::io::copy(&mut file, &mut hasher)
315        .map_err(|error| BundleError::Io(error.to_string()))?;
316    Ok((hex(&hasher.finalize()), bytes))
317}
318
319fn hex(bytes: &[u8]) -> String {
320    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
321}
322
323/// Whether a file matches a repository's `sha256:…` or `sha512:…`.
324fn matches(path: &Path, checksum: &str) -> Result<bool, BundleError> {
325    let (kind, want) = checksum.split_once(':').unwrap_or(("sha256", checksum));
326    let bytes = std::fs::read(path).map_err(|error| BundleError::Io(error.to_string()))?;
327    let have = match kind {
328        "sha512" => hex(&Sha512::digest(&bytes)),
329        _ => hex(&Sha256::digest(&bytes)),
330    };
331    Ok(have == want)
332}
333
334/// What making a bundle asks the machine; tests answer instead.
335pub struct Tools<'a> {
336    pub system: &'a dyn System,
337    /// Download a URL to a file; false when it failed.
338    pub download: &'a dyn Fn(&str, &Path) -> bool,
339    /// Have dnf download packages with their dependencies into a folder.
340    pub dnf_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
341}
342
343/// Make a bundle in `directory`, which must be new or empty: the packages,
344/// a copy of `helixforge`, the manifest and the checksums.
345#[allow(clippy::too_many_arguments)]
346pub fn create(
347    directory: &Path,
348    contents: &Contents,
349    platform: &LinuxInfo,
350    complete: bool,
351    helixforge: Option<(&Path, &str, bool)>,
352    now: u64,
353    tools: &Tools,
354) -> Result<Manifest, BundleError> {
355    let (_, family_name) = family(platform.manager)?;
356    if std::fs::read_dir(directory).is_ok_and(|mut listing| listing.next().is_some()) {
357        return Err(BundleError::NotEmpty(directory.to_path_buf()));
358    }
359    let packages = directory.join("packages");
360    std::fs::create_dir_all(&packages).map_err(|error| BundleError::Io(error.to_string()))?;
361    let mut files = Vec::new();
362    for download in &contents.downloads {
363        let target = packages.join(&download.file);
364        if !(tools.download)(&download.url, &target) {
365            return Err(BundleError::Download(printable(&download.url)));
366        }
367        let mut assurance = None;
368        if let Some(checksum) = &download.checksum {
369            let strong = checksum.starts_with("sha256:") || checksum.starts_with("sha512:");
370            let good = if strong {
371                matches(&target, checksum)?
372            } else {
373                // apt printed a weaker hash: ask its index for the SHA-256.
374                let (sha256, _) = sha256_file(&target)?;
375                known_to_apt(&target, &sha256, tools.system)
376            };
377            if !good {
378                let _ = std::fs::remove_file(&target);
379                return Err(BundleError::Mismatch(printable(&download.file)));
380            }
381            assurance = Some("its checksum matched the repository's signed index".to_string());
382        } else if download.file.ends_with(".sig") {
383            assurance = Some(
384                "the package's signature; pacman checks it against its keyring when it installs"
385                    .to_string(),
386            );
387        } else if family_name == "arch" {
388            assurance = Some("signed: pacman checks its .sig when it installs".to_string());
389        }
390        let (sha256, bytes) = sha256_file(&target)?;
391        files.push(BundledFile {
392            path: format!("packages/{}", download.file),
393            sha256,
394            bytes,
395            source: Some(printable(&download.url)),
396            assurance,
397        });
398    }
399    if !contents.dnf.is_empty() {
400        if !(tools.dnf_download)(&contents.dnf, complete, &packages) {
401            return Err(BundleError::Download(
402                "the packages, through dnf download".to_string(),
403            ));
404        }
405        let mut names: Vec<String> = std::fs::read_dir(&packages)
406            .map_err(|error| BundleError::Io(error.to_string()))?
407            .flatten()
408            .map(|item| item.file_name().to_string_lossy().into_owned())
409            .filter(|name| name.ends_with(".rpm") && plain_file(name))
410            .collect();
411        names.sort();
412        for name in names {
413            let (sha256, bytes) = sha256_file(&packages.join(&name))?;
414            files.push(BundledFile {
415                path: format!("packages/{name}"),
416                sha256,
417                bytes,
418                source: Some("dnf download".to_string()),
419                assurance: Some("signed: rpm -K checks it before it installs".to_string()),
420            });
421        }
422    }
423    let (version, release) = match helixforge {
424        Some((program, version, release)) => {
425            let target = directory.join("helixforge");
426            std::fs::copy(program, &target).map_err(|error| BundleError::Io(error.to_string()))?;
427            let (sha256, bytes) = sha256_file(&target)?;
428            files.push(BundledFile {
429                path: "helixforge".to_string(),
430                sha256,
431                bytes,
432                source: None,
433                assurance: Some(if release {
434                    "a release build: gh attestation verify, against the release's own file, shows where it was built".to_string()
435                } else {
436                    "built from source, so there is no build provenance".to_string()
437                }),
438            });
439            (version.to_string(), release)
440        }
441        None => (String::new(), false),
442    };
443    let manifest = Manifest {
444        format: FORMAT,
445        created: now,
446        helixforge: version,
447        release,
448        distribution: printable(&platform.pretty_name),
449        family: family_name.to_string(),
450        architecture: std::env::consts::ARCH.to_string(),
451        complete,
452        entries: contents.entries.clone(),
453        files,
454    };
455    let text = toml::to_string(&manifest).map_err(|error| BundleError::Io(error.to_string()))?;
456    std::fs::write(
457        directory.join(MANIFEST),
458        format!("# A HelixForge offline bundle: ./helixforge bundle install <this folder>\n{text}"),
459    )
460    .map_err(|error| BundleError::Io(error.to_string()))?;
461    let sums: String = manifest
462        .files
463        .iter()
464        .map(|file| format!("{}  {}\n", file.sha256, file.path))
465        .collect();
466    std::fs::write(directory.join(SUMS), sums)
467        .map_err(|error| BundleError::Io(error.to_string()))?;
468    Ok(manifest)
469}
470
471/// Read a bundle's manifest and check every file against it: the same
472/// distribution family and architecture, every file there, with its
473/// checksum, and nothing that climbs out of the bundle.
474pub fn verify(directory: &Path, platform: &LinuxInfo) -> Result<Manifest, BundleError> {
475    let path = directory.join(MANIFEST);
476    let size = std::fs::metadata(&path)
477        .map_err(|error| BundleError::Format(path.clone(), error.to_string()))?
478        .len();
479    if size > 16 << 20 {
480        return Err(BundleError::Damaged(
481            "its manifest is larger than any bundle's".to_string(),
482        ));
483    }
484    let text = std::fs::read_to_string(&path)
485        .map_err(|error| BundleError::Format(path.clone(), error.to_string()))?;
486    let manifest: Manifest = toml::from_str(&text)
487        .map_err(|error| BundleError::Format(path.clone(), printable(error.message())))?;
488    if manifest.format > FORMAT {
489        return Err(BundleError::Format(
490            path,
491            format!("format {} is from a later HelixForge", manifest.format),
492        ));
493    }
494    if !plain_manifest(&manifest) {
495        return Err(BundleError::Damaged(
496            "its manifest has control characters, malformed checksums or too many records"
497                .to_string(),
498        ));
499    }
500    let (_, family_name) = family(platform.manager)?;
501    if manifest.family != family_name || manifest.architecture != std::env::consts::ARCH {
502        return Err(BundleError::Machine(
503            printable(&format!(
504                "{} ({}, {})",
505                manifest.distribution, manifest.family, manifest.architecture
506            )),
507            format!(
508                "{} ({family_name}, {})",
509                printable(&platform.pretty_name),
510                std::env::consts::ARCH
511            ),
512        ));
513    }
514    for file in &manifest.files {
515        let relative = Path::new(&file.path);
516        let inside = relative
517            .components()
518            .all(|component| matches!(component, Component::Normal(_)));
519        let name_ok = relative
520            .file_name()
521            .is_some_and(|name| plain_file(&name.to_string_lossy()));
522        if !inside || !name_ok || !(file.path == "helixforge" || file.path.starts_with("packages/"))
523        {
524            return Err(BundleError::Damaged(format!(
525                "it names {}",
526                printable(&file.path)
527            )));
528        }
529        let full = directory.join(relative);
530        let (sha256, bytes) = sha256_file(&full)
531            .map_err(|_| BundleError::Damaged(format!("{} is missing", printable(&file.path))))?;
532        if sha256 != file.sha256 || bytes != file.bytes {
533            return Err(BundleError::Damaged(format!(
534                "{} does not match its checksum",
535                printable(&file.path)
536            )));
537        }
538    }
539    for entry in &manifest.entries {
540        if !helixforge_core::valid_id(&entry.id)
541            || !entry.packages.iter().all(|name| valid_package_name(name))
542        {
543            return Err(BundleError::Damaged(format!(
544                "it names {}",
545                printable(&entry.id)
546            )));
547        }
548    }
549    // Arch packages install only with the signature beside them, which is
550    // checked before they do: a detached signature and nothing else.
551    if family_name == "arch" {
552        for file in manifest
553            .files
554            .iter()
555            .filter(|file| file.path.ends_with(".sig"))
556        {
557            let bytes = std::fs::read(directory.join(&file.path))
558                .map_err(|error| BundleError::Io(error.to_string()))?;
559            if !detached_signature(&bytes) {
560                return Err(BundleError::Damaged(format!(
561                    "{} is not a detached signature",
562                    printable(&file.path)
563                )));
564            }
565        }
566        for file in &manifest.files {
567            let signed = format!("{}.sig", file.path);
568            if file.path.starts_with("packages/")
569                && !file.path.ends_with(".sig")
570                && !manifest.files.iter().any(|other| other.path == signed)
571            {
572                return Err(BundleError::Damaged(format!(
573                    "{} has no signature",
574                    printable(&file.path)
575                )));
576            }
577        }
578    }
579    Ok(manifest)
580}
581
582/// Whether bytes are binary OpenPGP signature packets and nothing else, as a
583/// detached signature is: no literal data, no compressed or one-pass
584/// packets that could carry a message of their own.
585pub fn detached_signature(bytes: &[u8]) -> bool {
586    if bytes.is_empty() || bytes.len() > 64 * 1024 {
587        return false;
588    }
589    let mut at = 0;
590    while at < bytes.len() {
591        let header = bytes[at];
592        if header & 0x80 == 0 {
593            return false;
594        }
595        let (tag, length, used) = if header & 0x40 != 0 {
596            // New format: one, two or five length octets.
597            let tag = header & 0x3f;
598            let Some(&first) = bytes.get(at + 1) else {
599                return false;
600            };
601            match first {
602                0..=191 => (tag, first as usize, 2),
603                192..=223 => {
604                    let Some(&second) = bytes.get(at + 2) else {
605                        return false;
606                    };
607                    (
608                        tag,
609                        ((first as usize - 192) << 8) + second as usize + 192,
610                        3,
611                    )
612                }
613                255 => {
614                    let Some(four) = bytes.get(at + 2..at + 6) else {
615                        return false;
616                    };
617                    (
618                        tag,
619                        u32::from_be_bytes([four[0], four[1], four[2], four[3]]) as usize,
620                        6,
621                    )
622                }
623                // Partial lengths belong to data packets, not signatures.
624                _ => return false,
625            }
626        } else {
627            let tag = (header >> 2) & 0x0f;
628            match header & 0x03 {
629                0 => {
630                    let Some(&one) = bytes.get(at + 1) else {
631                        return false;
632                    };
633                    (tag, one as usize, 2)
634                }
635                1 => {
636                    let Some(two) = bytes.get(at + 1..at + 3) else {
637                        return false;
638                    };
639                    (tag, u16::from_be_bytes([two[0], two[1]]) as usize, 3)
640                }
641                2 => {
642                    let Some(four) = bytes.get(at + 1..at + 5) else {
643                        return false;
644                    };
645                    (
646                        tag,
647                        u32::from_be_bytes([four[0], four[1], four[2], four[3]]) as usize,
648                        5,
649                    )
650                }
651                _ => return false,
652            }
653        };
654        if tag != 2 {
655            return false;
656        }
657        at += used + length;
658    }
659    at == bytes.len()
660}
661
662/// Plain text throughout: a manifest from someone else could otherwise put
663/// escape sequences on the terminal. Checksums are hexadecimal.
664fn plain_manifest(manifest: &Manifest) -> bool {
665    let plain = |text: &str| text.len() <= 1000 && !text.chars().any(char::is_control);
666    let mut texts: Vec<&str> = vec![
667        &manifest.helixforge,
668        &manifest.distribution,
669        &manifest.family,
670        &manifest.architecture,
671    ];
672    for entry in &manifest.entries {
673        texts.push(&entry.id);
674        texts.push(&entry.name);
675        texts.extend(entry.network.as_deref());
676        texts.extend(entry.packages.iter().map(String::as_str));
677    }
678    for file in &manifest.files {
679        texts.push(&file.path);
680        texts.extend(file.source.as_deref());
681        texts.extend(file.assurance.as_deref());
682    }
683    texts.into_iter().all(plain)
684        && manifest.files.iter().all(|file| {
685            file.sha256.len() == 64 && file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
686        })
687        && manifest.files.len() <= 10_000
688        && manifest.entries.len() <= 10_000
689}
690
691/// A plan that installs a verified bundle's packages from its files, for the
692/// entries named (all of the bundle's when none). The files are copied into
693/// a new private folder and checked there against the checksums of the
694/// manifest verified here, which are written into the plan itself, so
695/// nothing changed in the bundle afterwards is installed. Then each
696/// package's signature is checked (pacman's with pacman-key, rpm's with rpm
697/// -K), and the package manager installs them from the files.
698///
699/// Debian packages carry no signature of their own: each must be known to
700/// this machine's repository index (signed, and checked by apt) with the
701/// same checksum, unless `trust` takes the bundle's word for them.
702pub fn plan_install(
703    directory: &Path,
704    manifest: &Manifest,
705    ids: &[String],
706    platform: &LinuxInfo,
707    system: &dyn System,
708    trust: bool,
709) -> Result<Plan, BundleError> {
710    let (manager, _) = family(platform.manager)?;
711    // Entries share dependencies, so the bundle's packages install together:
712    // naming some would still install them all, which the plan would not say.
713    let offline_ids: Vec<&str> = manifest
714        .entries
715        .iter()
716        .filter(|entry| entry.offline)
717        .map(|entry| entry.id.as_str())
718        .collect();
719    if !ids.is_empty()
720        && !offline_ids
721            .iter()
722            .all(|id| ids.iter().any(|wanted| wanted == id))
723    {
724        return Err(BundleError::Partial(offline_ids.join(", ")));
725    }
726    let chosen: Vec<&BundledEntry> = manifest.entries.iter().collect();
727    let mut plan = Plan::default();
728    for entry in &chosen {
729        if let Some(network) = &entry.network {
730            plan.notes.push(format!(
731                "{}: needs the network ({}); run it when online",
732                printable(&entry.name),
733                printable(network)
734            ));
735        }
736    }
737    let offline: Vec<&&BundledEntry> = chosen.iter().filter(|entry| entry.offline).collect();
738    if offline.is_empty() {
739        return Ok(plan);
740    }
741    let directory =
742        std::fs::canonicalize(directory).map_err(|error| BundleError::Io(error.to_string()))?;
743    let packages: Vec<&BundledFile> = manifest
744        .files
745        .iter()
746        .filter(|file| file.path.starts_with("packages/"))
747        .collect();
748    let name = |file: &BundledFile| {
749        Path::new(&file.path)
750            .file_name()
751            .map(|name| name.to_string_lossy().into_owned())
752            .unwrap_or_default()
753    };
754    if manager == PackageManager::Debian && !trust {
755        let mut unknown = Vec::new();
756        for file in packages.iter().filter(|file| file.path.ends_with(".deb")) {
757            if !known_to_apt(&directory.join(&file.path), &file.sha256, system) {
758                unknown.push(name(file));
759            }
760        }
761        if !unknown.is_empty() {
762            return Err(BundleError::Damaged(format!(
763                "this machine's repository index does not know {} with the same checksum, so nothing but the bundle vouches for them; install with --trust-bundle only if you made the bundle yourself",
764                unknown.join(", ")
765            )));
766        }
767    }
768    let staged = |file: &BundledFile| format!("\"$hf_staging\"/{}", quote(&name(file)));
769    let mut lines = vec![
770        "hf_staging=$(mktemp -d) || exit 1".to_string(),
771        "trap 'rm -rf \"$hf_staging\"' EXIT".to_string(),
772    ];
773    for file in &packages {
774        lines.push(format!(
775            "cp -- {} \"$hf_staging\"/ || exit 1",
776            quote(&directory.join(&file.path).display().to_string())
777        ));
778    }
779    // The checksums verified here, not the bundle's own file.
780    let sums: Vec<String> = packages
781        .iter()
782        .map(|file| quote(&format!("{}  {}", file.sha256, name(file))))
783        .collect();
784    lines.push(format!(
785        "( cd \"$hf_staging\" && printf '%s\\n' {} | sha256sum --quiet --check - ) || {{ echo 'HelixForge: the bundle changed after it was checked; nothing was installed' >&2; exit 1; }}",
786        sums.join(" ")
787    ));
788    let installable: Vec<&&BundledFile> = packages
789        .iter()
790        .filter(|file| !file.path.ends_with(".sig"))
791        .collect();
792    let mut requires = vec![
793        "sha256sum".to_string(),
794        "sudo".to_string(),
795        "mktemp".to_string(),
796    ];
797    match manager {
798        PackageManager::Arch => {
799            for file in &installable {
800                lines.push(format!(
801                    "sudo pacman-key --verify {}.sig {} || exit 1",
802                    staged(file),
803                    staged(file)
804                ));
805            }
806            requires.push("pacman-key".to_string());
807        }
808        PackageManager::Fedora => {
809            let files: Vec<String> = installable.iter().map(|file| staged(file)).collect();
810            lines.push(format!("rpm -K {} || exit 1", files.join(" ")));
811            requires.push("rpm".to_string());
812        }
813        _ => {}
814    }
815    let files: Vec<String> = installable.iter().map(|file| staged(file)).collect();
816    let (install, tool) = match manager {
817        PackageManager::Arch => ("sudo pacman -U --needed", "pacman"),
818        // apt's --no-download refuses the local files too; with everything
819        // in the bundle, nothing is downloaded.
820        PackageManager::Debian => ("sudo apt-get install", "apt-get"),
821        _ => ("sudo dnf install --disablerepo='*'", "dnf"),
822    };
823    lines.push(format!("{install} {}", files.join(" ")));
824    requires.push(tool.to_string());
825    let names: Vec<String> = offline.iter().map(|entry| printable(&entry.name)).collect();
826    let shown: Vec<String> = installable.iter().map(|file| name(file)).collect();
827    plan.steps.push(Step::Shell {
828        id: "bundle-install".to_string(),
829        name: format!("Install {} from the bundle", names.join(", ")),
830        program: lines.join("\n"),
831        display: printable(&format!(
832            "copy the packages from {} to a private folder, check them against the verified checksums{}, then {install} {}",
833            directory.display(),
834            match manager {
835                PackageManager::Arch => " and their signatures (pacman-key --verify)",
836                PackageManager::Fedora => " and their signatures (rpm -K)",
837                _ => "",
838            },
839            shown.join(" ")
840        )),
841        requires: requires.clone(),
842        changes: Vec::new(),
843    });
844    requires.sort();
845    requires.dedup();
846    plan.requirements = requires;
847    plan.privileged = true;
848    plan.risk = Risk::Medium;
849    plan.depends = vec![Vec::new()];
850    Ok(plan)
851}
852
853/// Whether this machine's apt index lists the package in this file, at its
854/// version, with this checksum: `dpkg-deb` reads its name and version, and
855/// `apt-cache show` what the signed index says.
856fn known_to_apt(file: &Path, sha256: &str, system: &dyn System) -> bool {
857    let path = file.display().to_string();
858    let Some((true, fields)) = system.run(&["dpkg-deb", "--field", &path, "Package", "Version"])
859    else {
860        return false;
861    };
862    let field = |key: &str| {
863        fields.lines().find_map(|line| {
864            let (name, value) = line.split_once(':')?;
865            (name.trim() == key).then(|| value.trim().to_string())
866        })
867    };
868    let (Some(package), Some(version)) = (field("Package"), field("Version")) else {
869        return false;
870    };
871    if !valid_package_name(&package) || version.chars().any(|c| c.is_whitespace() || c.is_control())
872    {
873        return false;
874    }
875    let Some((true, shown)) = system.run(&["apt-cache", "show", &format!("{package}={version}")])
876    else {
877        return false;
878    };
879    shown
880        .lines()
881        .filter_map(|line| line.strip_prefix("SHA256:"))
882        .any(|value| value.trim().eq_ignore_ascii_case(sha256))
883}
884
885#[cfg(test)]
886mod tests {
887    use super::*;
888    use std::collections::HashMap;
889
890    #[derive(Default)]
891    struct Fake {
892        commands: HashMap<String, String>,
893    }
894
895    impl System for Fake {
896        fn read(&self, _: &Path) -> Option<String> {
897            None
898        }
899        fn exists(&self, _: &Path) -> bool {
900            false
901        }
902        fn list(&self, _: &Path) -> Vec<String> {
903            Vec::new()
904        }
905        fn link(&self, _: &Path) -> Option<PathBuf> {
906            None
907        }
908        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
909            self.commands
910                .get(&command.join(" "))
911                .map(|text| (true, text.clone()))
912        }
913        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
914            Err("offline".into())
915        }
916        fn space(&self, _: &Path) -> Option<(u64, u64)> {
917            None
918        }
919        fn owner(&self, _: &Path) -> Option<u32> {
920            None
921        }
922        fn is_link(&self, _: &Path) -> bool {
923            false
924        }
925        fn canonical(&self, _: &Path) -> Option<PathBuf> {
926            None
927        }
928        fn user(&self) -> (u32, u32, PathBuf) {
929            (1000, 1000, PathBuf::from("/home/me"))
930        }
931        fn on_path(&self, _: &str) -> bool {
932            true
933        }
934        fn processes(&self) -> Vec<String> {
935            Vec::new()
936        }
937    }
938
939    fn platform(manager: PackageManager, id: &str) -> LinuxInfo {
940        LinuxInfo {
941            id: id.into(),
942            pretty_name: format!("{id} Linux"),
943            id_like: vec![],
944            manager: Some(manager),
945        }
946    }
947
948    /// The smallest detached signature the checks accept: one signature
949    /// packet, new format, one byte long.
950    const SIGNATURE: &[u8] = &[0xc2, 0x01, 0x04];
951
952    #[test]
953    fn only_bare_signature_packets_count_as_a_detached_signature() {
954        assert!(detached_signature(SIGNATURE));
955        // Old format, tag 2, one-byte length.
956        assert!(detached_signature(&[0x88, 0x01, 0x04]));
957        // Two signatures in a row.
958        assert!(detached_signature(&[0xc2, 0x01, 0x04, 0x88, 0x01, 0x04]));
959        // A literal data packet (tag 11), a one-pass signature (tag 4), a
960        // compressed packet (tag 8), armour, a short or empty file: no.
961        for bad in [
962            &[0xcb, 0x01, 0x00][..],
963            &[0xc4, 0x01, 0x00],
964            &[0xc8, 0x01, 0x00],
965            b"-----BEGIN PGP SIGNATURE-----",
966            &[0xc2, 0x05, 0x04],
967            &[],
968            &[0xc2, 0x01, 0x04, 0xcb, 0x01, 0x00],
969        ] {
970            assert!(!detached_signature(bad), "{bad:?}");
971        }
972    }
973
974    /// Downloads served from a map of URL to contents.
975    fn served(files: HashMap<String, Vec<u8>>) -> impl Fn(&str, &Path) -> bool {
976        move |url: &str, to: &Path| match files.get(url) {
977            Some(bytes) => std::fs::write(to, bytes).is_ok(),
978            None => false,
979        }
980    }
981
982    #[test]
983    fn an_arch_bundle_holds_packages_and_signatures_and_installs_offline() {
984        let catalog = Catalog::builtin(false).unwrap();
985        let arch = platform(PackageManager::Arch, "arch");
986        let scratch = tempfile::tempdir().unwrap();
987        let scratch_cache = scratch.path().join("cache");
988        let mut fake = Fake::default();
989        fake.commands.insert(
990            format!("pacman -Sp --cachedir {} --print-format %f %l htop", scratch_cache.display()),
991            "htop-3.5.3-1-x86_64.pkg.tar.zst https://mirror.example/extra/os/x86_64/htop-3.5.3-1-x86_64.pkg.tar.zst\n".into(),
992        );
993        let ids = vec![
994            "htop".to_string(),
995            "steam".to_string(),
996            "heroic".to_string(),
997        ];
998        let found = contents(&ids, &catalog, &arch, false, &fake, scratch.path()).unwrap();
999        assert_eq!(found.downloads.len(), 2, "the package and its signature");
1000        assert!(found.entries[0].offline);
1001        assert!(!found.entries[1].offline);
1002        assert!(
1003            found.entries[1]
1004                .network
1005                .as_ref()
1006                .unwrap()
1007                .contains("script")
1008        );
1009        let url = "https://mirror.example/extra/os/x86_64/htop-3.5.3-1-x86_64.pkg.tar.zst";
1010        let download = served(HashMap::from([
1011            (url.to_string(), b"package".to_vec()),
1012            (format!("{url}.sig"), SIGNATURE.to_vec()),
1013        ]));
1014        let tools = Tools {
1015            system: &fake,
1016            download: &download,
1017            dnf_download: &|_, _, _| false,
1018        };
1019        let program = scratch.path().join("program");
1020        std::fs::write(&program, b"helixforge").unwrap();
1021        let bundle = scratch.path().join("bundle");
1022        let made = create(
1023            &bundle,
1024            &found,
1025            &arch,
1026            false,
1027            Some((&program, "1.5.0", true)),
1028            1,
1029            &tools,
1030        )
1031        .unwrap();
1032        assert_eq!(made.files.len(), 3);
1033        assert!(bundle.join(SUMS).is_file());
1034        // A second bundle into the same folder is refused.
1035        assert!(matches!(
1036            create(&bundle, &found, &arch, false, None, 1, &tools),
1037            Err(BundleError::NotEmpty(_))
1038        ));
1039        let read = verify(&bundle, &arch).unwrap();
1040        assert_eq!(read, made);
1041        let planned = plan_install(&bundle, &read, &[], &arch, &fake, false).unwrap();
1042        assert_eq!(planned.steps.len(), 1);
1043        let shown = planned.steps[0].display();
1044        assert!(shown.contains("pacman-key --verify"), "{shown}");
1045        assert!(
1046            shown.contains("sudo pacman -U --needed htop-3.5.3-1-x86_64.pkg.tar.zst"),
1047            "{shown}"
1048        );
1049        let Step::Shell { program, .. } = &planned.steps[0] else {
1050            panic!("a shell step");
1051        };
1052        // The checksums are the verified manifest's, written into the plan.
1053        assert!(program.contains(&read.files[0].sha256), "{program}");
1054        assert!(program.contains("sha256sum --quiet --check -"), "{program}");
1055        assert!(!program.contains("SHA256SUMS"), "{program}");
1056        assert!(program.contains("pacman-key --verify"), "{program}");
1057        assert!(
1058            planned
1059                .notes
1060                .iter()
1061                .any(|note| note.contains("Steam") && note.contains("network"))
1062        );
1063        // Naming only some of the bundle's offline entries is refused.
1064        assert!(matches!(
1065            plan_install(&bundle, &read, &["steam".to_string()], &arch, &fake, false),
1066            Err(BundleError::Partial(_))
1067        ));
1068        // Another distribution, or a damaged file, is refused.
1069        assert!(matches!(
1070            verify(&bundle, &platform(PackageManager::Debian, "debian")),
1071            Err(BundleError::Machine(..))
1072        ));
1073        std::fs::write(
1074            bundle.join("packages/htop-3.5.3-1-x86_64.pkg.tar.zst"),
1075            b"tampered",
1076        )
1077        .unwrap();
1078        assert!(matches!(
1079            verify(&bundle, &arch),
1080            Err(BundleError::Damaged(_))
1081        ));
1082    }
1083
1084    /// A bundle of one Arch package, made with stand-in downloads.
1085    fn arch_bundle(at: &Path) -> (Manifest, LinuxInfo, Fake) {
1086        let catalog = Catalog::builtin(false).unwrap();
1087        let arch = platform(PackageManager::Arch, "arch");
1088        let scratch_cache = at.join("cache");
1089        let mut fake = Fake::default();
1090        fake.commands.insert(
1091            format!(
1092                "pacman -Sp --cachedir {} --print-format %f %l htop",
1093                scratch_cache.display()
1094            ),
1095            "htop-1-1-x86_64.pkg.tar.zst https://mirror.example/htop-1-1-x86_64.pkg.tar.zst\n"
1096                .into(),
1097        );
1098        let found = contents(&["htop".to_string()], &catalog, &arch, false, &fake, at).unwrap();
1099        let download = served(HashMap::from([
1100            (
1101                "https://mirror.example/htop-1-1-x86_64.pkg.tar.zst".to_string(),
1102                b"package".to_vec(),
1103            ),
1104            (
1105                "https://mirror.example/htop-1-1-x86_64.pkg.tar.zst.sig".to_string(),
1106                SIGNATURE.to_vec(),
1107            ),
1108        ]));
1109        let tools = Tools {
1110            system: &fake,
1111            download: &download,
1112            dnf_download: &|_, _, _| false,
1113        };
1114        let made = create(&at.join("bundle"), &found, &arch, false, None, 1, &tools).unwrap();
1115        (made, arch, fake)
1116    }
1117
1118    #[test]
1119    fn a_file_changed_after_the_check_is_never_installed() {
1120        let scratch = tempfile::tempdir().unwrap();
1121        let (_, arch, fake) = arch_bundle(scratch.path());
1122        let bundle = scratch.path().join("bundle");
1123        let manifest = verify(&bundle, &arch).unwrap();
1124        let planned = plan_install(&bundle, &manifest, &[], &arch, &fake, false).unwrap();
1125        let Step::Shell { program, .. } = &planned.steps[0] else {
1126            panic!("a shell step");
1127        };
1128        // Stand-ins: sudo runs its command, pacman-key accepts, pacman records.
1129        let bin = scratch.path().join("bin");
1130        std::fs::create_dir_all(&bin).unwrap();
1131        let log = scratch.path().join("pacman.log");
1132        for (tool, body) in [
1133            ("sudo", "exec \"$@\"".to_string()),
1134            ("pacman-key", "exit 0".to_string()),
1135            ("pacman", format!("echo \"$@\" >> {}", log.display())),
1136        ] {
1137            let path = bin.join(tool);
1138            std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1139            std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o755))
1140                .unwrap();
1141        }
1142        let run = || {
1143            std::process::Command::new("sh")
1144                .arg("-c")
1145                .arg(program)
1146                .env(
1147                    "PATH",
1148                    format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
1149                )
1150                .output()
1151                .unwrap()
1152        };
1153        let output = run();
1154        assert!(
1155            output.status.success(),
1156            "{}",
1157            String::from_utf8_lossy(&output.stderr)
1158        );
1159        assert!(
1160            std::fs::read_to_string(&log)
1161                .unwrap()
1162                .contains("-U --needed")
1163        );
1164        std::fs::remove_file(&log).unwrap();
1165        // Changed after the plan was made, the bundle's own sums along with it.
1166        std::fs::write(bundle.join("packages/htop-1-1-x86_64.pkg.tar.zst"), b"evil").unwrap();
1167        std::fs::write(bundle.join(SUMS), "").unwrap();
1168        let output = run();
1169        assert!(!output.status.success());
1170        assert!(
1171            String::from_utf8_lossy(&output.stderr)
1172                .contains("the bundle changed after it was checked")
1173        );
1174        assert!(!log.exists(), "pacman never ran");
1175    }
1176
1177    #[test]
1178    fn unsigned_or_unprintable_manifests_are_refused() {
1179        let scratch = tempfile::tempdir().unwrap();
1180        let (made, arch, _) = arch_bundle(scratch.path());
1181        let bundle = scratch.path().join("bundle");
1182        let write = |manifest: &Manifest| {
1183            std::fs::write(bundle.join(MANIFEST), toml::to_string(manifest).unwrap()).unwrap()
1184        };
1185        let mut unsigned = made.clone();
1186        unsigned.files.retain(|file| !file.path.ends_with(".sig"));
1187        write(&unsigned);
1188        let error = verify(&bundle, &arch).unwrap_err().to_string();
1189        assert!(error.contains("no signature"), "{error}");
1190        let mut evil = made.clone();
1191        evil.entries[0].network = Some("\u{1b}]52;c;x\u{7}".into());
1192        write(&evil);
1193        let error = verify(&bundle, &arch).unwrap_err().to_string();
1194        assert!(error.contains("control characters"), "{error}");
1195        assert!(!error.chars().any(char::is_control));
1196        write(&made);
1197        assert!(verify(&bundle, &arch).is_ok());
1198    }
1199
1200    #[test]
1201    fn debian_downloads_are_checked_against_the_repository_index() {
1202        let catalog = Catalog::builtin(false).unwrap();
1203        let debian = platform(PackageManager::Debian, "debian");
1204        let good = hex(&Sha256::digest(b"deb"));
1205        let mut fake = Fake::default();
1206        fake.commands.insert(
1207            "apt-get --print-uris -qq --reinstall install htop".into(),
1208            format!("'http://deb.example/pool/h/htop_3.2_amd64.deb' htop_3.2_amd64.deb 3 SHA256:{good}\n"),
1209        );
1210        let scratch = tempfile::tempdir().unwrap();
1211        let found = contents(
1212            &["htop".to_string()],
1213            &catalog,
1214            &debian,
1215            false,
1216            &fake,
1217            scratch.path(),
1218        )
1219        .unwrap();
1220        assert_eq!(
1221            found.downloads[0].checksum.as_deref(),
1222            Some(format!("sha256:{good}").as_str())
1223        );
1224        let honest = served(HashMap::from([(
1225            "http://deb.example/pool/h/htop_3.2_amd64.deb".to_string(),
1226            b"deb".to_vec(),
1227        )]));
1228        let tools = Tools {
1229            system: &fake,
1230            download: &honest,
1231            dnf_download: &|_, _, _| false,
1232        };
1233        let bundle = scratch.path().join("good");
1234        create(&bundle, &found, &debian, false, None, 1, &tools).unwrap();
1235        let manifest = verify(&bundle, &debian).unwrap();
1236        // Unknown to this machine's index: refused, unless the bundle is trusted.
1237        let refused = plan_install(&bundle, &manifest, &[], &debian, &fake, false).unwrap_err();
1238        assert!(refused.to_string().contains("--trust-bundle"), "{refused}");
1239        let planned = plan_install(&bundle, &manifest, &[], &debian, &fake, true).unwrap();
1240        assert!(
1241            planned.steps[0]
1242                .display()
1243                .contains("sudo apt-get install htop_3.2_amd64.deb")
1244        );
1245        // Known to the index with the same checksum: accepted.
1246        let file = std::fs::canonicalize(bundle.join("packages/htop_3.2_amd64.deb")).unwrap();
1247        fake.commands.insert(
1248            format!("dpkg-deb --field {} Package Version", file.display()),
1249            "Package: htop\nVersion: 3.2\n".into(),
1250        );
1251        fake.commands.insert(
1252            "apt-cache show htop=3.2".into(),
1253            format!("Package: htop\nVersion: 3.2\nSHA256: {good}\n"),
1254        );
1255        assert!(plan_install(&bundle, &manifest, &[], &debian, &fake, false).is_ok());
1256        // A download that differs from the signed index is deleted, not kept.
1257        let lying = served(HashMap::from([(
1258            "http://deb.example/pool/h/htop_3.2_amd64.deb".to_string(),
1259            b"evil".to_vec(),
1260        )]));
1261        let tools = Tools {
1262            system: &fake,
1263            download: &lying,
1264            dnf_download: &|_, _, _| false,
1265        };
1266        let bad = scratch.path().join("bad");
1267        assert!(matches!(
1268            create(&bad, &found, &debian, false, None, 1, &tools),
1269            Err(BundleError::Mismatch(_))
1270        ));
1271        assert!(!bad.join("packages/htop_3.2_amd64.deb").exists());
1272    }
1273
1274    #[test]
1275    fn manifests_cannot_reach_outside_the_bundle() {
1276        let arch = platform(PackageManager::Arch, "arch");
1277        let directory = tempfile::tempdir().unwrap();
1278        let manifest = Manifest {
1279            format: FORMAT,
1280            family: "arch".into(),
1281            architecture: std::env::consts::ARCH.into(),
1282            files: vec![BundledFile {
1283                path: "../../etc/passwd".into(),
1284                ..BundledFile::default()
1285            }],
1286            ..Manifest::default()
1287        };
1288        std::fs::write(
1289            directory.path().join(MANIFEST),
1290            toml::to_string(&manifest).unwrap(),
1291        )
1292        .unwrap();
1293        assert!(matches!(
1294            verify(directory.path(), &arch),
1295            Err(BundleError::Damaged(_))
1296        ));
1297        assert!(!plain_file("../x"));
1298        assert!(!plain_file("a b"));
1299        assert!(plain_file("libstdc++-16.2.1+r23-1-x86_64.pkg.tar.zst"));
1300        assert!(plain_file("zlib-1:1.3.2-4-x86_64.pkg.tar.zst"));
1301        assert!(matches!(
1302            contents(
1303                &[],
1304                &Catalog::builtin(false).unwrap(),
1305                &platform(PackageManager::Suse, "opensuse"),
1306                false,
1307                &Fake::default(),
1308                directory.path()
1309            ),
1310            Err(BundleError::Unsupported(_))
1311        ));
1312    }
1313}