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 flatpaks: Vec<BundledFlatpak>,
127}
128
129#[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
162fn 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
173pub 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 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 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 found.downloads.push(Download {
307 url: format!("{url}.sig"),
308 file: format!("{file}.sig"),
309 checksum: None,
310 });
311 }
312 }
313 PackageManager::Debian => {
314 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
349fn 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
377fn 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
433type Remote = (String, String, String);
436
437fn 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
463const 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
478fn 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
499fn 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
509fn 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
518fn key_path(remote: &str, user: bool) -> String {
520 format!("{KEYS}/{}{remote}.gpg", if user { "user-" } else { "" })
521}
522
523fn 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
535fn 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
557fn 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
568pub struct Tools<'a> {
570 pub system: &'a dyn System,
571 pub download: &'a dyn Fn(&str, &Path) -> bool,
573 pub dnf_download: &'a dyn Fn(&[String], bool, &Path) -> bool,
575 pub create_usb: &'a dyn Fn(&[String], bool, &Path) -> bool,
579 pub flatpak_key: &'a dyn Fn(&str, bool) -> PathBuf,
582}
583
584#[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 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 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
766pub 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 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
902pub 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 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 _ => 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
982fn 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
1023pub 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 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
1102fn 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 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 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
1214fn 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 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
1347fn 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 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 assert!(detached_signature(&[0x88, 0x01, 0x04]));
1451 assert!(detached_signature(&[0xc2, 0x01, 0x04, 0x88, 0x01, 0x04]));
1453 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 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 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 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 assert!(matches!(
1561 plan_install(&bundle, &read, &["steam".to_string()], &arch, &fake, false),
1562 Err(BundleError::Partial(_))
1563 ));
1564 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}