1mod icons;
20mod linux;
21mod macos;
22mod windows;
23
24use ailloli_ui_core::{
25 AppIdentityMetadata, AILLOLI_UI_PACKAGE_METADATA_PATH_ENV, APP_IDENTITY_METADATA_VERSION,
26 CONVENTIONAL_APP_ICON_PATH,
27};
28use clap::{Args, Parser, Subcommand, ValueEnum};
29use serde::{Deserialize, Serialize};
30use sha2::{Digest, Sha256};
31use std::ffi::OsString;
32use std::fs::{self, File};
33use std::io::{BufReader, Read};
34use std::path::{Path, PathBuf};
35use std::process::Command;
36
37const GENERATOR_VERSION: &str = "1";
42
43#[derive(Debug, thiserror::Error)]
61pub enum PackagingError {
62 #[error("{0}")]
64 Message(String),
65 #[error("packaging I/O failed: {0}")]
67 Io(#[from] std::io::Error),
68 #[error("invalid packaging JSON: {0}")]
70 Json(#[from] serde_json::Error),
71 #[error(transparent)]
73 Icon(#[from] icons::IconGenerationError),
74 #[error("ZIP generation failed: {0}")]
76 Zip(#[from] zip::result::ZipError),
77}
78
79impl PackagingError {
81 pub(crate) fn message(message: impl Into<String>) -> Self {
91 Self::Message(message.into())
92 }
93}
94
95#[derive(Debug, Parser)]
97#[command(
98 name = "cargo ailloli-ui",
99 version,
100 about = "Package an Ailloli UI consumer application"
101)]
102struct Cli {
103 #[command(subcommand)]
105 command: CliCommand,
106}
107
108#[derive(Debug, Subcommand)]
110enum CliCommand {
111 Icons(IconsArgs),
113 Package(PackageArgs),
115}
116
117#[derive(Debug, Args)]
119struct SelectionArgs {
120 #[arg(short = 'p', long = "package")]
122 package: Option<String>,
123 #[arg(long)]
125 bin: Option<String>,
126}
127
128#[derive(Debug, Args)]
130struct IconsArgs {
131 #[command(flatten)]
133 selection: SelectionArgs,
134}
135
136#[derive(Debug, Args)]
138struct PackageArgs {
139 #[command(flatten)]
141 selection: SelectionArgs,
142 #[arg(long, default_value = "release")]
144 profile: String,
145 #[arg(long)]
147 target: Option<String>,
148 #[arg(long, value_enum, default_value_t = PackageFormat::Auto)]
150 format: PackageFormat,
151 #[arg(long)]
153 no_build: bool,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
158enum PackageFormat {
159 Auto,
161 Deb,
163 WindowsZip,
165 MacosApp,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Platform {
172 Linux,
174 Windows,
176 Macos,
178}
179
180impl Platform {
182 fn name(self) -> &'static str {
184 match self {
185 Self::Linux => "linux",
186 Self::Windows => "windows",
187 Self::Macos => "macos",
188 }
189 }
190}
191
192pub fn run_from_env() -> Result<(), PackagingError> {
214 let mut args: Vec<OsString> = std::env::args_os().collect();
215 if args.get(1).is_some_and(|value| value == "ailloli-ui") {
216 args.remove(1);
217 }
218 let cli = Cli::parse_from(args);
219 run(cli, &std::env::current_dir()?)
220}
221
222fn run(cli: Cli, cwd: &Path) -> Result<(), PackagingError> {
229 let metadata = cargo_metadata(cwd)?;
230 match cli.command {
231 CliCommand::Icons(args) => generate_icons_command(cwd, &metadata, args),
232 CliCommand::Package(args) => package_command(cwd, &metadata, args),
233 }
234}
235
236#[derive(Debug, Deserialize)]
238struct CargoMetadata {
239 packages: Vec<CargoPackage>,
241 target_directory: PathBuf,
243}
244
245#[derive(Debug, Clone, Deserialize)]
247struct CargoPackage {
248 name: String,
250 version: String,
252 manifest_path: PathBuf,
254 authors: Vec<String>,
256 description: Option<String>,
258 license: Option<String>,
260 homepage: Option<String>,
262 repository: Option<String>,
264 targets: Vec<CargoTarget>,
266}
267
268#[derive(Debug, Clone, Deserialize)]
270struct CargoTarget {
271 name: String,
273 kind: Vec<String>,
275}
276
277fn cargo_metadata(cwd: &Path) -> Result<CargoMetadata, PackagingError> {
284 let output = Command::new(cargo_program())
285 .args(["metadata", "--format-version", "1", "--no-deps"])
286 .current_dir(cwd)
287 .output()?;
288 if !output.status.success() {
289 return Err(PackagingError::message(format!(
290 "cargo metadata failed:\n{}",
291 String::from_utf8_lossy(&output.stderr)
292 )));
293 }
294 Ok(serde_json::from_slice(&output.stdout)?)
295}
296
297fn cargo_program() -> OsString {
299 std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
300}
301
302fn select_package<'a>(
311 cwd: &Path,
312 metadata: &'a CargoMetadata,
313 requested: Option<&str>,
314) -> Result<&'a CargoPackage, PackagingError> {
315 if let Some(requested) = requested {
316 let matches: Vec<_> = metadata
317 .packages
318 .iter()
319 .filter(|package| package.name == requested)
320 .collect();
321 return match matches.as_slice() {
322 [package] => Ok(*package),
323 [] => Err(PackagingError::message(format!(
324 "Cargo package `{requested}` was not found"
325 ))),
326 _ => Err(PackagingError::message(format!(
327 "Cargo package name `{requested}` is ambiguous"
328 ))),
329 };
330 }
331 let current_manifest = cwd.join("Cargo.toml").canonicalize().ok();
332 let current: Vec<_> = metadata
333 .packages
334 .iter()
335 .filter(|package| {
336 current_manifest.as_ref().is_some_and(|manifest| {
337 package
338 .manifest_path
339 .canonicalize()
340 .is_ok_and(|candidate| &candidate == manifest)
341 })
342 })
343 .collect();
344 match current.as_slice() {
345 [package] => Ok(*package),
346 _ if metadata.packages.len() == 1 => Ok(&metadata.packages[0]),
347 _ => Err(PackagingError::message(
348 "the Cargo workspace is ambiguous; pass `-p <package>`",
349 )),
350 }
351}
352
353fn select_binary<'a>(
360 package: &'a CargoPackage,
361 requested: Option<&str>,
362) -> Result<&'a CargoTarget, PackagingError> {
363 let binaries: Vec<_> = package
364 .targets
365 .iter()
366 .filter(|target| target.kind.iter().any(|kind| kind == "bin"))
367 .collect();
368 if let Some(requested) = requested {
369 return binaries
370 .into_iter()
371 .find(|target| target.name == requested)
372 .ok_or_else(|| {
373 PackagingError::message(format!(
374 "binary target `{requested}` was not found in package `{}`",
375 package.name
376 ))
377 });
378 }
379 match binaries.as_slice() {
380 [binary] => Ok(*binary),
381 [] => Err(PackagingError::message(format!(
382 "package `{}` has no binary target",
383 package.name
384 ))),
385 _ => Err(PackagingError::message(format!(
386 "package `{}` has multiple binaries; pass `--bin <name>`",
387 package.name
388 ))),
389 }
390}
391
392fn package_root(package: &CargoPackage) -> &Path {
398 package
399 .manifest_path
400 .parent()
401 .expect("Cargo manifest has a parent")
402}
403
404fn conventional_icon_path(package: &CargoPackage) -> PathBuf {
406 package_root(package).join(CONVENTIONAL_APP_ICON_PATH)
407}
408
409fn generate_icons_command(
416 cwd: &Path,
417 metadata: &CargoMetadata,
418 args: IconsArgs,
419) -> Result<(), PackagingError> {
420 let package = select_package(cwd, metadata, args.selection.package.as_deref())?;
421 let source = conventional_icon_path(package);
422 if !source.is_file() {
423 return Err(PackagingError::message(format!(
424 "missing conventional application icon: {}",
425 source.display()
426 )));
427 }
428 let icon = icons::app_icon_from_file(&source)?;
429 let digest = icon.sha256();
430 let cache = metadata
431 .target_directory
432 .join("ailloli_ui/icons")
433 .join(GENERATOR_VERSION)
434 .join(digest);
435 let generated = icons::generate_icon_set(&icon, &cache)?;
436 println!(
437 "generated application icons in {}",
438 generated.root.display()
439 );
440 Ok(())
441}
442
443#[derive(Debug, Clone)]
454pub(crate) struct PackageContext {
455 consumer_root: PathBuf,
457 package_name: String,
459 distribution_name: String,
461 binary_name: String,
463 version: String,
465 authors: Vec<String>,
467 description: Option<String>,
469 license: Option<String>,
471 homepage: Option<String>,
473 repository: Option<String>,
475 identity: AppIdentityMetadata,
477 profile: String,
479 target: Option<String>,
481}
482
483fn package_command(
497 cwd: &Path,
498 metadata: &CargoMetadata,
499 args: PackageArgs,
500) -> Result<(), PackagingError> {
501 let package = select_package(cwd, metadata, args.selection.package.as_deref())?;
502 let binary = select_binary(package, args.selection.bin.as_deref())?;
503 let platform = requested_platform(args.format, args.target.as_deref())?;
504 let distribution_name = distribution_name(&package.name)?;
505 let debian_architecture = if platform == Platform::Linux {
506 Some(linux::debian_architecture(args.target.as_deref())?)
507 } else {
508 None
509 };
510 let debian_plan = debian_architecture
511 .map(|architecture| {
512 linux::resolve_debian_plan(package_root(package), &distribution_name, architecture)
513 })
514 .transpose()?;
515 let source_icon = conventional_icon_path(package);
516 if !source_icon.is_file() {
517 return Err(PackagingError::message(format!(
518 "missing conventional application icon: {}",
519 source_icon.display()
520 )));
521 }
522 let app_icon = icons::app_icon_from_file(&source_icon)?;
523 ailloli_ui_icon::validate_app_icon(&app_icon).map_err(icons::IconGenerationError::from)?;
524
525 if !args.no_build {
526 build_consumer(cwd, package, binary, &args)?;
527 }
528 let executable = executable_path(metadata, binary, &args, platform);
529 if !executable.is_file() {
530 return Err(PackagingError::message(format!(
531 "built executable is missing: {}; run without `--no-build`",
532 executable.display()
533 )));
534 }
535 let identity = probe_identity(cwd, metadata, &executable)?;
536 validate_embedded_identity(&identity, &app_icon)?;
537 let context = PackageContext {
538 consumer_root: package_root(package).to_path_buf(),
539 package_name: package.name.clone(),
540 distribution_name,
541 binary_name: binary.name.clone(),
542 version: package.version.clone(),
543 authors: package.authors.clone(),
544 description: package.description.clone(),
545 license: package.license.clone(),
546 homepage: package.homepage.clone(),
547 repository: package.repository.clone(),
548 identity,
549 profile: args.profile.clone(),
550 target: args.target.clone(),
551 };
552 let payloads: Vec<_> = debian_plan
553 .as_ref()
554 .map(|plan| {
555 plan.payloads
556 .iter()
557 .map(|payload| payload.attestation.clone())
558 .collect()
559 })
560 .unwrap_or_default();
561 let packaging_config_sha256 = debian_plan
562 .as_ref()
563 .and_then(|plan| plan.config_sha256.as_deref());
564
565 let receipt_path = receipt_path(metadata, &context);
566 if args.no_build {
567 validate_receipt(
568 &receipt_path,
569 &context,
570 package,
571 &executable,
572 &app_icon,
573 packaging_config_sha256,
574 &payloads,
575 )?;
576 } else {
577 write_receipt(
578 &receipt_path,
579 &context,
580 package,
581 &executable,
582 &app_icon,
583 packaging_config_sha256,
584 &payloads,
585 )?;
586 }
587
588 let icon_cache = metadata
589 .target_directory
590 .join("ailloli_ui/icons")
591 .join(GENERATOR_VERSION)
592 .join(app_icon.sha256());
593 let generated_icons = icons::generate_icon_set(&app_icon, &icon_cache)?;
594 let package_root = metadata.target_directory.join("ailloli_ui/package");
595 fs::create_dir_all(&package_root)?;
596 let final_staging = package_root.join(platform.name());
597 let temp_staging =
598 package_root.join(format!(".{}.tmp-{}", platform.name(), std::process::id()));
599 if temp_staging.exists() {
600 fs::remove_dir_all(&temp_staging)?;
601 }
602 fs::create_dir_all(&temp_staging)?;
603 let dist = metadata.target_directory.join("ailloli_ui/dist");
604 fs::create_dir_all(&dist)?;
605
606 let artifact = match platform {
607 Platform::Linux => {
608 let plan = debian_plan
609 .as_ref()
610 .expect("Linux packaging resolved a Debian plan");
611 let rootfs = linux::stage_linux_root(
612 &context,
613 &executable,
614 &source_icon,
615 &generated_icons,
616 &temp_staging,
617 &plan.payloads,
618 )?;
619 let arch = debian_architecture.expect("Linux packaging resolved an architecture");
620 let artifact = dist.join(format!(
621 "{}_{}_{}.deb",
622 context.distribution_name, context.version, arch
623 ));
624 write_replacing(&artifact, |temp| {
625 linux::build_deb(&context, &rootfs, temp, arch, plan)
626 })?;
627 artifact
628 }
629 Platform::Windows => {
630 windows::stage_windows(&context, &executable, &generated_icons, &temp_staging)?;
631 let artifact = dist.join(format!(
632 "{}-{}-{}.zip",
633 context.distribution_name,
634 context.version,
635 target_label(args.target.as_deref(), platform)
636 ));
637 write_replacing(&artifact, |temp| {
638 windows::build_portable_zip(&temp_staging, temp)
639 })?;
640 artifact
641 }
642 Platform::Macos => {
643 let bundle =
644 macos::stage_macos_bundle(&context, &executable, &generated_icons, &temp_staging)?;
645 let artifact = dist.join(format!(
646 "{}-{}-{}.app.tar.gz",
647 context.distribution_name,
648 context.version,
649 target_label(args.target.as_deref(), platform)
650 ));
651 write_replacing(&artifact, |temp| macos::build_bundle_archive(&bundle, temp))?;
652 artifact
653 }
654 };
655
656 if final_staging.exists() {
657 fs::remove_dir_all(&final_staging)?;
658 }
659 fs::rename(&temp_staging, &final_staging)?;
660 write_distribution_manifest(
661 &dist,
662 &context,
663 platform,
664 &artifact,
665 packaging_config_sha256,
666 &payloads,
667 )?;
668 println!("packaged {}", artifact.display());
669 Ok(())
670}
671
672fn build_consumer(
679 cwd: &Path,
680 package: &CargoPackage,
681 binary: &CargoTarget,
682 args: &PackageArgs,
683) -> Result<(), PackagingError> {
684 let mut command = Command::new(cargo_program());
685 command.arg("build");
686 if args.profile == "release" {
687 command.arg("--release");
688 } else {
689 command.args(["--profile", &args.profile]);
690 }
691 command.args(["-p", &package.name, "--bin", &binary.name]);
692 if let Some(target) = args.target.as_ref() {
693 command.args(["--target", target]);
694 }
695 let status = command.current_dir(cwd).status()?;
696 if !status.success() {
697 return Err(PackagingError::message(format!(
698 "cargo build failed with status {status}"
699 )));
700 }
701 Ok(())
702}
703
704fn executable_path(
709 metadata: &CargoMetadata,
710 binary: &CargoTarget,
711 args: &PackageArgs,
712 platform: Platform,
713) -> PathBuf {
714 let mut path = metadata.target_directory.clone();
715 if let Some(target) = args.target.as_ref() {
716 path.push(target);
717 }
718 path.push(if args.profile == "dev" {
719 "debug"
720 } else {
721 &args.profile
722 });
723 path.push(&binary.name);
724 if platform == Platform::Windows {
725 path.set_extension("exe");
726 }
727 path
728}
729
730fn probe_identity(
743 cwd: &Path,
744 metadata: &CargoMetadata,
745 executable: &Path,
746) -> Result<AppIdentityMetadata, PackagingError> {
747 let package_root = metadata.target_directory.join("ailloli_ui/package");
748 fs::create_dir_all(&package_root)?;
749 let output = package_root.join(format!(".metadata-{}.json", std::process::id()));
750 if output.exists() {
751 fs::remove_file(&output)?;
752 }
753 let status = Command::new(executable)
754 .env(AILLOLI_UI_PACKAGE_METADATA_PATH_ENV, &output)
755 .current_dir(cwd)
756 .status()
757 .map_err(|error| {
758 PackagingError::message(format!(
759 "could not execute target binary for metadata (host-native packaging is required): {error}"
760 ))
761 })?;
762 if !status.success() {
763 return Err(PackagingError::message(format!(
764 "application metadata probe failed with status {status}"
765 )));
766 }
767 let bytes = fs::read(&output).map_err(|error| {
768 PackagingError::message(format!(
769 "application did not emit package metadata to {}: {error}",
770 output.display()
771 ))
772 })?;
773 fs::remove_file(output)?;
774 let identity: AppIdentityMetadata = serde_json::from_slice(&bytes)?;
775 if identity.schema_version != APP_IDENTITY_METADATA_VERSION {
776 return Err(PackagingError::message(format!(
777 "unsupported app identity metadata schema {}",
778 identity.schema_version
779 )));
780 }
781 Ok(identity)
782}
783
784fn validate_embedded_identity(
791 identity: &AppIdentityMetadata,
792 source_icon: &ailloli_ui_core::AppIcon,
793) -> Result<(), PackagingError> {
794 if identity.icon.conventional_path != CONVENTIONAL_APP_ICON_PATH {
795 return Err(PackagingError::message(format!(
796 "packaging requires app_icon!() and `{CONVENTIONAL_APP_ICON_PATH}`; binary reports `{}`",
797 identity.icon.conventional_path
798 )));
799 }
800 let digest = source_icon.sha256();
801 if identity.icon.sha256 != digest {
802 return Err(PackagingError::message(
803 "the built binary embeds a different icon; rebuild before packaging",
804 ));
805 }
806 Ok(())
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
814struct BuildReceipt {
815 schema_version: u32,
817 package: String,
819 binary: String,
821 profile: String,
823 target: Option<String>,
825 executable_sha256: String,
827 manifest_sha256: String,
829 icon_sha256: String,
831 identity: AppIdentityMetadata,
833 packaging_config_sha256: Option<String>,
835 payloads: Vec<linux::PayloadAttestation>,
837}
838
839fn receipt_path(metadata: &CargoMetadata, context: &PackageContext) -> PathBuf {
841 metadata
842 .target_directory
843 .join("ailloli_ui/build-receipts")
844 .join(format!(
845 "{}-{}-{}-{}.json",
846 context.package_name,
847 context.binary_name,
848 context.profile,
849 context.target.as_deref().unwrap_or("host")
850 ))
851}
852
853fn expected_receipt(
860 context: &PackageContext,
861 package: &CargoPackage,
862 executable: &Path,
863 icon: &ailloli_ui_core::AppIcon,
864 packaging_config_sha256: Option<&str>,
865 payloads: &[linux::PayloadAttestation],
866) -> Result<BuildReceipt, PackagingError> {
867 Ok(BuildReceipt {
868 schema_version: 2,
869 package: context.package_name.clone(),
870 binary: context.binary_name.clone(),
871 profile: context.profile.clone(),
872 target: context.target.clone(),
873 executable_sha256: hash_file(executable)?,
874 manifest_sha256: hash_file(&package.manifest_path)?,
875 icon_sha256: icon.sha256(),
876 identity: context.identity.clone(),
877 packaging_config_sha256: packaging_config_sha256.map(ToOwned::to_owned),
878 payloads: payloads.to_vec(),
879 })
880}
881
882fn write_receipt(
892 path: &Path,
893 context: &PackageContext,
894 package: &CargoPackage,
895 executable: &Path,
896 icon: &ailloli_ui_core::AppIcon,
897 packaging_config_sha256: Option<&str>,
898 payloads: &[linux::PayloadAttestation],
899) -> Result<(), PackagingError> {
900 let receipt = expected_receipt(
901 context,
902 package,
903 executable,
904 icon,
905 packaging_config_sha256,
906 payloads,
907 )?;
908 if let Some(parent) = path.parent() {
909 fs::create_dir_all(parent)?;
910 }
911 fs::write(path, serde_json::to_vec_pretty(&receipt)?)?;
912 Ok(())
913}
914
915fn validate_receipt(
925 path: &Path,
926 context: &PackageContext,
927 package: &CargoPackage,
928 executable: &Path,
929 icon: &ailloli_ui_core::AppIcon,
930 packaging_config_sha256: Option<&str>,
931 payloads: &[linux::PayloadAttestation],
932) -> Result<(), PackagingError> {
933 let stored_bytes = fs::read(path).map_err(|_| {
934 PackagingError::message(
935 "--no-build requires a prior successful cargo ailloli-ui package build",
936 )
937 })?;
938 let stored: BuildReceipt = serde_json::from_slice(&stored_bytes).map_err(|_| {
939 PackagingError::message(
940 "--no-build receipt is stale; rerun cargo ailloli-ui package without --no-build",
941 )
942 })?;
943 let expected = expected_receipt(
944 context,
945 package,
946 executable,
947 icon,
948 packaging_config_sha256,
949 payloads,
950 )?;
951 if stored != expected {
952 return Err(PackagingError::message(
953 "--no-build receipt is stale; rerun cargo ailloli-ui package without --no-build",
954 ));
955 }
956 Ok(())
957}
958
959#[derive(Debug, Serialize)]
961struct DistributionManifest<'a> {
962 schema_version: u32,
964 application_id: &'a str,
966 display_name: &'a str,
968 package: &'a str,
970 binary: &'a str,
972 version: &'a str,
974 profile: &'a str,
976 target: &'a str,
978 platform: &'a str,
980 icon_sha256: &'a str,
982 artifact: ArtifactEntry,
984 homepage: Option<&'a str>,
986 repository: Option<&'a str>,
988 packaging_config_sha256: Option<&'a str>,
990 payloads: &'a [linux::PayloadAttestation],
992}
993
994#[derive(Debug, Serialize)]
996struct ArtifactEntry {
997 file: String,
999 size: u64,
1001 sha256: String,
1003}
1004
1005fn write_distribution_manifest(
1017 dist: &Path,
1018 context: &PackageContext,
1019 platform: Platform,
1020 artifact: &Path,
1021 packaging_config_sha256: Option<&str>,
1022 payloads: &[linux::PayloadAttestation],
1023) -> Result<(), PackagingError> {
1024 let sha256 = hash_file(artifact)?;
1025 let file = artifact
1026 .file_name()
1027 .expect("artifact has filename")
1028 .to_string_lossy()
1029 .to_string();
1030 let manifest = DistributionManifest {
1031 schema_version: 2,
1032 application_id: context.identity.application_id.as_str(),
1033 display_name: &context.identity.display_name,
1034 package: &context.package_name,
1035 binary: &context.binary_name,
1036 version: &context.version,
1037 profile: &context.profile,
1038 target: context.target.as_deref().unwrap_or("host"),
1039 platform: platform.name(),
1040 icon_sha256: &context.identity.icon.sha256,
1041 artifact: ArtifactEntry {
1042 file: file.clone(),
1043 size: fs::metadata(artifact)?.len(),
1044 sha256: sha256.clone(),
1045 },
1046 homepage: context.homepage.as_deref(),
1047 repository: context.repository.as_deref(),
1048 packaging_config_sha256,
1049 payloads,
1050 };
1051 atomic_write(
1052 &dist.join("manifest.json"),
1053 &serde_json::to_vec_pretty(&manifest)?,
1054 )?;
1055 atomic_write(
1056 &dist.join("SHA256SUMS"),
1057 format!("{sha256} {file}\n").as_bytes(),
1058 )?;
1059 Ok(())
1060}
1061
1062fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), PackagingError> {
1071 let temp = path.with_extension(format!("tmp-{}", std::process::id()));
1072 fs::write(&temp, bytes)?;
1073 if path.exists() {
1074 fs::remove_file(path)?;
1075 }
1076 fs::rename(temp, path)?;
1077 Ok(())
1078}
1079
1080fn write_replacing(
1091 destination: &Path,
1092 write: impl FnOnce(&Path) -> Result<(), PackagingError>,
1093) -> Result<(), PackagingError> {
1094 let temp = destination.with_extension(format!("tmp-{}", std::process::id()));
1095 if temp.exists() {
1096 fs::remove_file(&temp)?;
1097 }
1098 if let Err(error) = write(&temp) {
1099 let _ = fs::remove_file(&temp);
1100 return Err(error);
1101 }
1102 if destination.exists() {
1103 fs::remove_file(destination)?;
1104 }
1105 fs::rename(temp, destination)?;
1106 Ok(())
1107}
1108
1109fn hash_file(path: &Path) -> Result<String, PackagingError> {
1117 let mut input = BufReader::new(File::open(path)?);
1118 let mut digest = Sha256::new();
1119 let mut buffer = [0_u8; 64 * 1024];
1120 loop {
1121 let read = input.read(&mut buffer)?;
1122 if read == 0 {
1123 break;
1124 }
1125 digest.update(&buffer[..read]);
1126 }
1127 Ok(digest
1128 .finalize()
1129 .iter()
1130 .map(|byte| format!("{byte:02x}"))
1131 .collect())
1132}
1133
1134fn distribution_name(name: &str) -> Result<String, PackagingError> {
1144 let name = name.to_ascii_lowercase().replace('_', "-");
1145 if name.is_empty()
1146 || !name.chars().all(|ch| {
1147 ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '+' | '-' | '.')
1148 })
1149 {
1150 return Err(PackagingError::message(format!(
1151 "Cargo package name `{name}` cannot be normalized as a distribution name"
1152 )));
1153 }
1154 Ok(name)
1155}
1156
1157fn requested_platform(
1167 format: PackageFormat,
1168 target: Option<&str>,
1169) -> Result<Platform, PackagingError> {
1170 let host = host_platform()?;
1171 if let Some(target) = target {
1172 let target_platform = platform_from_target(target)?;
1173 if target_platform != host {
1174 return Err(PackagingError::message(format!(
1175 "target `{target}` is not host-native; host-native packaging packages only for {}",
1176 host.name()
1177 )));
1178 }
1179 }
1180 let requested = match format {
1181 PackageFormat::Auto => host,
1182 PackageFormat::Deb => Platform::Linux,
1183 PackageFormat::WindowsZip => Platform::Windows,
1184 PackageFormat::MacosApp => Platform::Macos,
1185 };
1186 if requested != host {
1187 return Err(PackagingError::message(format!(
1188 "format `{}` requires a {} host",
1189 requested.name(),
1190 requested.name()
1191 )));
1192 }
1193 Ok(requested)
1194}
1195
1196fn host_platform() -> Result<Platform, PackagingError> {
1202 match std::env::consts::OS {
1203 "linux" => Ok(Platform::Linux),
1204 "windows" => Ok(Platform::Windows),
1205 "macos" => Ok(Platform::Macos),
1206 other => Err(PackagingError::message(format!(
1207 "unsupported packaging host `{other}`"
1208 ))),
1209 }
1210}
1211
1212fn platform_from_target(target: &str) -> Result<Platform, PackagingError> {
1219 if target.contains("windows") {
1220 Ok(Platform::Windows)
1221 } else if target.contains("apple-darwin") {
1222 Ok(Platform::Macos)
1223 } else if target.contains("linux") {
1224 Ok(Platform::Linux)
1225 } else {
1226 Err(PackagingError::message(format!(
1227 "unsupported target triple `{target}`"
1228 )))
1229 }
1230}
1231
1232fn target_label(target: Option<&str>, platform: Platform) -> String {
1234 target.map(ToOwned::to_owned).unwrap_or_else(|| {
1235 format!(
1236 "{}-{}",
1237 std::env::consts::ARCH,
1238 match platform {
1239 Platform::Linux => "linux",
1240 Platform::Windows => "windows",
1241 Platform::Macos => "macos",
1242 }
1243 )
1244 })
1245}
1246
1247#[cfg(test)]
1248mod tests {
1250 use super::*;
1251 use ailloli_ui_core::app_identity::AppIconMetadata;
1252 use ailloli_ui_core::{ApplicationId, APP_IDENTITY_METADATA_VERSION};
1253
1254 #[test]
1255 fn distribution_names_are_debian_safe() {
1256 assert_eq!(distribution_name("sample_app").unwrap(), "sample-app");
1257 assert!(distribution_name("bad name").is_err());
1258 }
1259
1260 #[test]
1261 fn binary_selection_is_explicit_when_ambiguous() {
1262 let package = CargoPackage {
1263 name: "demo".to_string(),
1264 version: "0.1.0".to_string(),
1265 manifest_path: PathBuf::from("Cargo.toml"),
1266 authors: Vec::new(),
1267 description: None,
1268 license: None,
1269 homepage: None,
1270 repository: None,
1271 targets: vec![
1272 CargoTarget {
1273 name: "a".to_string(),
1274 kind: vec!["bin".to_string()],
1275 },
1276 CargoTarget {
1277 name: "b".to_string(),
1278 kind: vec!["bin".to_string()],
1279 },
1280 ],
1281 };
1282 assert!(select_binary(&package, None).is_err());
1283 assert_eq!(select_binary(&package, Some("b")).unwrap().name, "b");
1284 }
1285
1286 #[test]
1287 fn schema_two_receipt_is_invalidated_by_payload_or_config_changes() {
1288 let receipt = BuildReceipt {
1289 schema_version: 2,
1290 package: "sample_app".into(),
1291 binary: "sample_app".into(),
1292 profile: "release".into(),
1293 target: None,
1294 executable_sha256: "exe".into(),
1295 manifest_sha256: "manifest".into(),
1296 icon_sha256: "icon".into(),
1297 identity: AppIdentityMetadata {
1298 schema_version: APP_IDENTITY_METADATA_VERSION,
1299 application_id: ApplicationId::parse("org.example.sample-app").unwrap(),
1300 display_name: "Sample App".into(),
1301 icon: AppIconMetadata {
1302 conventional_path: CONVENTIONAL_APP_ICON_PATH.into(),
1303 sha256: "icon".into(),
1304 },
1305 },
1306 packaging_config_sha256: Some("config-a".into()),
1307 payloads: vec![linux::PayloadAttestation {
1308 source: "src/providers/tool".into(),
1309 destination: "usr/libexec/sample-app/providers/tool/tool".into(),
1310 architecture: "amd64".into(),
1311 size: 7,
1312 mode: "0755".into(),
1313 sha256: "payload-a".into(),
1314 }],
1315 };
1316 let mut changed_payload = receipt.clone();
1317 changed_payload.payloads[0].sha256 = "payload-b".into();
1318 assert_ne!(receipt, changed_payload);
1319 let mut changed_config = receipt.clone();
1320 changed_config.packaging_config_sha256 = Some("config-b".into());
1321 assert_ne!(receipt, changed_config);
1322
1323 let serialized = serde_json::to_value(&receipt).unwrap();
1324 assert_eq!(serialized["schema_version"], 2);
1325 assert_eq!(serialized["payloads"][0]["architecture"], "amd64");
1326 }
1327}