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