1use 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
20pub const FORMAT: u32 = 2;
23pub const FLATPAKS: &str = "flatpak";
25pub const KEYS: &str = "flatpak-keys";
27pub const MANIFEST: &str = "bundle.toml";
29pub const SUMS: &str = "SHA256SUMS";
31
32#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
33pub struct Manifest {
34 pub format: u32,
35 pub created: u64,
37 pub helixforge: String,
38 #[serde(default)]
40 pub release: bool,
41 pub distribution: String,
42 pub family: String,
44 pub architecture: String,
46 #[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 pub offline: bool,
63 #[serde(default)]
65 pub packages: Vec<String>,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub network: Option<String>,
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub flatpak: Option<String>,
72}
73
74#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
77pub struct BundledFlatpak {
78 pub id: String,
80 pub reference: String,
82 pub commit: String,
84 pub remote: String,
86 pub url: String,
87 pub collection: String,
88 #[serde(default)]
90 pub user: bool,
91}
92
93#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
94pub struct BundledFile {
95 pub path: String,
97 pub sha256: String,
98 pub bytes: u64,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub source: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub assurance: Option<String>,
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct Download {
111 pub url: String,
112 pub file: String,
113 pub checksum: Option<String>,
116}
117
118#[derive(Clone, Debug, Default)]
120pub struct Contents {
121 pub entries: Vec<BundledEntry>,
122 pub downloads: Vec<Download>,
123 pub dnf: Vec<String>,
125 pub zypper: Vec<String>,
127 pub flatpaks: Vec<BundledFlatpak>,
129}
130
131#[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
164fn 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
176pub 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 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 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 found.downloads.push(Download {
310 url: format!("{url}.sig"),
311 file: format!("{file}.sig"),
312 checksum: None,
313 });
314 }
315 }
316 PackageManager::Debian => {
317 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
353fn 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
381fn 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
437type Remote = (String, String, String);
440
441fn 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
467const 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
482fn 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
503fn 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
513fn 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
522fn key_path(remote: &str, user: bool) -> String {
524 format!("{KEYS}/{}{remote}.gpg", if user { "user-" } else { "" })
525}
526
527fn 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
539fn 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
561fn 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
572type Downloader<'a> = dyn Fn(&[String], bool, &Path) -> bool + 'a;
575
576pub struct Tools<'a> {
578 pub system: &'a dyn System,
579 pub download: &'a dyn Fn(&str, &Path) -> bool,
581 pub dnf_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
583 pub zypper_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
585 pub create_usb: &'a dyn Fn(&[String], bool, &Path) -> bool,
589 pub flatpak_key: &'a dyn Fn(&str, bool) -> PathBuf,
592}
593
594#[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 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 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
787pub 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 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
923pub 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 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 _ => 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
1003fn 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
1044pub 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 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
1123fn 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 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 PackageManager::Debian => ("sudo apt-get install", "apt-get"),
1205 PackageManager::Suse => ("sudo zypper --no-refresh --no-remote install", "zypper"),
1207 _ => ("sudo dnf install --disablerepo='*'", "dnf"),
1208 };
1209 if manager == PackageManager::Suse {
1210 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
1251fn 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 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
1384fn 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 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 assert!(detached_signature(&[0x88, 0x01, 0x04]));
1488 assert!(detached_signature(&[0xc2, 0x01, 0x04, 0x88, 0x01, 0x04]));
1490 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 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 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 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 assert!(matches!(
1599 plan_install(&bundle, &read, &["steam".to_string()], &arch, &fake, false),
1600 Err(BundleError::Partial(_))
1601 ));
1602 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 assert!(matches!(
2131 verify(&bundle, &platform(PackageManager::Fedora, "fedora")),
2132 Err(BundleError::Machine(..))
2133 ));
2134 }
2135}