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. Flatpak applications installed here
7//! go in as `flatpak create-usb` copies them, with their runtimes, and are
8//! installed from that copy, their remote's signatures checked as from the
9//! remote itself. What needs the network (AUR builds, scripts, Flatpaks not
10//! installed here) is named, never pretended to be offline.
11
12use crate::{Plan, Step, doctor::System, quote};
13use helixforge_catalog::Catalog;
14use helixforge_core::{Action, Entry, PackageManager, Risk, printable, valid_package_name};
15use helixforge_platform::LinuxInfo;
16use serde::{Deserialize, Serialize};
17use sha2::{Digest, Sha256, Sha512};
18use std::path::{Component, Path, PathBuf};
19
20/// The newest manifest format this version reads and writes: 2 holds
21/// Flatpaks, and bundles without them are still written as 1.
22pub const FORMAT: u32 = 2;
23/// The folder `flatpak create-usb` writes its repository into.
24pub const FLATPAKS: &str = "flatpak";
25/// The folder for the Flatpak remotes' signing keys.
26pub const KEYS: &str = "flatpak-keys";
27/// The manifest's file name in a bundle.
28pub const MANIFEST: &str = "bundle.toml";
29/// The checksums, for `sha256sum --check` by hand.
30pub const SUMS: &str = "SHA256SUMS";
31
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33pub struct Manifest {
34    pub format: u32,
35    /// Unix seconds.
36    pub created: u64,
37    pub helixforge: String,
38    /// Whether the HelixForge in it is a release build.
39    #[serde(default)]
40    pub release: bool,
41    pub distribution: String,
42    /// arch, debian, fedora or opensuse.
43    pub family: String,
44    /// x86_64, aarch64.
45    pub architecture: String,
46    /// Whether every dependency is in it, not only those this machine lacked.
47    #[serde(default)]
48    pub complete: bool,
49    #[serde(default, rename = "entry")]
50    pub entries: Vec<BundledEntry>,
51    #[serde(default, rename = "file")]
52    pub files: Vec<BundledFile>,
53    #[serde(default, rename = "flatpak", skip_serializing_if = "Vec::is_empty")]
54    pub flatpaks: Vec<BundledFlatpak>,
55}
56
57#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
58pub struct BundledEntry {
59    pub id: String,
60    pub name: String,
61    /// Whether it installs from the bundle alone.
62    pub offline: bool,
63    /// Its own packages, by name.
64    #[serde(default)]
65    pub packages: Vec<String>,
66    /// Why it needs the network, when it does.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub network: Option<String>,
69    /// The Flatpak application it installs from the bundle, instead.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub flatpak: Option<String>,
72}
73
74/// A Flatpak application in the bundle, as `flatpak create-usb` copied it
75/// from this machine's installation with the runtimes it needs.
76#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
77pub struct BundledFlatpak {
78    /// The application's ID.
79    pub id: String,
80    /// Its ref: `app/ID/ARCH/BRANCH`.
81    pub reference: String,
82    /// The commit installed here, which the bundle's copy must be.
83    pub commit: String,
84    /// The remote it came from, the remote's address and collection ID.
85    pub remote: String,
86    pub url: String,
87    pub collection: String,
88    /// In the user's own installation rather than the system's.
89    #[serde(default)]
90    pub user: bool,
91}
92
93#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
94pub struct BundledFile {
95    /// Relative to the bundle.
96    pub path: String,
97    pub sha256: String,
98    pub bytes: u64,
99    /// Where it was downloaded from.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub source: Option<String>,
102    /// What else vouches for it: a signature the package manager checks, or
103    /// the repository's signed index it was checked against.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub assurance: Option<String>,
106}
107
108/// A package to download.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct Download {
111    pub url: String,
112    pub file: String,
113    /// The checksum the repository's signed index gives, as `sha256:…` or
114    /// `sha512:…`, when the package manager says.
115    pub checksum: Option<String>,
116}
117
118/// What a bundle would hold.
119#[derive(Clone, Debug, Default)]
120pub struct Contents {
121    pub entries: Vec<BundledEntry>,
122    pub downloads: Vec<Download>,
123    /// Fedora's dnf downloads the packages itself: these names.
124    pub dnf: Vec<String>,
125    /// So does openSUSE's zypper, into a scratch root of its own.
126    pub zypper: Vec<String>,
127    /// Flatpak applications installed here, to copy.
128    pub flatpaks: Vec<BundledFlatpak>,
129}
130
131/// Why a bundle cannot be made, read or used.
132#[derive(Debug, thiserror::Error)]
133pub enum BundleError {
134    #[error("bundles are made with pacman, apt, dnf or zypper; {0} is not supported yet")]
135    Unsupported(String),
136    #[error("{0} already exists and is not empty; choose a new folder")]
137    NotEmpty(PathBuf),
138    #[error("the package manager could not resolve the packages: {0}")]
139    Resolve(String),
140    #[error("downloading {0} failed")]
141    Download(String),
142    #[error("{0} does not match the checksum its repository gives; it was deleted")]
143    Mismatch(String),
144    #[error("{0}")]
145    Io(String),
146    #[error("{0} is not a HelixForge bundle: {1}")]
147    Format(PathBuf, String),
148    #[error("the bundle was made for {0}; this machine is {1}")]
149    Machine(String, String),
150    #[error("the bundle is damaged: {0}")]
151    Damaged(String),
152    #[error(
153        "the bundle installs as a whole: its packages, shared between {0}, go in together; make a bundle of only the entries wanted"
154    )]
155    Partial(String),
156    #[error(
157        "the {0} Flatpak remote has no collection ID, which flatpak create-usb needs to copy its applications; {1}"
158    )]
159    Collection(String, String),
160    #[error("{0}")]
161    Untrusted(String),
162}
163
164/// The family a bundle is made for.
165fn family(manager: Option<PackageManager>) -> Result<(PackageManager, &'static str), BundleError> {
166    match manager {
167        Some(PackageManager::Arch) => Ok((PackageManager::Arch, "arch")),
168        Some(PackageManager::Debian) => Ok((PackageManager::Debian, "debian")),
169        Some(PackageManager::Fedora) => Ok((PackageManager::Fedora, "fedora")),
170        Some(PackageManager::Suse) => Ok((PackageManager::Suse, "opensuse")),
171        Some(other) => Err(BundleError::Unsupported(other.executable().to_string())),
172        None => Err(BundleError::Unsupported("this system".to_string())),
173    }
174}
175
176/// Which entries can go offline, and the packages they need. `complete`
177/// takes every dependency; otherwise those this machine lacks, which suits
178/// a machine set up like this one. `scratch` is a folder for pacman's empty
179/// database.
180pub fn contents(
181    ids: &[String],
182    catalog: &Catalog,
183    platform: &LinuxInfo,
184    complete: bool,
185    system: &dyn System,
186    scratch: &Path,
187) -> Result<Contents, BundleError> {
188    let (manager, _) = family(platform.manager)?;
189    let mut found = Contents::default();
190    let mut names: Vec<String> = Vec::new();
191    let mut seen: Vec<String> = Vec::new();
192    let mut queue: Vec<String> = ids.iter().rev().cloned().collect();
193    let mut installed: Option<Vec<BundledFlatpak>> = None;
194    while let Some(id) = queue.pop() {
195        if seen.contains(&id) {
196            continue;
197        }
198        seen.push(id.clone());
199        let Some(entry) = catalog.find(&id) else {
200            found.entries.push(BundledEntry {
201                name: printable(&id),
202                id,
203                offline: false,
204                packages: Vec::new(),
205                network: Some("not in this catalog".to_string()),
206                flatpak: None,
207            });
208            continue;
209        };
210        if let Action::Profile { entries } = &entry.action {
211            queue.extend(entries.iter().rev().cloned());
212            continue;
213        }
214        let (offline, mut network) = classify(entry, manager);
215        if offline.is_none()
216            && let Action::Package {
217                flatpak: Some(id), ..
218            } = &entry.action
219        {
220            let installed = installed.get_or_insert_with(|| installed_flatpaks(system));
221            if let Some(flatpak) = installed.iter().find(|flatpak| flatpak.id == *id) {
222                if flatpak.collection.is_empty() {
223                    return Err(BundleError::Collection(
224                        printable(&flatpak.remote),
225                        collection_hint(&flatpak.remote, flatpak.user),
226                    ));
227                }
228                found.entries.push(BundledEntry {
229                    id: entry.id.clone(),
230                    name: entry.name.clone(),
231                    offline: true,
232                    packages: Vec::new(),
233                    network: None,
234                    flatpak: Some(id.clone()),
235                });
236                if !found
237                    .flatpaks
238                    .iter()
239                    .any(|other| other.reference == flatpak.reference)
240                {
241                    found.flatpaks.push(flatpak.clone());
242                }
243                continue;
244            }
245            network =
246                network.map(|why| format!("{why}; its Flatpak can be bundled once installed here"));
247        }
248        let packages = offline.clone().into_iter().collect::<Vec<_>>();
249        for name in &packages {
250            if !names.contains(name) {
251                names.push(name.clone());
252            }
253        }
254        found.entries.push(BundledEntry {
255            id: entry.id.clone(),
256            name: entry.name.clone(),
257            offline: offline.is_some(),
258            packages,
259            network,
260            flatpak: None,
261        });
262    }
263    if names.is_empty() {
264        return Ok(found);
265    }
266    let words: Vec<&str> = names.iter().map(String::as_str).collect();
267    match manager {
268        PackageManager::Arch => {
269            // An empty cache, so pacman gives the mirrors' addresses rather
270            // than those of packages it keeps on this machine.
271            let cache = scratch.join("cache");
272            std::fs::create_dir_all(&cache).map_err(|error| BundleError::Io(error.to_string()))?;
273            let mut command: Vec<String> = vec![
274                "pacman".into(),
275                "-Sp".into(),
276                "--cachedir".into(),
277                cache.display().to_string(),
278            ];
279            if complete {
280                // An empty local database, so every dependency is listed; the
281                // sync databases are this machine's.
282                let local = scratch.join("db/local");
283                std::fs::create_dir_all(&local)
284                    .map_err(|error| BundleError::Io(error.to_string()))?;
285                let sync = scratch.join("db/sync");
286                if !sync.exists() {
287                    std::os::unix::fs::symlink("/var/lib/pacman/sync", &sync)
288                        .map_err(|error| BundleError::Io(error.to_string()))?;
289                }
290                command.push("--dbpath".into());
291                command.push(scratch.join("db").display().to_string());
292            }
293            command.extend(["--print-format".into(), "%f %l".into()]);
294            command.extend(names.iter().cloned());
295            let command: Vec<&str> = command.iter().map(String::as_str).collect();
296            let Some((true, text)) = system.run(&command) else {
297                return Err(BundleError::Resolve("pacman -Sp".to_string()));
298            };
299            for line in text.lines() {
300                let Some((file, url)) = line.trim().split_once(' ') else {
301                    continue;
302                };
303                found.downloads.push(Download {
304                    url: url.to_string(),
305                    file: file.to_string(),
306                    checksum: None,
307                });
308                // Arch signs every package: the signature goes with it.
309                found.downloads.push(Download {
310                    url: format!("{url}.sig"),
311                    file: format!("{file}.sig"),
312                    checksum: None,
313                });
314            }
315        }
316        PackageManager::Debian => {
317            // --reinstall lists packages this machine already has, too.
318            let mut command = vec!["apt-get", "--print-uris", "-qq", "--reinstall"];
319            if complete {
320                command.extend(["-o", "Dir::State::status=/dev/null"]);
321            }
322            command.push("install");
323            command.extend(words);
324            let Some((true, text)) = system.run(&command) else {
325                return Err(BundleError::Resolve("apt-get --print-uris".to_string()));
326            };
327            for line in text.lines() {
328                let parts: Vec<&str> = line.split_whitespace().collect();
329                if parts.len() < 4 {
330                    continue;
331                }
332                found.downloads.push(Download {
333                    url: parts[0].trim_matches('\'').to_string(),
334                    file: parts[1].to_string(),
335                    checksum: Some(parts[3].to_ascii_lowercase()),
336                });
337            }
338        }
339        PackageManager::Suse => found.zypper = names,
340        _ => found.dnf = names,
341    }
342    for download in &found.downloads {
343        if !plain_file(&download.file) {
344            return Err(BundleError::Resolve(format!(
345                "the package manager named a file HelixForge does not accept: {}",
346                printable(&download.file)
347            )));
348        }
349    }
350    Ok(found)
351}
352
353/// The native package an entry installs offline, or why it needs the network.
354fn classify(entry: &Entry, manager: PackageManager) -> (Option<String>, Option<String>) {
355    match &entry.action {
356        Action::Package {
357            packages,
358            aur,
359            flatpak,
360            ..
361        } => match packages.for_manager(manager) {
362            Some(name) if valid_package_name(name) => (Some(name.to_string()), None),
363            _ if aur.is_some() => (None, Some("built from the AUR".to_string())),
364            _ if flatpak.is_some() => (None, Some("installed from Flathub".to_string())),
365            _ => (None, Some("no package for this distribution".to_string())),
366        },
367        Action::Script { .. } => (
368            None,
369            Some("its script may download what it installs".to_string()),
370        ),
371        Action::Command { .. } => (None, Some("its command may need the network".to_string())),
372        Action::Nix { .. } => (None, Some("Nix fetches from its caches".to_string())),
373        Action::Native { .. } => (
374            None,
375            Some("nothing to install; it runs anywhere".to_string()),
376        ),
377        Action::Profile { .. } => (None, None),
378    }
379}
380
381/// The Flatpak applications installed here, in the system installation or
382/// the user's, with their remotes; `collection` is empty when the remote
383/// has none.
384fn installed_flatpaks(system: &dyn System) -> Vec<BundledFlatpak> {
385    let Some((true, text)) = system.run(&[
386        "flatpak",
387        "list",
388        "--app",
389        "--columns=application,ref,origin,installation",
390    ]) else {
391        return Vec::new();
392    };
393    let mut remotes: Vec<(bool, Vec<Remote>)> = Vec::new();
394    let mut found = Vec::new();
395    for line in text.lines() {
396        let parts: Vec<&str> = line.trim_end().split('\t').collect();
397        let [id, reference, remote, installation] = parts[..] else {
398            continue;
399        };
400        let user = match installation {
401            "user" => true,
402            "system" => false,
403            _ => continue,
404        };
405        let reference = format!("app/{reference}");
406        if !plain_flatpak_ref(&reference, id) || !flatpak_name(remote) {
407            continue;
408        }
409        if !remotes.iter().any(|(scope, _)| *scope == user) {
410            remotes.push((user, flatpak_remotes(system, user)));
411        }
412        let Some((_, url, collection)) = remotes
413            .iter()
414            .find(|(scope, _)| *scope == user)
415            .and_then(|(_, known)| known.iter().find(|(name, _, _)| name == remote))
416        else {
417            continue;
418        };
419        let Some((true, commit)) =
420            system.run(&["flatpak", "info", scope(user), "--show-commit", &reference])
421        else {
422            continue;
423        };
424        found.push(BundledFlatpak {
425            id: id.to_string(),
426            reference,
427            commit: commit.trim().to_string(),
428            remote: remote.to_string(),
429            url: url.clone(),
430            collection: collection.clone(),
431            user,
432        });
433    }
434    found
435}
436
437/// A Flatpak remote: name, address and collection ID (empty when it has
438/// none).
439type Remote = (String, String, String);
440
441/// A machine's Flatpak remotes.
442fn flatpak_remotes(system: &dyn System, user: bool) -> Vec<Remote> {
443    let Some((true, text)) = system.run(&[
444        "flatpak",
445        "remotes",
446        scope(user),
447        "--columns=name,url,collection",
448    ]) else {
449        return Vec::new();
450    };
451    text.lines()
452        .filter_map(|line| {
453            let parts: Vec<&str> = line.trim_end().split('\t').collect();
454            let [name, url, collection] = parts[..] else {
455                return None;
456            };
457            let collection = if collection == "-" { "" } else { collection };
458            Some((name.to_string(), url.to_string(), collection.to_string()))
459        })
460        .collect()
461}
462
463fn scope(user: bool) -> &'static str {
464    if user { "--user" } else { "--system" }
465}
466
467/// The collection IDs publishers give their remotes, by address: the only
468/// ones a bundle may give a remote that has none, unless it is trusted.
469const PUBLISHED_COLLECTIONS: [(&str, &str); 2] = [
470    ("https://dl.flathub.org/repo/", "org.flathub.Stable"),
471    ("https://dl.flathub.org/beta-repo/", "org.flathub.Beta"),
472];
473
474fn published_collection(address: &str) -> Option<&'static str> {
475    let address = address.trim_end_matches('/');
476    PUBLISHED_COLLECTIONS
477        .iter()
478        .find(|(published, _)| published.trim_end_matches('/') == address)
479        .map(|(_, collection)| *collection)
480}
481
482/// How to give a remote its collection ID.
483fn collection_hint(remote: &str, user: bool) -> String {
484    let command = if user {
485        "flatpak --user remote-modify"
486    } else {
487        "sudo flatpak remote-modify"
488    };
489    match remote {
490        "flathub" => format!(
491            "Flathub's is org.flathub.Stable: {command} --collection-id=org.flathub.Stable flathub"
492        ),
493        "flathub-beta" => format!(
494            "Flathub Beta's is org.flathub.Beta: {command} --collection-id=org.flathub.Beta flathub-beta"
495        ),
496        _ => format!(
497            "set the one its publisher gives with {command} --collection-id=ID {}",
498            printable(remote)
499        ),
500    }
501}
502
503/// A Flatpak ID, remote, collection, architecture or branch: plain characters.
504fn flatpak_name(text: &str) -> bool {
505    !text.is_empty()
506        && text.len() <= 255
507        && !text.starts_with(['.', '-'])
508        && text
509            .bytes()
510            .all(|byte| byte.is_ascii_alphanumeric() || b"._-".contains(&byte))
511}
512
513/// `app/ID/ARCH/BRANCH`, for this ID.
514fn plain_flatpak_ref(reference: &str, id: &str) -> bool {
515    let parts: Vec<&str> = reference.split('/').collect();
516    parts.len() == 4
517        && parts[0] == "app"
518        && parts[1] == id
519        && parts.iter().all(|part| flatpak_name(part))
520}
521
522/// Where a remote's key goes in the bundle.
523fn key_path(remote: &str, user: bool) -> String {
524    format!("{KEYS}/{}{remote}.gpg", if user { "user-" } else { "" })
525}
526
527/// The commit a Flatpak repository holds for a ref, where `create-usb`
528/// writes it: under the remote's collection ID.
529fn mirrored_commit(repository: &Path, flatpak: &BundledFlatpak) -> Option<String> {
530    let path = repository
531        .join("refs/mirrors")
532        .join(&flatpak.collection)
533        .join(&flatpak.reference);
534    std::fs::read_to_string(path)
535        .ok()
536        .map(|text| text.trim().to_string())
537}
538
539/// A file name a bundle may hold: one name, no path, plain characters.
540fn plain_file(name: &str) -> bool {
541    !name.is_empty()
542        && name.len() <= 255
543        && !name.starts_with('.')
544        && name
545            .bytes()
546            .all(|byte| byte.is_ascii_alphanumeric() || b"+._@:~-".contains(&byte))
547}
548
549fn sha256_file(path: &Path) -> Result<(String, u64), BundleError> {
550    let mut file = std::fs::File::open(path).map_err(|error| BundleError::Io(error.to_string()))?;
551    let mut hasher = Sha256::new();
552    let bytes = std::io::copy(&mut file, &mut hasher)
553        .map_err(|error| BundleError::Io(error.to_string()))?;
554    Ok((hex(&hasher.finalize()), bytes))
555}
556
557fn hex(bytes: &[u8]) -> String {
558    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
559}
560
561/// Whether a file matches a repository's `sha256:…` or `sha512:…`.
562fn matches(path: &Path, checksum: &str) -> Result<bool, BundleError> {
563    let (kind, want) = checksum.split_once(':').unwrap_or(("sha256", checksum));
564    let bytes = std::fs::read(path).map_err(|error| BundleError::Io(error.to_string()))?;
565    let have = match kind {
566        "sha512" => hex(&Sha512::digest(&bytes)),
567        _ => hex(&Sha256::digest(&bytes)),
568    };
569    Ok(have == want)
570}
571
572/// A package manager downloading packages, with their dependencies (all
573/// of them, when asked), into a folder.
574type Downloader<'a> = dyn Fn(&[String], bool, &Path) -> bool + 'a;
575
576/// What making a bundle asks the machine; tests answer instead.
577pub struct Tools<'a> {
578    pub system: &'a dyn System,
579    /// Download a URL to a file; false when it failed.
580    pub download: &'a dyn Fn(&str, &Path) -> bool,
581    /// Have dnf download packages with their dependencies into a folder.
582    pub dnf_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
583    /// The same with zypper, which needs no root for it.
584    pub zypper_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
585    /// Have `flatpak create-usb` copy refs, with the runtimes they need,
586    /// from the system installation (or the user's) into the bundle's
587    /// FLATPAKS folder.
588    pub create_usb: &'a dyn Fn(&[String], bool, &Path) -> bool,
589    /// Where a remote's signing key is kept, in the system installation or
590    /// the user's.
591    pub flatpak_key: &'a dyn Fn(&str, bool) -> PathBuf,
592}
593
594/// Make a bundle in `directory`, which must be new or empty: the packages,
595/// a copy of `helixforge`, the manifest and the checksums.
596#[allow(clippy::too_many_arguments)]
597pub fn create(
598    directory: &Path,
599    contents: &Contents,
600    platform: &LinuxInfo,
601    complete: bool,
602    helixforge: Option<(&Path, &str, bool)>,
603    now: u64,
604    tools: &Tools,
605) -> Result<Manifest, BundleError> {
606    let (_, family_name) = family(platform.manager)?;
607    if std::fs::read_dir(directory).is_ok_and(|mut listing| listing.next().is_some()) {
608        return Err(BundleError::NotEmpty(directory.to_path_buf()));
609    }
610    let packages = directory.join("packages");
611    std::fs::create_dir_all(&packages).map_err(|error| BundleError::Io(error.to_string()))?;
612    let mut files = Vec::new();
613    for download in &contents.downloads {
614        let target = packages.join(&download.file);
615        if !(tools.download)(&download.url, &target) {
616            return Err(BundleError::Download(printable(&download.url)));
617        }
618        let mut assurance = None;
619        if let Some(checksum) = &download.checksum {
620            let strong = checksum.starts_with("sha256:") || checksum.starts_with("sha512:");
621            let good = if strong {
622                matches(&target, checksum)?
623            } else {
624                // apt printed a weaker hash: ask its index for the SHA-256.
625                let (sha256, _) = sha256_file(&target)?;
626                known_to_apt(&target, &sha256, tools.system)
627            };
628            if !good {
629                let _ = std::fs::remove_file(&target);
630                return Err(BundleError::Mismatch(printable(&download.file)));
631            }
632            assurance = Some("its checksum matched the repository's signed index".to_string());
633        } else if download.file.ends_with(".sig") {
634            assurance = Some(
635                "the package's signature; pacman checks it against its keyring when it installs"
636                    .to_string(),
637            );
638        } else if family_name == "arch" {
639            assurance = Some("signed: pacman checks its .sig when it installs".to_string());
640        }
641        let (sha256, bytes) = sha256_file(&target)?;
642        files.push(BundledFile {
643            path: format!("packages/{}", download.file),
644            sha256,
645            bytes,
646            source: Some(printable(&download.url)),
647            assurance,
648        });
649    }
650    let downloaders: [(&[String], &Downloader, &str); 2] = [
651        (&contents.dnf, tools.dnf_download, "dnf download"),
652        (
653            &contents.zypper,
654            tools.zypper_download,
655            "zypper install --download-only",
656        ),
657    ];
658    for (names, download, label) in downloaders {
659        if names.is_empty() {
660            continue;
661        }
662        if !download(names, complete, &packages) {
663            return Err(BundleError::Download(format!(
664                "the packages, through {label}"
665            )));
666        }
667        let mut names: Vec<String> = std::fs::read_dir(&packages)
668            .map_err(|error| BundleError::Io(error.to_string()))?
669            .flatten()
670            .map(|item| item.file_name().to_string_lossy().into_owned())
671            .filter(|name| name.ends_with(".rpm") && plain_file(name))
672            .collect();
673        names.sort();
674        for name in names {
675            let (sha256, bytes) = sha256_file(&packages.join(&name))?;
676            files.push(BundledFile {
677                path: format!("packages/{name}"),
678                sha256,
679                bytes,
680                source: Some(label.to_string()),
681                assurance: Some("signed: rpm -K checks it before it installs".to_string()),
682            });
683        }
684    }
685    for user in [false, true] {
686        let refs: Vec<String> = contents
687            .flatpaks
688            .iter()
689            .filter(|flatpak| flatpak.user == user)
690            .map(|flatpak| flatpak.reference.clone())
691            .collect();
692        if !refs.is_empty() && !(tools.create_usb)(&refs, user, directory) {
693            return Err(BundleError::Download(
694                "the Flatpaks, through flatpak create-usb".to_string(),
695            ));
696        }
697    }
698    let repository = directory.join(FLATPAKS);
699    for flatpak in &contents.flatpaks {
700        if mirrored_commit(&repository, flatpak).as_deref() != Some(flatpak.commit.as_str()) {
701            return Err(BundleError::Mismatch(format!(
702                "{}, as flatpak create-usb copied it,",
703                printable(&flatpak.reference)
704            )));
705        }
706    }
707    // The remotes' signing keys, for a machine that lacks the remote.
708    let mut keys: Vec<String> = Vec::new();
709    for flatpak in &contents.flatpaks {
710        let path = key_path(&flatpak.remote, flatpak.user);
711        if keys.contains(&path) {
712            continue;
713        }
714        let source = (tools.flatpak_key)(&flatpak.remote, flatpak.user);
715        let target = directory.join(&path);
716        std::fs::create_dir_all(directory.join(KEYS))
717            .map_err(|error| BundleError::Io(error.to_string()))?;
718        std::fs::copy(&source, &target).map_err(|error| {
719            BundleError::Io(format!("cannot copy {}: {error}", source.display()))
720        })?;
721        let (sha256, bytes) = sha256_file(&target)?;
722        files.push(BundledFile {
723            path: path.clone(),
724            sha256,
725            bytes,
726            source: Some(printable(&source.display().to_string())),
727            assurance: Some(format!(
728                "the {} remote's signing key, from the machine that made the bundle; used only where the remote is missing, with --trust-bundle",
729                printable(&flatpak.remote)
730            )),
731        });
732        keys.push(path);
733    }
734    let (version, release) = match helixforge {
735        Some((program, version, release)) => {
736            let target = directory.join("helixforge");
737            std::fs::copy(program, &target).map_err(|error| BundleError::Io(error.to_string()))?;
738            let (sha256, bytes) = sha256_file(&target)?;
739            files.push(BundledFile {
740                path: "helixforge".to_string(),
741                sha256,
742                bytes,
743                source: None,
744                assurance: Some(if release {
745                    "a release build: gh attestation verify, against the release's own file, shows where it was built".to_string()
746                } else {
747                    "built from source, so there is no build provenance".to_string()
748                }),
749            });
750            (version.to_string(), release)
751        }
752        None => (String::new(), false),
753    };
754    let manifest = Manifest {
755        format: if contents.flatpaks.is_empty() {
756            1
757        } else {
758            FORMAT
759        },
760        created: now,
761        helixforge: version,
762        release,
763        distribution: printable(&platform.pretty_name),
764        family: family_name.to_string(),
765        architecture: std::env::consts::ARCH.to_string(),
766        complete,
767        entries: contents.entries.clone(),
768        files,
769        flatpaks: contents.flatpaks.clone(),
770    };
771    let text = toml::to_string(&manifest).map_err(|error| BundleError::Io(error.to_string()))?;
772    std::fs::write(
773        directory.join(MANIFEST),
774        format!("# A HelixForge offline bundle: ./helixforge bundle install <this folder>\n{text}"),
775    )
776    .map_err(|error| BundleError::Io(error.to_string()))?;
777    let sums: String = manifest
778        .files
779        .iter()
780        .map(|file| format!("{}  {}\n", file.sha256, file.path))
781        .collect();
782    std::fs::write(directory.join(SUMS), sums)
783        .map_err(|error| BundleError::Io(error.to_string()))?;
784    Ok(manifest)
785}
786
787/// Read a bundle's manifest and check every file against it: the same
788/// distribution family and architecture, every file there, with its
789/// checksum, and nothing that climbs out of the bundle.
790pub fn verify(directory: &Path, platform: &LinuxInfo) -> Result<Manifest, BundleError> {
791    let path = directory.join(MANIFEST);
792    let size = std::fs::metadata(&path)
793        .map_err(|error| BundleError::Format(path.clone(), error.to_string()))?
794        .len();
795    if size > 16 << 20 {
796        return Err(BundleError::Damaged(
797            "its manifest is larger than any bundle's".to_string(),
798        ));
799    }
800    let text = std::fs::read_to_string(&path)
801        .map_err(|error| BundleError::Format(path.clone(), error.to_string()))?;
802    let manifest: Manifest = toml::from_str(&text)
803        .map_err(|error| BundleError::Format(path.clone(), printable(error.message())))?;
804    if manifest.format > FORMAT {
805        return Err(BundleError::Format(
806            path,
807            format!("format {} is from a later HelixForge", manifest.format),
808        ));
809    }
810    if !plain_manifest(&manifest) {
811        return Err(BundleError::Damaged(
812            "its manifest has control characters, malformed checksums or too many records"
813                .to_string(),
814        ));
815    }
816    let (_, family_name) = family(platform.manager)?;
817    if manifest.family != family_name || manifest.architecture != std::env::consts::ARCH {
818        return Err(BundleError::Machine(
819            printable(&format!(
820                "{} ({}, {})",
821                manifest.distribution, manifest.family, manifest.architecture
822            )),
823            format!(
824                "{} ({family_name}, {})",
825                printable(&platform.pretty_name),
826                std::env::consts::ARCH
827            ),
828        ));
829    }
830    for file in &manifest.files {
831        let relative = Path::new(&file.path);
832        let inside = relative
833            .components()
834            .all(|component| matches!(component, Component::Normal(_)));
835        let name_ok = relative
836            .file_name()
837            .is_some_and(|name| plain_file(&name.to_string_lossy()));
838        let place = file.path == "helixforge"
839            || file.path.starts_with("packages/")
840            || file.path.starts_with(&format!("{KEYS}/"));
841        if !inside || !name_ok || !place {
842            return Err(BundleError::Damaged(format!(
843                "it names {}",
844                printable(&file.path)
845            )));
846        }
847        let full = directory.join(relative);
848        let (sha256, bytes) = sha256_file(&full)
849            .map_err(|_| BundleError::Damaged(format!("{} is missing", printable(&file.path))))?;
850        if sha256 != file.sha256 || bytes != file.bytes {
851            return Err(BundleError::Damaged(format!(
852                "{} does not match its checksum",
853                printable(&file.path)
854            )));
855        }
856    }
857    let repository = directory.join(FLATPAKS);
858    for flatpak in &manifest.flatpaks {
859        if mirrored_commit(&repository, flatpak).as_deref() != Some(flatpak.commit.as_str()) {
860            return Err(BundleError::Damaged(format!(
861                "its Flatpak repository does not hold {} at the commit it records",
862                printable(&flatpak.reference)
863            )));
864        }
865        if !manifest
866            .files
867            .iter()
868            .any(|file| file.path == key_path(&flatpak.remote, flatpak.user))
869        {
870            return Err(BundleError::Damaged(format!(
871                "the {} remote's key is missing",
872                printable(&flatpak.remote)
873            )));
874        }
875    }
876    for entry in &manifest.entries {
877        if !helixforge_core::valid_id(&entry.id)
878            || !entry.packages.iter().all(|name| valid_package_name(name))
879            || entry
880                .flatpak
881                .as_deref()
882                .is_some_and(|id| !manifest.flatpaks.iter().any(|flatpak| flatpak.id == id))
883        {
884            return Err(BundleError::Damaged(format!(
885                "it names {}",
886                printable(&entry.id)
887            )));
888        }
889    }
890    // Arch packages install only with the signature beside them, which is
891    // checked before they do: a detached signature and nothing else.
892    if family_name == "arch" {
893        for file in manifest
894            .files
895            .iter()
896            .filter(|file| file.path.ends_with(".sig"))
897        {
898            let bytes = std::fs::read(directory.join(&file.path))
899                .map_err(|error| BundleError::Io(error.to_string()))?;
900            if !detached_signature(&bytes) {
901                return Err(BundleError::Damaged(format!(
902                    "{} is not a detached signature",
903                    printable(&file.path)
904                )));
905            }
906        }
907        for file in &manifest.files {
908            let signed = format!("{}.sig", file.path);
909            if file.path.starts_with("packages/")
910                && !file.path.ends_with(".sig")
911                && !manifest.files.iter().any(|other| other.path == signed)
912            {
913                return Err(BundleError::Damaged(format!(
914                    "{} has no signature",
915                    printable(&file.path)
916                )));
917            }
918        }
919    }
920    Ok(manifest)
921}
922
923/// Whether bytes are binary OpenPGP signature packets and nothing else, as a
924/// detached signature is: no literal data, no compressed or one-pass
925/// packets that could carry a message of their own.
926pub fn detached_signature(bytes: &[u8]) -> bool {
927    if bytes.is_empty() || bytes.len() > 64 * 1024 {
928        return false;
929    }
930    let mut at = 0;
931    while at < bytes.len() {
932        let header = bytes[at];
933        if header & 0x80 == 0 {
934            return false;
935        }
936        let (tag, length, used) = if header & 0x40 != 0 {
937            // New format: one, two or five length octets.
938            let tag = header & 0x3f;
939            let Some(&first) = bytes.get(at + 1) else {
940                return false;
941            };
942            match first {
943                0..=191 => (tag, first as usize, 2),
944                192..=223 => {
945                    let Some(&second) = bytes.get(at + 2) else {
946                        return false;
947                    };
948                    (
949                        tag,
950                        ((first as usize - 192) << 8) + second as usize + 192,
951                        3,
952                    )
953                }
954                255 => {
955                    let Some(four) = bytes.get(at + 2..at + 6) else {
956                        return false;
957                    };
958                    (
959                        tag,
960                        u32::from_be_bytes([four[0], four[1], four[2], four[3]]) as usize,
961                        6,
962                    )
963                }
964                // Partial lengths belong to data packets, not signatures.
965                _ => return false,
966            }
967        } else {
968            let tag = (header >> 2) & 0x0f;
969            match header & 0x03 {
970                0 => {
971                    let Some(&one) = bytes.get(at + 1) else {
972                        return false;
973                    };
974                    (tag, one as usize, 2)
975                }
976                1 => {
977                    let Some(two) = bytes.get(at + 1..at + 3) else {
978                        return false;
979                    };
980                    (tag, u16::from_be_bytes([two[0], two[1]]) as usize, 3)
981                }
982                2 => {
983                    let Some(four) = bytes.get(at + 1..at + 5) else {
984                        return false;
985                    };
986                    (
987                        tag,
988                        u32::from_be_bytes([four[0], four[1], four[2], four[3]]) as usize,
989                        5,
990                    )
991                }
992                _ => return false,
993            }
994        };
995        if tag != 2 {
996            return false;
997        }
998        at += used + length;
999    }
1000    at == bytes.len()
1001}
1002
1003/// Plain text throughout: a manifest from someone else could otherwise put
1004/// escape sequences on the terminal. Checksums are hexadecimal.
1005fn plain_manifest(manifest: &Manifest) -> bool {
1006    let plain = |text: &str| text.len() <= 1000 && !text.chars().any(char::is_control);
1007    let mut texts: Vec<&str> = vec![
1008        &manifest.helixforge,
1009        &manifest.distribution,
1010        &manifest.family,
1011        &manifest.architecture,
1012    ];
1013    for entry in &manifest.entries {
1014        texts.push(&entry.id);
1015        texts.push(&entry.name);
1016        texts.extend(entry.network.as_deref());
1017        texts.extend(entry.flatpak.as_deref());
1018        texts.extend(entry.packages.iter().map(String::as_str));
1019    }
1020    for file in &manifest.files {
1021        texts.push(&file.path);
1022        texts.extend(file.source.as_deref());
1023        texts.extend(file.assurance.as_deref());
1024    }
1025    let flatpaks = manifest.flatpaks.iter().all(|flatpak| {
1026        plain_flatpak_ref(&flatpak.reference, &flatpak.id)
1027            && flatpak_name(&flatpak.remote)
1028            && flatpak_name(&flatpak.collection)
1029            && flatpak.commit.len() == 64
1030            && flatpak.commit.bytes().all(|byte| byte.is_ascii_hexdigit())
1031            && plain(&flatpak.url)
1032            && !flatpak.url.contains(char::is_whitespace)
1033    });
1034    texts.into_iter().all(plain)
1035        && flatpaks
1036        && manifest.flatpaks.len() <= 1_000
1037        && manifest.files.iter().all(|file| {
1038            file.sha256.len() == 64 && file.sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
1039        })
1040        && manifest.files.len() <= 10_000
1041        && manifest.entries.len() <= 10_000
1042}
1043
1044/// A plan that installs a verified bundle's packages from its files, for the
1045/// entries named (all of the bundle's when none). The files are copied into
1046/// a new private folder and checked there against the checksums of the
1047/// manifest verified here, which are written into the plan itself, so
1048/// nothing changed in the bundle afterwards is installed. Then each
1049/// package's signature is checked (pacman's with pacman-key, rpm's with rpm
1050/// -K), and the package manager installs them from the files.
1051///
1052/// Debian packages carry no signature of their own: each must be known to
1053/// this machine's repository index (signed, and checked by apt) with the
1054/// same checksum, unless `trust` takes the bundle's word for them.
1055pub fn plan_install(
1056    directory: &Path,
1057    manifest: &Manifest,
1058    ids: &[String],
1059    platform: &LinuxInfo,
1060    system: &dyn System,
1061    trust: bool,
1062) -> Result<Plan, BundleError> {
1063    let (manager, _) = family(platform.manager)?;
1064    // Entries share dependencies, so the bundle's packages install together:
1065    // naming some would still install them all, which the plan would not say.
1066    let offline_ids: Vec<&str> = manifest
1067        .entries
1068        .iter()
1069        .filter(|entry| entry.offline)
1070        .map(|entry| entry.id.as_str())
1071        .collect();
1072    if !ids.is_empty()
1073        && !offline_ids
1074            .iter()
1075            .all(|id| ids.iter().any(|wanted| wanted == id))
1076    {
1077        return Err(BundleError::Partial(offline_ids.join(", ")));
1078    }
1079    let chosen: Vec<&BundledEntry> = manifest.entries.iter().collect();
1080    let mut plan = Plan::default();
1081    for entry in &chosen {
1082        if let Some(network) = &entry.network {
1083            plan.notes.push(format!(
1084                "{}: needs the network ({}); run it when online",
1085                printable(&entry.name),
1086                printable(network)
1087            ));
1088        }
1089    }
1090    let offline: Vec<&&BundledEntry> = chosen.iter().filter(|entry| entry.offline).collect();
1091    if offline.is_empty() {
1092        return Ok(plan);
1093    }
1094    let directory =
1095        std::fs::canonicalize(directory).map_err(|error| BundleError::Io(error.to_string()))?;
1096    let packages: Vec<&BundledFile> = manifest
1097        .files
1098        .iter()
1099        .filter(|file| file.path.starts_with("packages/"))
1100        .collect();
1101    let mut requirements: Vec<String> = Vec::new();
1102    if !packages.is_empty() {
1103        let (step, requires) =
1104            native_step(&directory, manager, &packages, &offline, system, trust)?;
1105        plan.steps.push(step);
1106        requirements.extend(requires);
1107    }
1108    if !manifest.flatpaks.is_empty() {
1109        let (step, requires, privileged) = flatpak_step(&directory, manifest, system, trust)?;
1110        plan.steps.push(step);
1111        requirements.extend(requires);
1112        plan.privileged |= privileged;
1113    }
1114    requirements.sort();
1115    requirements.dedup();
1116    plan.requirements = requirements;
1117    plan.privileged |= !packages.is_empty();
1118    plan.risk = Risk::Medium;
1119    plan.depends = vec![Vec::new(); plan.steps.len()];
1120    Ok(plan)
1121}
1122
1123/// The step that installs a bundle's packages from its files.
1124fn native_step(
1125    directory: &Path,
1126    manager: PackageManager,
1127    packages: &[&BundledFile],
1128    offline: &[&&BundledEntry],
1129    system: &dyn System,
1130    trust: bool,
1131) -> Result<(Step, Vec<String>), BundleError> {
1132    let name = |file: &BundledFile| {
1133        Path::new(&file.path)
1134            .file_name()
1135            .map(|name| name.to_string_lossy().into_owned())
1136            .unwrap_or_default()
1137    };
1138    if manager == PackageManager::Debian && !trust {
1139        let mut unknown = Vec::new();
1140        for file in packages.iter().filter(|file| file.path.ends_with(".deb")) {
1141            if !known_to_apt(&directory.join(&file.path), &file.sha256, system) {
1142                unknown.push(name(file));
1143            }
1144        }
1145        if !unknown.is_empty() {
1146            return Err(BundleError::Damaged(format!(
1147                "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",
1148                unknown.join(", ")
1149            )));
1150        }
1151    }
1152    let staged = |file: &BundledFile| format!("\"$hf_staging\"/{}", quote(&name(file)));
1153    let mut lines = vec![
1154        "hf_staging=$(mktemp -d) || exit 1".to_string(),
1155        "trap 'rm -rf \"$hf_staging\"' EXIT".to_string(),
1156    ];
1157    for file in packages {
1158        lines.push(format!(
1159            "cp -- {} \"$hf_staging\"/ || exit 1",
1160            quote(&directory.join(&file.path).display().to_string())
1161        ));
1162    }
1163    // The checksums verified here, not the bundle's own file.
1164    let sums: Vec<String> = packages
1165        .iter()
1166        .map(|file| quote(&format!("{}  {}", file.sha256, name(file))))
1167        .collect();
1168    lines.push(format!(
1169        "( cd \"$hf_staging\" && printf '%s\\n' {} | sha256sum --quiet --check - ) || {{ echo 'HelixForge: the bundle changed after it was checked; nothing was installed' >&2; exit 1; }}",
1170        sums.join(" ")
1171    ));
1172    let installable: Vec<&&BundledFile> = packages
1173        .iter()
1174        .filter(|file| !file.path.ends_with(".sig"))
1175        .collect();
1176    let mut requires = vec![
1177        "sha256sum".to_string(),
1178        "sudo".to_string(),
1179        "mktemp".to_string(),
1180    ];
1181    match manager {
1182        PackageManager::Arch => {
1183            for file in &installable {
1184                lines.push(format!(
1185                    "sudo pacman-key --verify {}.sig {} || exit 1",
1186                    staged(file),
1187                    staged(file)
1188                ));
1189            }
1190            requires.push("pacman-key".to_string());
1191        }
1192        PackageManager::Fedora | PackageManager::Suse => {
1193            let files: Vec<String> = installable.iter().map(|file| staged(file)).collect();
1194            lines.push(format!("rpm -K {} || exit 1", files.join(" ")));
1195            requires.push("rpm".to_string());
1196        }
1197        _ => {}
1198    }
1199    let files: Vec<String> = installable.iter().map(|file| staged(file)).collect();
1200    let (install, tool) = match manager {
1201        PackageManager::Arch => ("sudo pacman -U --needed", "pacman"),
1202        // apt's --no-download refuses the local files too; with everything
1203        // in the bundle, nothing is downloaded.
1204        PackageManager::Debian => ("sudo apt-get install", "apt-get"),
1205        // The remote repositories set aside, as dnf's are.
1206        PackageManager::Suse => ("sudo zypper --no-refresh --no-remote install", "zypper"),
1207        _ => ("sudo dnf install --disablerepo='*'", "dnf"),
1208    };
1209    if manager == PackageManager::Suse {
1210        // zypper reads its answer only from a terminal; with none attached,
1211        // the confirmation already given stands, and its defaults refuse
1212        // anything unsigned.
1213        lines
1214            .push("if [ -t 0 ]; then hf_answer=; else hf_answer=--non-interactive; fi".to_string());
1215        lines.push(format!(
1216            "sudo zypper $hf_answer --no-refresh --no-remote install {}",
1217            files.join(" ")
1218        ));
1219    } else {
1220        lines.push(format!("{install} {}", files.join(" ")));
1221    }
1222    requires.push(tool.to_string());
1223    let names: Vec<String> = offline
1224        .iter()
1225        .filter(|entry| entry.flatpak.is_none())
1226        .map(|entry| printable(&entry.name))
1227        .collect();
1228    let shown: Vec<String> = installable.iter().map(|file| name(file)).collect();
1229    let step = Step::Shell {
1230        id: "bundle-install".to_string(),
1231        name: format!("Install {} from the bundle", names.join(", ")),
1232        program: lines.join("\n"),
1233        display: printable(&format!(
1234            "copy the packages from {} to a private folder, check them against the verified checksums{}, then {install} {}",
1235            directory.display(),
1236            match manager {
1237                PackageManager::Arch => " and their signatures (pacman-key --verify)",
1238                PackageManager::Fedora | PackageManager::Suse => {
1239                    " and their signatures (rpm -K)"
1240                }
1241                _ => "",
1242            },
1243            shown.join(" ")
1244        )),
1245        requires: requires.clone(),
1246        changes: Vec::new(),
1247    };
1248    Ok((step, requires))
1249}
1250
1251/// The step that installs a bundle's Flatpaks from its copy of their
1252/// repository. flatpak checks each commit's signature against the remote's
1253/// key on this machine. A remote this machine lacks is added with the
1254/// bundle's copy of its key, which only `trust` allows, as nothing here
1255/// vouches for that key; one without a collection ID is given the bundle's.
1256fn flatpak_step(
1257    directory: &Path,
1258    manifest: &Manifest,
1259    system: &dyn System,
1260    trust: bool,
1261) -> Result<(Step, Vec<String>, bool), BundleError> {
1262    let repository = quote(&directory.join(FLATPAKS).display().to_string());
1263    let mut lines: Vec<String> = Vec::new();
1264    let mut shown: Vec<String> = Vec::new();
1265    let mut requires = vec!["flatpak".to_string()];
1266    let mut privileged = false;
1267    for user in [false, true] {
1268        let group: Vec<&BundledFlatpak> = manifest
1269            .flatpaks
1270            .iter()
1271            .filter(|flatpak| flatpak.user == user)
1272            .collect();
1273        if group.is_empty() {
1274            continue;
1275        }
1276        let sudo = if user { "" } else { "sudo " };
1277        privileged |= !user;
1278        let here = flatpak_remotes(system, user);
1279        let mut remotes: Vec<&BundledFlatpak> = Vec::new();
1280        for flatpak in &group {
1281            if !remotes.iter().any(|seen| seen.remote == flatpak.remote) {
1282                remotes.push(flatpak);
1283            }
1284        }
1285        for flatpak in remotes {
1286            let remote = &flatpak.remote;
1287            let collection = &flatpak.collection;
1288            match here.iter().find(|(name, _, _)| name == remote) {
1289                None if !trust => {
1290                    return Err(BundleError::Untrusted(format!(
1291                        "this machine has no {remote} Flatpak remote, so only the bundle's copy of its key would vouch for the Flatpaks; add the remote first, or install with --trust-bundle only if you made the bundle yourself"
1292                    )));
1293                }
1294                None => {
1295                    let key = manifest
1296                        .files
1297                        .iter()
1298                        .find(|file| file.path == key_path(remote, user))
1299                        .ok_or_else(|| {
1300                            BundleError::Damaged(format!("the {remote} remote's key is missing"))
1301                        })?;
1302                    // The checksum verified here, not the bundle's own file.
1303                    lines.push(format!(
1304                        "hf_key=$(mktemp) && cp -- {} \"$hf_key\" && printf '%s  %s\\n' {} \"$hf_key\" | sha256sum --quiet --check - || {{ echo 'HelixForge: the bundle changed after it was checked; nothing was installed' >&2; exit 1; }}",
1305                        quote(&directory.join(&key.path).display().to_string()),
1306                        key.sha256
1307                    ));
1308                    lines.push(format!(
1309                        "{sudo}flatpak remote-add {} --gpg-import=\"$hf_key\" --collection-id={collection} {remote} {} || exit 1\nrm -f \"$hf_key\"",
1310                        scope(user),
1311                        quote(&flatpak.url)
1312                    ));
1313                    shown.push(format!(
1314                        "add the {remote} remote ({}) with the bundle's copy of its key",
1315                        flatpak.url
1316                    ));
1317                    requires.extend(["mktemp".to_string(), "sha256sum".to_string()]);
1318                }
1319                Some((_, address, here))
1320                    if here.is_empty()
1321                        && !trust
1322                        && published_collection(address) != Some(collection.as_str()) =>
1323                {
1324                    return Err(BundleError::Untrusted(format!(
1325                        "this machine's {remote} Flatpak remote has no collection ID, and {} is not the one published for {}; give the remote its own, or install with --trust-bundle only if you made the bundle yourself",
1326                        printable(collection),
1327                        printable(address)
1328                    )));
1329                }
1330                Some((_, _, here)) if here.is_empty() => {
1331                    lines.push(format!(
1332                        "{sudo}flatpak remote-modify {} --collection-id={collection} {remote} || exit 1",
1333                        scope(user)
1334                    ));
1335                    shown.push(format!(
1336                        "give the {remote} remote its collection ID, {collection}"
1337                    ));
1338                }
1339                Some((_, _, here)) if here != collection => {
1340                    return Err(BundleError::Untrusted(format!(
1341                        "this machine's {remote} Flatpak remote has the collection ID {}, and the bundle's Flatpaks {collection}",
1342                        printable(here)
1343                    )));
1344                }
1345                Some(_) => {}
1346            }
1347            let ids: Vec<&str> = group
1348                .iter()
1349                .filter(|other| other.remote == *remote)
1350                .map(|other| other.id.as_str())
1351                .collect();
1352            let install = format!(
1353                "{sudo}flatpak install {} --noninteractive --sideload-repo={repository} {remote} {}",
1354                scope(user),
1355                ids.join(" ")
1356            );
1357            lines.push(format!("{install} || exit 1"));
1358            shown.push(install);
1359        }
1360    }
1361    if privileged {
1362        requires.push("sudo".to_string());
1363    }
1364    let names: Vec<String> = manifest
1365        .entries
1366        .iter()
1367        .filter(|entry| entry.flatpak.is_some())
1368        .map(|entry| printable(&entry.name))
1369        .collect();
1370    let step = Step::Shell {
1371        id: "bundle-flatpak".to_string(),
1372        name: format!("Install {} from the bundle's Flatpaks", names.join(", ")),
1373        program: lines.join("\n"),
1374        display: printable(&format!(
1375            "{}, from the bundle's copy of the repository; flatpak checks each commit's signature against the remote's key",
1376            shown.join("; then ")
1377        )),
1378        requires: requires.clone(),
1379        changes: Vec::new(),
1380    };
1381    Ok((step, requires, privileged))
1382}
1383
1384/// Whether this machine's apt index lists the package in this file, at its
1385/// version, with this checksum: `dpkg-deb` reads its name and version, and
1386/// `apt-cache show` what the signed index says.
1387fn known_to_apt(file: &Path, sha256: &str, system: &dyn System) -> bool {
1388    let path = file.display().to_string();
1389    let Some((true, fields)) = system.run(&["dpkg-deb", "--field", &path, "Package", "Version"])
1390    else {
1391        return false;
1392    };
1393    let field = |key: &str| {
1394        fields.lines().find_map(|line| {
1395            let (name, value) = line.split_once(':')?;
1396            (name.trim() == key).then(|| value.trim().to_string())
1397        })
1398    };
1399    let (Some(package), Some(version)) = (field("Package"), field("Version")) else {
1400        return false;
1401    };
1402    if !valid_package_name(&package) || version.chars().any(|c| c.is_whitespace() || c.is_control())
1403    {
1404        return false;
1405    }
1406    let Some((true, shown)) = system.run(&["apt-cache", "show", &format!("{package}={version}")])
1407    else {
1408        return false;
1409    };
1410    shown
1411        .lines()
1412        .filter_map(|line| line.strip_prefix("SHA256:"))
1413        .any(|value| value.trim().eq_ignore_ascii_case(sha256))
1414}
1415
1416#[cfg(test)]
1417mod tests {
1418    use super::*;
1419    use std::collections::HashMap;
1420
1421    #[derive(Default)]
1422    struct Fake {
1423        commands: HashMap<String, String>,
1424    }
1425
1426    impl System for Fake {
1427        fn read(&self, _: &Path) -> Option<String> {
1428            None
1429        }
1430        fn exists(&self, _: &Path) -> bool {
1431            false
1432        }
1433        fn list(&self, _: &Path) -> Vec<String> {
1434            Vec::new()
1435        }
1436        fn link(&self, _: &Path) -> Option<PathBuf> {
1437            None
1438        }
1439        fn run(&self, command: &[&str]) -> Option<(bool, String)> {
1440            self.commands
1441                .get(&command.join(" "))
1442                .map(|text| (true, text.clone()))
1443        }
1444        fn resolve(&self, _: &str) -> Result<Vec<String>, String> {
1445            Err("offline".into())
1446        }
1447        fn space(&self, _: &Path) -> Option<(u64, u64)> {
1448            None
1449        }
1450        fn owner(&self, _: &Path) -> Option<u32> {
1451            None
1452        }
1453        fn is_link(&self, _: &Path) -> bool {
1454            false
1455        }
1456        fn canonical(&self, _: &Path) -> Option<PathBuf> {
1457            None
1458        }
1459        fn user(&self) -> (u32, u32, PathBuf) {
1460            (1000, 1000, PathBuf::from("/home/me"))
1461        }
1462        fn on_path(&self, _: &str) -> bool {
1463            true
1464        }
1465        fn processes(&self) -> Vec<String> {
1466            Vec::new()
1467        }
1468    }
1469
1470    fn platform(manager: PackageManager, id: &str) -> LinuxInfo {
1471        LinuxInfo {
1472            id: id.into(),
1473            pretty_name: format!("{id} Linux"),
1474            id_like: vec![],
1475            manager: Some(manager),
1476        }
1477    }
1478
1479    /// The smallest detached signature the checks accept: one signature
1480    /// packet, new format, one byte long.
1481    const SIGNATURE: &[u8] = &[0xc2, 0x01, 0x04];
1482
1483    #[test]
1484    fn only_bare_signature_packets_count_as_a_detached_signature() {
1485        assert!(detached_signature(SIGNATURE));
1486        // Old format, tag 2, one-byte length.
1487        assert!(detached_signature(&[0x88, 0x01, 0x04]));
1488        // Two signatures in a row.
1489        assert!(detached_signature(&[0xc2, 0x01, 0x04, 0x88, 0x01, 0x04]));
1490        // A literal data packet (tag 11), a one-pass signature (tag 4), a
1491        // compressed packet (tag 8), armour, a short or empty file: no.
1492        for bad in [
1493            &[0xcb, 0x01, 0x00][..],
1494            &[0xc4, 0x01, 0x00],
1495            &[0xc8, 0x01, 0x00],
1496            b"-----BEGIN PGP SIGNATURE-----",
1497            &[0xc2, 0x05, 0x04],
1498            &[],
1499            &[0xc2, 0x01, 0x04, 0xcb, 0x01, 0x00],
1500        ] {
1501            assert!(!detached_signature(bad), "{bad:?}");
1502        }
1503    }
1504
1505    /// Downloads served from a map of URL to contents.
1506    fn served(files: HashMap<String, Vec<u8>>) -> impl Fn(&str, &Path) -> bool {
1507        move |url: &str, to: &Path| match files.get(url) {
1508            Some(bytes) => std::fs::write(to, bytes).is_ok(),
1509            None => false,
1510        }
1511    }
1512
1513    #[test]
1514    fn an_arch_bundle_holds_packages_and_signatures_and_installs_offline() {
1515        let catalog = Catalog::builtin(false).unwrap();
1516        let arch = platform(PackageManager::Arch, "arch");
1517        let scratch = tempfile::tempdir().unwrap();
1518        let scratch_cache = scratch.path().join("cache");
1519        let mut fake = Fake::default();
1520        fake.commands.insert(
1521            format!("pacman -Sp --cachedir {} --print-format %f %l htop", scratch_cache.display()),
1522            "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(),
1523        );
1524        let ids = vec![
1525            "htop".to_string(),
1526            "steam".to_string(),
1527            "heroic".to_string(),
1528        ];
1529        let found = contents(&ids, &catalog, &arch, false, &fake, scratch.path()).unwrap();
1530        assert_eq!(found.downloads.len(), 2, "the package and its signature");
1531        assert!(found.entries[0].offline);
1532        assert!(!found.entries[1].offline);
1533        assert!(
1534            found.entries[1]
1535                .network
1536                .as_ref()
1537                .unwrap()
1538                .contains("script")
1539        );
1540        let url = "https://mirror.example/extra/os/x86_64/htop-3.5.3-1-x86_64.pkg.tar.zst";
1541        let download = served(HashMap::from([
1542            (url.to_string(), b"package".to_vec()),
1543            (format!("{url}.sig"), SIGNATURE.to_vec()),
1544        ]));
1545        let tools = Tools {
1546            system: &fake,
1547            download: &download,
1548            dnf_download: &|_, _, _| false,
1549            zypper_download: &|_, _, _| false,
1550            create_usb: &|_, _, _| false,
1551            flatpak_key: &|_, _| PathBuf::new(),
1552        };
1553        let program = scratch.path().join("program");
1554        std::fs::write(&program, b"helixforge").unwrap();
1555        let bundle = scratch.path().join("bundle");
1556        let made = create(
1557            &bundle,
1558            &found,
1559            &arch,
1560            false,
1561            Some((&program, "1.5.0", true)),
1562            1,
1563            &tools,
1564        )
1565        .unwrap();
1566        assert_eq!(made.files.len(), 3);
1567        assert!(bundle.join(SUMS).is_file());
1568        // A second bundle into the same folder is refused.
1569        assert!(matches!(
1570            create(&bundle, &found, &arch, false, None, 1, &tools),
1571            Err(BundleError::NotEmpty(_))
1572        ));
1573        let read = verify(&bundle, &arch).unwrap();
1574        assert_eq!(read, made);
1575        let planned = plan_install(&bundle, &read, &[], &arch, &fake, false).unwrap();
1576        assert_eq!(planned.steps.len(), 1);
1577        let shown = planned.steps[0].display();
1578        assert!(shown.contains("pacman-key --verify"), "{shown}");
1579        assert!(
1580            shown.contains("sudo pacman -U --needed htop-3.5.3-1-x86_64.pkg.tar.zst"),
1581            "{shown}"
1582        );
1583        let Step::Shell { program, .. } = &planned.steps[0] else {
1584            panic!("a shell step");
1585        };
1586        // The checksums are the verified manifest's, written into the plan.
1587        assert!(program.contains(&read.files[0].sha256), "{program}");
1588        assert!(program.contains("sha256sum --quiet --check -"), "{program}");
1589        assert!(!program.contains("SHA256SUMS"), "{program}");
1590        assert!(program.contains("pacman-key --verify"), "{program}");
1591        assert!(
1592            planned
1593                .notes
1594                .iter()
1595                .any(|note| note.contains("Steam") && note.contains("network"))
1596        );
1597        // Naming only some of the bundle's offline entries is refused.
1598        assert!(matches!(
1599            plan_install(&bundle, &read, &["steam".to_string()], &arch, &fake, false),
1600            Err(BundleError::Partial(_))
1601        ));
1602        // Another distribution, or a damaged file, is refused.
1603        assert!(matches!(
1604            verify(&bundle, &platform(PackageManager::Debian, "debian")),
1605            Err(BundleError::Machine(..))
1606        ));
1607        std::fs::write(
1608            bundle.join("packages/htop-3.5.3-1-x86_64.pkg.tar.zst"),
1609            b"tampered",
1610        )
1611        .unwrap();
1612        assert!(matches!(
1613            verify(&bundle, &arch),
1614            Err(BundleError::Damaged(_))
1615        ));
1616    }
1617
1618    /// A bundle of one Arch package, made with stand-in downloads.
1619    fn arch_bundle(at: &Path) -> (Manifest, LinuxInfo, Fake) {
1620        let catalog = Catalog::builtin(false).unwrap();
1621        let arch = platform(PackageManager::Arch, "arch");
1622        let scratch_cache = at.join("cache");
1623        let mut fake = Fake::default();
1624        fake.commands.insert(
1625            format!(
1626                "pacman -Sp --cachedir {} --print-format %f %l htop",
1627                scratch_cache.display()
1628            ),
1629            "htop-1-1-x86_64.pkg.tar.zst https://mirror.example/htop-1-1-x86_64.pkg.tar.zst\n"
1630                .into(),
1631        );
1632        let found = contents(&["htop".to_string()], &catalog, &arch, false, &fake, at).unwrap();
1633        let download = served(HashMap::from([
1634            (
1635                "https://mirror.example/htop-1-1-x86_64.pkg.tar.zst".to_string(),
1636                b"package".to_vec(),
1637            ),
1638            (
1639                "https://mirror.example/htop-1-1-x86_64.pkg.tar.zst.sig".to_string(),
1640                SIGNATURE.to_vec(),
1641            ),
1642        ]));
1643        let tools = Tools {
1644            system: &fake,
1645            download: &download,
1646            dnf_download: &|_, _, _| false,
1647            zypper_download: &|_, _, _| false,
1648            create_usb: &|_, _, _| false,
1649            flatpak_key: &|_, _| PathBuf::new(),
1650        };
1651        let made = create(&at.join("bundle"), &found, &arch, false, None, 1, &tools).unwrap();
1652        (made, arch, fake)
1653    }
1654
1655    #[test]
1656    fn a_file_changed_after_the_check_is_never_installed() {
1657        let scratch = tempfile::tempdir().unwrap();
1658        let (_, arch, fake) = arch_bundle(scratch.path());
1659        let bundle = scratch.path().join("bundle");
1660        let manifest = verify(&bundle, &arch).unwrap();
1661        let planned = plan_install(&bundle, &manifest, &[], &arch, &fake, false).unwrap();
1662        let Step::Shell { program, .. } = &planned.steps[0] else {
1663            panic!("a shell step");
1664        };
1665        // Stand-ins: sudo runs its command, pacman-key accepts, pacman records.
1666        let bin = scratch.path().join("bin");
1667        std::fs::create_dir_all(&bin).unwrap();
1668        let log = scratch.path().join("pacman.log");
1669        for (tool, body) in [
1670            ("sudo", "exec \"$@\"".to_string()),
1671            ("pacman-key", "exit 0".to_string()),
1672            ("pacman", format!("echo \"$@\" >> {}", log.display())),
1673        ] {
1674            let path = bin.join(tool);
1675            std::fs::write(&path, format!("#!/bin/sh\n{body}\n")).unwrap();
1676            std::fs::set_permissions(&path, std::os::unix::fs::PermissionsExt::from_mode(0o755))
1677                .unwrap();
1678        }
1679        let run = || {
1680            std::process::Command::new("sh")
1681                .arg("-c")
1682                .arg(program)
1683                .env(
1684                    "PATH",
1685                    format!("{}:{}", bin.display(), std::env::var("PATH").unwrap()),
1686                )
1687                .output()
1688                .unwrap()
1689        };
1690        let output = run();
1691        assert!(
1692            output.status.success(),
1693            "{}",
1694            String::from_utf8_lossy(&output.stderr)
1695        );
1696        assert!(
1697            std::fs::read_to_string(&log)
1698                .unwrap()
1699                .contains("-U --needed")
1700        );
1701        std::fs::remove_file(&log).unwrap();
1702        // Changed after the plan was made, the bundle's own sums along with it.
1703        std::fs::write(bundle.join("packages/htop-1-1-x86_64.pkg.tar.zst"), b"evil").unwrap();
1704        std::fs::write(bundle.join(SUMS), "").unwrap();
1705        let output = run();
1706        assert!(!output.status.success());
1707        assert!(
1708            String::from_utf8_lossy(&output.stderr)
1709                .contains("the bundle changed after it was checked")
1710        );
1711        assert!(!log.exists(), "pacman never ran");
1712    }
1713
1714    #[test]
1715    fn unsigned_or_unprintable_manifests_are_refused() {
1716        let scratch = tempfile::tempdir().unwrap();
1717        let (made, arch, _) = arch_bundle(scratch.path());
1718        let bundle = scratch.path().join("bundle");
1719        let write = |manifest: &Manifest| {
1720            std::fs::write(bundle.join(MANIFEST), toml::to_string(manifest).unwrap()).unwrap()
1721        };
1722        let mut unsigned = made.clone();
1723        unsigned.files.retain(|file| !file.path.ends_with(".sig"));
1724        write(&unsigned);
1725        let error = verify(&bundle, &arch).unwrap_err().to_string();
1726        assert!(error.contains("no signature"), "{error}");
1727        let mut evil = made.clone();
1728        evil.entries[0].network = Some("\u{1b}]52;c;x\u{7}".into());
1729        write(&evil);
1730        let error = verify(&bundle, &arch).unwrap_err().to_string();
1731        assert!(error.contains("control characters"), "{error}");
1732        assert!(!error.chars().any(char::is_control));
1733        write(&made);
1734        assert!(verify(&bundle, &arch).is_ok());
1735    }
1736
1737    #[test]
1738    fn debian_downloads_are_checked_against_the_repository_index() {
1739        let catalog = Catalog::builtin(false).unwrap();
1740        let debian = platform(PackageManager::Debian, "debian");
1741        let good = hex(&Sha256::digest(b"deb"));
1742        let mut fake = Fake::default();
1743        fake.commands.insert(
1744            "apt-get --print-uris -qq --reinstall install htop".into(),
1745            format!("'http://deb.example/pool/h/htop_3.2_amd64.deb' htop_3.2_amd64.deb 3 SHA256:{good}\n"),
1746        );
1747        let scratch = tempfile::tempdir().unwrap();
1748        let found = contents(
1749            &["htop".to_string()],
1750            &catalog,
1751            &debian,
1752            false,
1753            &fake,
1754            scratch.path(),
1755        )
1756        .unwrap();
1757        assert_eq!(
1758            found.downloads[0].checksum.as_deref(),
1759            Some(format!("sha256:{good}").as_str())
1760        );
1761        let honest = served(HashMap::from([(
1762            "http://deb.example/pool/h/htop_3.2_amd64.deb".to_string(),
1763            b"deb".to_vec(),
1764        )]));
1765        let tools = Tools {
1766            system: &fake,
1767            download: &honest,
1768            dnf_download: &|_, _, _| false,
1769            zypper_download: &|_, _, _| false,
1770            create_usb: &|_, _, _| false,
1771            flatpak_key: &|_, _| PathBuf::new(),
1772        };
1773        let bundle = scratch.path().join("good");
1774        create(&bundle, &found, &debian, false, None, 1, &tools).unwrap();
1775        let manifest = verify(&bundle, &debian).unwrap();
1776        // Unknown to this machine's index: refused, unless the bundle is trusted.
1777        let refused = plan_install(&bundle, &manifest, &[], &debian, &fake, false).unwrap_err();
1778        assert!(refused.to_string().contains("--trust-bundle"), "{refused}");
1779        let planned = plan_install(&bundle, &manifest, &[], &debian, &fake, true).unwrap();
1780        assert!(
1781            planned.steps[0]
1782                .display()
1783                .contains("sudo apt-get install htop_3.2_amd64.deb")
1784        );
1785        // Known to the index with the same checksum: accepted.
1786        let file = std::fs::canonicalize(bundle.join("packages/htop_3.2_amd64.deb")).unwrap();
1787        fake.commands.insert(
1788            format!("dpkg-deb --field {} Package Version", file.display()),
1789            "Package: htop\nVersion: 3.2\n".into(),
1790        );
1791        fake.commands.insert(
1792            "apt-cache show htop=3.2".into(),
1793            format!("Package: htop\nVersion: 3.2\nSHA256: {good}\n"),
1794        );
1795        assert!(plan_install(&bundle, &manifest, &[], &debian, &fake, false).is_ok());
1796        // A download that differs from the signed index is deleted, not kept.
1797        let lying = served(HashMap::from([(
1798            "http://deb.example/pool/h/htop_3.2_amd64.deb".to_string(),
1799            b"evil".to_vec(),
1800        )]));
1801        let tools = Tools {
1802            system: &fake,
1803            download: &lying,
1804            dnf_download: &|_, _, _| false,
1805            zypper_download: &|_, _, _| false,
1806            create_usb: &|_, _, _| false,
1807            flatpak_key: &|_, _| PathBuf::new(),
1808        };
1809        let bad = scratch.path().join("bad");
1810        assert!(matches!(
1811            create(&bad, &found, &debian, false, None, 1, &tools),
1812            Err(BundleError::Mismatch(_))
1813        ));
1814        assert!(!bad.join("packages/htop_3.2_amd64.deb").exists());
1815    }
1816
1817    #[test]
1818    fn manifests_cannot_reach_outside_the_bundle() {
1819        let arch = platform(PackageManager::Arch, "arch");
1820        let directory = tempfile::tempdir().unwrap();
1821        let manifest = Manifest {
1822            format: FORMAT,
1823            family: "arch".into(),
1824            architecture: std::env::consts::ARCH.into(),
1825            files: vec![BundledFile {
1826                path: "../../etc/passwd".into(),
1827                ..BundledFile::default()
1828            }],
1829            ..Manifest::default()
1830        };
1831        std::fs::write(
1832            directory.path().join(MANIFEST),
1833            toml::to_string(&manifest).unwrap(),
1834        )
1835        .unwrap();
1836        assert!(matches!(
1837            verify(directory.path(), &arch),
1838            Err(BundleError::Damaged(_))
1839        ));
1840        assert!(!plain_file("../x"));
1841        assert!(!plain_file("a b"));
1842        assert!(plain_file("libstdc++-16.2.1+r23-1-x86_64.pkg.tar.zst"));
1843        assert!(plain_file("zlib-1:1.3.2-4-x86_64.pkg.tar.zst"));
1844        assert!(matches!(
1845            contents(
1846                &[],
1847                &Catalog::builtin(false).unwrap(),
1848                &platform(PackageManager::Alpine, "alpine"),
1849                false,
1850                &Fake::default(),
1851                directory.path()
1852            ),
1853            Err(BundleError::Unsupported(_))
1854        ));
1855    }
1856
1857    /// What `flatpak` says on a machine with Flycast installed from Flathub.
1858    fn flatpak_machine(collection: &str) -> Fake {
1859        let mut fake = Fake::default();
1860        fake.commands.insert(
1861            "flatpak list --app --columns=application,ref,origin,installation".into(),
1862            "org.flycast.Flycast\torg.flycast.Flycast/x86_64/stable\tflathub\tsystem\n".into(),
1863        );
1864        fake.commands.insert(
1865            "flatpak remotes --system --columns=name,url,collection".into(),
1866            format!("flathub\thttps://dl.flathub.org/repo/\t{collection}\n"),
1867        );
1868        fake.commands.insert(
1869            "flatpak info --system --show-commit app/org.flycast.Flycast/x86_64/stable".into(),
1870            format!("{}\n", "c".repeat(64)),
1871        );
1872        fake
1873    }
1874
1875    #[test]
1876    fn installed_flatpaks_are_copied_and_installed_from_the_copy() {
1877        let catalog = Catalog::builtin(false).unwrap();
1878        let arch = platform(PackageManager::Arch, "arch");
1879        let scratch = tempfile::tempdir().unwrap();
1880        // Without a collection ID, create-usb cannot copy it: say how to set one.
1881        let error = contents(
1882            &["flycast".to_string()],
1883            &catalog,
1884            &arch,
1885            false,
1886            &flatpak_machine("-"),
1887            scratch.path(),
1888        )
1889        .unwrap_err()
1890        .to_string();
1891        assert!(
1892            error.contains("sudo flatpak remote-modify --collection-id=org.flathub.Stable flathub"),
1893            "{error}"
1894        );
1895        let fake = flatpak_machine("org.flathub.Stable");
1896        let found = contents(
1897            &["flycast".to_string(), "zoom".to_string()],
1898            &catalog,
1899            &arch,
1900            false,
1901            &fake,
1902            scratch.path(),
1903        )
1904        .unwrap();
1905        assert_eq!(found.flatpaks.len(), 1);
1906        assert_eq!(
1907            found.flatpaks[0].reference,
1908            "app/org.flycast.Flycast/x86_64/stable"
1909        );
1910        assert!(found.entries[0].offline);
1911        assert_eq!(
1912            found.entries[0].flatpak.as_deref(),
1913            Some("org.flycast.Flycast")
1914        );
1915        // Zoom is not installed here: it needs the network, and says how not to.
1916        assert!(!found.entries[1].offline);
1917        assert!(
1918            found.entries[1]
1919                .network
1920                .as_ref()
1921                .unwrap()
1922                .contains("its Flatpak can be bundled once installed here")
1923        );
1924        let key = scratch.path().join("flathub.trustedkeys.gpg");
1925        std::fs::write(&key, b"key").unwrap();
1926        let commit = "c".repeat(64);
1927        let copied = |refs: &[String], user: bool, bundle: &Path| {
1928            assert!(!user);
1929            for reference in refs {
1930                let path = bundle
1931                    .join(FLATPAKS)
1932                    .join("refs/mirrors/org.flathub.Stable")
1933                    .join(reference);
1934                std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1935                std::fs::write(path, format!("{commit}\n")).unwrap();
1936            }
1937            true
1938        };
1939        let tools = Tools {
1940            system: &fake,
1941            download: &|_, _| false,
1942            dnf_download: &|_, _, _| false,
1943            zypper_download: &|_, _, _| false,
1944            create_usb: &copied,
1945            flatpak_key: &|remote, _| {
1946                assert_eq!(remote, "flathub");
1947                key.clone()
1948            },
1949        };
1950        let bundle = scratch.path().join("bundle");
1951        let made = create(&bundle, &found, &arch, false, None, 1, &tools).unwrap();
1952        assert_eq!(made.format, 2);
1953        assert_eq!(made.files.len(), 1, "the remote's key");
1954        assert_eq!(made.files[0].path, "flatpak-keys/flathub.gpg");
1955        let read = verify(&bundle, &arch).unwrap();
1956        assert_eq!(read, made);
1957
1958        // A machine with the remote: installed from the copy, nothing added.
1959        let planned = plan_install(&bundle, &read, &[], &arch, &fake, false).unwrap();
1960        assert_eq!(planned.steps.len(), 1);
1961        let Step::Shell { program, id, .. } = &planned.steps[0] else {
1962            panic!("a shell step");
1963        };
1964        assert_eq!(id, "bundle-flatpak");
1965        let repository = std::fs::canonicalize(&bundle).unwrap().join("flatpak");
1966        assert!(
1967            program.contains(&format!(
1968                "sudo flatpak install --system --noninteractive --sideload-repo='{}' flathub org.flycast.Flycast",
1969                repository.display()
1970            )),
1971            "{program}"
1972        );
1973        assert!(!program.contains("remote-add"), "{program}");
1974        assert!(planned.privileged);
1975        assert!(planned.requirements.contains(&"flatpak".to_string()));
1976
1977        // Its remote without a collection ID: given the bundle's, which is
1978        // the one Flathub publishes for its address.
1979        let planned =
1980            plan_install(&bundle, &read, &[], &arch, &flatpak_machine("-"), false).unwrap();
1981        assert!(
1982            planned.steps[0]
1983                .display()
1984                .contains("give the flathub remote its collection ID, org.flathub.Stable")
1985        );
1986
1987        // At another address, no collection ID is given on the bundle's word.
1988        let mut elsewhere = flatpak_machine("-");
1989        elsewhere.commands.insert(
1990            "flatpak remotes --system --columns=name,url,collection".into(),
1991            "flathub\thttps://mirror.example/flathub/\t-\n".into(),
1992        );
1993        assert!(matches!(
1994            plan_install(&bundle, &read, &[], &arch, &elsewhere, false),
1995            Err(BundleError::Untrusted(_))
1996        ));
1997        assert!(plan_install(&bundle, &read, &[], &arch, &elsewhere, true).is_ok());
1998
1999        // No remote: only the bundle's key would vouch, so only on trust.
2000        let bare = Fake::default();
2001        assert!(matches!(
2002            plan_install(&bundle, &read, &[], &arch, &bare, false),
2003            Err(BundleError::Untrusted(_))
2004        ));
2005        let planned = plan_install(&bundle, &read, &[], &arch, &bare, true).unwrap();
2006        let Step::Shell { program, .. } = &planned.steps[0] else {
2007            panic!("a shell step");
2008        };
2009        assert!(program.contains(&read.files[0].sha256), "{program}");
2010        assert!(
2011            program.contains("sudo flatpak remote-add --system --gpg-import=\"$hf_key\" --collection-id=org.flathub.Stable flathub 'https://dl.flathub.org/repo/'"),
2012            "{program}"
2013        );
2014
2015        // The copy at another commit, or no copy at all, is refused.
2016        let mirrored = bundle
2017            .join("flatpak/refs/mirrors/org.flathub.Stable/app/org.flycast.Flycast/x86_64/stable");
2018        std::fs::write(&mirrored, "d".repeat(64)).unwrap();
2019        assert!(matches!(
2020            verify(&bundle, &arch),
2021            Err(BundleError::Damaged(_))
2022        ));
2023        std::fs::remove_file(&mirrored).unwrap();
2024        assert!(matches!(
2025            verify(&bundle, &arch),
2026            Err(BundleError::Damaged(_))
2027        ));
2028    }
2029
2030    #[test]
2031    fn flatpak_records_are_plain() {
2032        let flatpak = BundledFlatpak {
2033            id: "org.example.App".into(),
2034            reference: "app/org.example.App/x86_64/stable".into(),
2035            commit: "a".repeat(64),
2036            remote: "flathub".into(),
2037            url: "https://dl.flathub.org/repo/".into(),
2038            collection: "org.flathub.Stable".into(),
2039            user: false,
2040        };
2041        let mut manifest = Manifest {
2042            flatpaks: vec![flatpak.clone()],
2043            ..Manifest::default()
2044        };
2045        assert!(plain_manifest(&manifest));
2046        for bad in [
2047            BundledFlatpak {
2048                reference: "app/other.App/x86_64/stable".into(),
2049                ..flatpak.clone()
2050            },
2051            BundledFlatpak {
2052                reference: "runtime/org.example.App/x86_64/stable".into(),
2053                ..flatpak.clone()
2054            },
2055            BundledFlatpak {
2056                remote: "flathub; rm -rf ~".into(),
2057                ..flatpak.clone()
2058            },
2059            BundledFlatpak {
2060                collection: "../x".into(),
2061                ..flatpak.clone()
2062            },
2063            BundledFlatpak {
2064                commit: "short".into(),
2065                ..flatpak.clone()
2066            },
2067            BundledFlatpak {
2068                url: "https://x/ y".into(),
2069                ..flatpak.clone()
2070            },
2071        ] {
2072            manifest.flatpaks = vec![bad.clone()];
2073            assert!(!plain_manifest(&manifest), "{bad:?}");
2074        }
2075    }
2076
2077    #[test]
2078    fn zypper_downloads_without_root_and_installs_from_the_files() {
2079        let catalog = Catalog::builtin(false).unwrap();
2080        let suse = platform(PackageManager::Suse, "opensuse-tumbleweed");
2081        let scratch = tempfile::tempdir().unwrap();
2082        let found = contents(
2083            &["htop".to_string()],
2084            &catalog,
2085            &suse,
2086            true,
2087            &Fake::default(),
2088            scratch.path(),
2089        )
2090        .unwrap();
2091        assert_eq!(found.zypper, ["htop"]);
2092        assert!(found.downloads.is_empty() && found.dnf.is_empty());
2093        let downloaded = |names: &[String], complete: bool, into: &Path| {
2094            assert_eq!(names, ["htop"]);
2095            assert!(complete);
2096            std::fs::write(into.join("htop-3.5.3-1.1.x86_64.rpm"), b"rpm").is_ok()
2097                && std::fs::write(into.join("libhwloc15-2.13.0-1.3.x86_64.rpm"), b"rpm").is_ok()
2098        };
2099        let tools = Tools {
2100            system: &Fake::default(),
2101            download: &|_, _| false,
2102            dnf_download: &|_, _, _| panic!("not dnf"),
2103            zypper_download: &downloaded,
2104            create_usb: &|_, _, _| false,
2105            flatpak_key: &|_, _| PathBuf::new(),
2106        };
2107        let bundle = scratch.path().join("bundle");
2108        let made = create(&bundle, &found, &suse, true, None, 1, &tools).unwrap();
2109        assert_eq!(made.family, "opensuse");
2110        assert_eq!(made.files.len(), 2);
2111        assert_eq!(
2112            made.files[0].source.as_deref(),
2113            Some("zypper install --download-only")
2114        );
2115        let read = verify(&bundle, &suse).unwrap();
2116        let planned = plan_install(&bundle, &read, &[], &suse, &Fake::default(), false).unwrap();
2117        let Step::Shell { program, .. } = &planned.steps[0] else {
2118            panic!("a shell step");
2119        };
2120        assert!(program.contains("rpm -K"), "{program}");
2121        assert!(
2122            program.contains("sudo zypper $hf_answer --no-refresh --no-remote install"),
2123            "{program}"
2124        );
2125        assert!(
2126            program.contains("if [ -t 0 ]; then hf_answer=; else hf_answer=--non-interactive; fi")
2127        );
2128        assert!(planned.requirements.contains(&"zypper".to_string()));
2129        // A Fedora bundle is not an openSUSE one, though both are RPMs.
2130        assert!(matches!(
2131            verify(&bundle, &platform(PackageManager::Fedora, "fedora")),
2132            Err(BundleError::Machine(..))
2133        ));
2134    }
2135}