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