ailloli_ui_packaging/
lib.rs

1//! Consumer-side desktop packaging for Ailloli UI applications.
2//!
3//! The `cargo-ailloli-ui` binary discovers a consumer package through Cargo
4//! metadata, builds or authenticates its executable, probes the embedded
5//! application identity, and emits a host-native package plus checksums and a
6//! JSON manifest. Packaging is intentionally host-native: this crate does not
7//! claim that copying a foreign-target executable is sufficient to create a
8//! valid package.
9//!
10//! # Examples
11//!
12//! ```no_run
13//! fn main() -> Result<(), ailloli_ui_packaging::PackagingError> {
14//!     // Reads the real process arguments and current directory.
15//!     ailloli_ui_packaging::run_from_env()
16//! }
17//! ```
18
19mod 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
37/// Version of the deterministic generated-icon cache layout.
38///
39/// Changing icon layout or encoding semantics requires changing this value so
40/// stale derivatives cannot be mistaken for current output.
41const GENERATOR_VERSION: &str = "1";
42
43/// Error returned by the packaging command and its format-specific backends.
44///
45/// The display text is intended for command-line diagnostics. Callers should
46/// match variants, not parse those strings.
47///
48/// # Examples
49///
50/// ```
51/// use ailloli_ui_packaging::PackagingError;
52///
53/// let error: PackagingError = std::io::Error::new(
54///     std::io::ErrorKind::PermissionDenied,
55///     "artifact",
56/// ).into();
57/// assert!(matches!(error, PackagingError::Io(_)));
58/// assert!(error.to_string().contains("packaging I/O failed"));
59/// ```
60#[derive(Debug, thiserror::Error)]
61pub enum PackagingError {
62    /// A packaging invariant or external command failed with contextual text.
63    #[error("{0}")]
64    Message(String),
65    /// A filesystem or process-I/O operation failed.
66    #[error("packaging I/O failed: {0}")]
67    Io(#[from] std::io::Error),
68    /// Identity, receipt, or manifest JSON was malformed.
69    #[error("invalid packaging JSON: {0}")]
70    Json(#[from] serde_json::Error),
71    /// Application-icon validation or generation failed.
72    #[error(transparent)]
73    Icon(#[from] icons::IconGenerationError),
74    /// A portable Windows ZIP archive could not be generated.
75    #[error("ZIP generation failed: {0}")]
76    Zip(#[from] zip::result::ZipError),
77}
78
79/// Constructors used to preserve contextual command-line diagnostics.
80impl PackagingError {
81    /// Wraps a human-readable invariant failure without assigning another source error.
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use ailloli_ui_packaging::PackagingError;
87    /// let error: PackagingError = std::io::Error::other("fixture").into();
88    /// assert!(error.to_string().contains("fixture"));
89    /// ```
90    pub(crate) fn message(message: impl Into<String>) -> Self {
91        Self::Message(message.into())
92    }
93}
94
95/// Parsed `cargo ailloli-ui` command line.
96#[derive(Debug, Parser)]
97#[command(
98    name = "cargo ailloli-ui",
99    version,
100    about = "Package an Ailloli UI consumer application"
101)]
102struct Cli {
103    /// Selected top-level operation.
104    #[command(subcommand)]
105    command: CliCommand,
106}
107
108/// Top-level packaging operations exposed by the Cargo subcommand.
109#[derive(Debug, Subcommand)]
110enum CliCommand {
111    /// Validate and generate PNG, ICO and ICNS icon derivatives without building the app.
112    Icons(IconsArgs),
113    /// Build and package the selected consumer application.
114    Package(PackageArgs),
115}
116
117/// Optional selectors shared by icon generation and packaging.
118#[derive(Debug, Args)]
119struct SelectionArgs {
120    /// Cargo package to select when the current workspace is ambiguous.
121    #[arg(short = 'p', long = "package")]
122    package: Option<String>,
123    /// Binary target to package when the selected package has multiple binaries.
124    #[arg(long)]
125    bin: Option<String>,
126}
127
128/// Arguments for generating icon derivatives only.
129#[derive(Debug, Args)]
130struct IconsArgs {
131    /// Consumer package and binary selection.
132    #[command(flatten)]
133    selection: SelectionArgs,
134}
135
136/// Arguments controlling a full package build.
137#[derive(Debug, Args)]
138struct PackageArgs {
139    /// Consumer package and binary selection.
140    #[command(flatten)]
141    selection: SelectionArgs,
142    /// Cargo build profile. Defaults to release.
143    #[arg(long, default_value = "release")]
144    profile: String,
145    /// Explicit host-native Rust target triple.
146    #[arg(long)]
147    target: Option<String>,
148    /// Output package format. `auto` selects the host platform.
149    #[arg(long, value_enum, default_value_t = PackageFormat::Auto)]
150    format: PackageFormat,
151    /// Reuse a previously attested executable instead of invoking cargo build.
152    #[arg(long)]
153    no_build: bool,
154}
155
156/// User-facing output format selection.
157#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
158enum PackageFormat {
159    /// Selects the sole supported format for the current host.
160    Auto,
161    /// Builds a Debian `ar` package; valid only on Linux hosts.
162    Deb,
163    /// Builds a portable ZIP with an updated PE executable; valid only on Windows.
164    WindowsZip,
165    /// Builds and archives an application bundle; valid only on macOS.
166    MacosApp,
167}
168
169/// Normalized host platform used for validation and output naming.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171enum Platform {
172    /// Linux and Debian packaging.
173    Linux,
174    /// Windows PE and ZIP packaging.
175    Windows,
176    /// macOS application-bundle packaging.
177    Macos,
178}
179
180/// Stable staging labels for each supported platform.
181impl Platform {
182    /// Returns the lowercase stable label used as a staging-directory name.
183    fn name(self) -> &'static str {
184        match self {
185            Self::Linux => "linux",
186            Self::Windows => "windows",
187            Self::Macos => "macos",
188        }
189    }
190}
191
192/// Runs the external Cargo subcommand using process arguments and current directory.
193///
194/// Cargo invokes an external subcommand as `cargo-ailloli-ui ailloli-ui ...`;
195/// the redundant second argument is removed when present. Parsing errors and
196/// help/version requests are handled by `clap` and may terminate the process.
197/// Successful packaging writes below Cargo's target directory and may invoke
198/// both Cargo and the built consumer executable.
199///
200/// # Errors
201///
202/// Returns an error when the current directory is unavailable or any discovery,
203/// build, identity, staging, archive, or manifest step fails.
204///
205/// # Examples
206///
207/// ```no_run
208/// let result: Result<(), ailloli_ui_packaging::PackagingError> =
209///     ailloli_ui_packaging::run_from_env();
210/// result?;
211/// # Ok::<(), ailloli_ui_packaging::PackagingError>(())
212/// ```
213pub 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
222/// Executes an already parsed command relative to `cwd`.
223///
224/// # Errors
225///
226/// Propagates Cargo metadata discovery and the selected icon-generation or
227/// packaging workflow error.
228fn 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/// Minimal subset of Cargo metadata used by the packager.
237#[derive(Debug, Deserialize)]
238struct CargoMetadata {
239    /// Workspace packages returned by `cargo metadata --no-deps`.
240    packages: Vec<CargoPackage>,
241    /// Cargo target directory in which caches and distributions are written.
242    target_directory: PathBuf,
243}
244
245/// Consumer package metadata relevant to selection and package manifests.
246#[derive(Debug, Clone, Deserialize)]
247struct CargoPackage {
248    /// Cargo package name before distribution-name normalization.
249    name: String,
250    /// Package version copied verbatim into artifacts and metadata.
251    version: String,
252    /// Absolute or metadata-relative path to the package manifest.
253    manifest_path: PathBuf,
254    /// Cargo authors; Debian requires one `Name <email>` entry.
255    authors: Vec<String>,
256    /// Optional Cargo description; required for Linux AppStream and Debian metadata.
257    description: Option<String>,
258    /// Optional SPDX license expression; required for Linux AppStream metadata.
259    license: Option<String>,
260    /// Optional project homepage copied into the distribution manifest.
261    homepage: Option<String>,
262    /// Optional source repository copied into the distribution manifest.
263    repository: Option<String>,
264    /// Build targets used to choose exactly one binary.
265    targets: Vec<CargoTarget>,
266}
267
268/// Cargo target metadata used by binary selection.
269#[derive(Debug, Clone, Deserialize)]
270struct CargoTarget {
271    /// Cargo target name and eventual executable basename.
272    name: String,
273    /// Cargo target kinds; a binary contains the exact string `bin`.
274    kind: Vec<String>,
275}
276
277/// Queries Cargo without dependency metadata in `cwd`.
278///
279/// # Errors
280///
281/// Propagates process-launch I/O errors, returns a message error for non-success
282/// Cargo status, and propagates malformed JSON metadata errors.
283fn 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
297/// Returns the Cargo executable from `CARGO`, falling back to `cargo`.
298fn cargo_program() -> OsString {
299    std::env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"))
300}
301
302/// Selects one package explicitly, by current manifest, or by singleton fallback.
303///
304/// An absent request never guesses when a multi-package workspace is ambiguous.
305///
306/// # Errors
307///
308/// Returns an error when an explicit package is absent/ambiguous or when no
309/// unambiguous current/singleton package can be inferred.
310fn 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
353/// Selects one binary target, requiring `--bin` when several exist.
354///
355/// # Errors
356///
357/// Returns an error when the requested binary is absent, the package exposes no
358/// binary, or multiple binaries exist without an explicit request.
359fn 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
392/// Returns the directory containing a package's Cargo manifest.
393///
394/// # Panics
395///
396/// Panics only if Cargo metadata supplies a manifest path with no parent.
397fn package_root(package: &CargoPackage) -> &Path {
398    package
399        .manifest_path
400        .parent()
401        .expect("Cargo manifest has a parent")
402}
403
404/// Resolves the framework's conventional icon path below a consumer package.
405fn conventional_icon_path(package: &CargoPackage) -> PathBuf {
406    package_root(package).join(CONVENTIONAL_APP_ICON_PATH)
407}
408
409/// Implements the icon-only command in the content-addressed target cache.
410///
411/// # Errors
412///
413/// Returns an error for package-selection failure, an absent conventional icon,
414/// invalid icon input, or generated-icon cache I/O/encoding failure.
415fn 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/// Immutable inputs shared by all format-specific staging backends.
444///
445/// # Examples
446///
447/// ```
448/// use ailloli_ui_core::ApplicationId;
449/// let id = ApplicationId::parse("org.example.sample-app")?;
450/// assert_eq!(id.as_str(), "org.example.sample-app");
451/// # Ok::<(), ailloli_ui_core::AppIdentityError>(())
452/// ```
453#[derive(Debug, Clone)]
454pub(crate) struct PackageContext {
455    /// Root of the consumer package; all configured payload sources are confined here.
456    consumer_root: PathBuf,
457    /// Original Cargo package name.
458    package_name: String,
459    /// Lowercase Debian-safe distribution name.
460    distribution_name: String,
461    /// Selected Cargo binary target name.
462    binary_name: String,
463    /// Cargo package version copied into platform metadata.
464    version: String,
465    /// Cargo authors used to derive maintainer and company fields.
466    authors: Vec<String>,
467    /// Optional Cargo description; Linux packaging requires `Some`.
468    description: Option<String>,
469    /// Optional Cargo license; Linux AppStream generation requires `Some`.
470    license: Option<String>,
471    /// Optional homepage written to the distribution manifest; `None` omits it.
472    homepage: Option<String>,
473    /// Optional repository written to the distribution manifest; `None` omits it.
474    repository: Option<String>,
475    /// Identity emitted by the built executable and verified against the source icon.
476    identity: AppIdentityMetadata,
477    /// Cargo profile name; `dev` maps to the `debug` output directory.
478    profile: String,
479    /// Explicit host-native target triple, or `None` for the host toolchain target.
480    target: Option<String>,
481}
482
483/// Runs the build, attestation, staging, and artifact workflow for one package.
484///
485/// # Errors
486///
487/// Propagates selection/platform/configuration, icon validation, Cargo build,
488/// identity attestation, receipt, staging, archive, hashing, and manifest I/O
489/// failures. Existing temporary/final staging is replaced only at the documented
490/// transaction boundaries.
491///
492/// # Panics
493///
494/// Panics only if successful Linux platform resolution failed to retain its
495/// already-computed Debian plan or architecture.
496fn 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
672/// Invokes Cargo for exactly the selected package, binary, profile, and target.
673///
674/// # Errors
675///
676/// Propagates failure to spawn/wait for Cargo and returns a message error for a
677/// non-success build status.
678fn 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
704/// Computes Cargo's expected executable path for the selected build settings.
705///
706/// The special Cargo profile name `dev` maps to directory `debug`; Windows
707/// appends `.exe`, while other platforms preserve the binary target name.
708fn 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
730/// Executes the host-native consumer binary to obtain embedded identity JSON.
731///
732/// The probe path is communicated through
733/// [`AILLOLI_UI_PACKAGE_METADATA_PATH_ENV`]. A stale file with the same
734/// process-based name is removed first. The binary is trusted build output and
735/// runs with the packager's normal environment and permissions.
736///
737/// # Errors
738///
739/// Propagates package-directory/stale-file I/O, executable launch, non-success
740/// status, missing output, JSON decoding, cleanup, and unsupported identity
741/// schema failures.
742fn 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
784/// Confirms that probed identity names the conventional icon and exact SVG digest.
785///
786/// # Errors
787///
788/// Returns an error when the binary does not report the conventional
789/// `app_icon!()` path or its embedded SVG digest differs from `source_icon`.
790fn 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/// Reproducible build inputs authenticated for `--no-build` reuse.
810///
811/// Schema version `2` includes the packaging configuration and resolved Linux
812/// payloads. Any field difference invalidates the entire receipt.
813#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
814struct BuildReceipt {
815    /// Receipt schema; currently exactly `2`.
816    schema_version: u32,
817    /// Cargo package name.
818    package: String,
819    /// Selected binary target name.
820    binary: String,
821    /// Cargo profile name.
822    profile: String,
823    /// Explicit target triple, or `None` for the host target.
824    target: Option<String>,
825    /// Lowercase hexadecimal SHA-256 of the executable bytes.
826    executable_sha256: String,
827    /// Lowercase hexadecimal SHA-256 of `Cargo.toml`.
828    manifest_sha256: String,
829    /// Lowercase hexadecimal SHA-256 of the embedded SVG bytes.
830    icon_sha256: String,
831    /// Metadata emitted by the executable during the identity probe.
832    identity: AppIdentityMetadata,
833    /// Debian configuration digest, or `None` when no config file exists.
834    packaging_config_sha256: Option<String>,
835    /// Sorted, architecture-specific payload attestations; empty for no payloads.
836    payloads: Vec<linux::PayloadAttestation>,
837}
838
839/// Returns the receipt path keyed by package, binary, profile, and target.
840fn 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
853/// Recomputes every authenticated receipt field from current files and metadata.
854///
855/// # Errors
856///
857/// Propagates executable or Cargo-manifest open/read errors while hashing the
858/// current authenticated inputs.
859fn 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
882/// Serializes a fresh schema-two receipt after a successful build.
883///
884/// Parent directories are created as needed. The write is not atomic; a write
885/// error may leave a partial receipt, which validation rejects as stale.
886///
887/// # Errors
888///
889/// Propagates input hashing, parent-directory creation, JSON serialization, and
890/// receipt-file write errors.
891fn 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
915/// Requires a stored receipt to equal the freshly computed expected receipt.
916///
917/// Missing, malformed, older-schema, or unequal receipts all produce the same
918/// actionable `--no-build` stale-receipt diagnostic.
919///
920/// # Errors
921///
922/// Returns a stale-receipt error when the file is missing, malformed, or differs
923/// from current inputs; propagates failures while hashing those current inputs.
924fn 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/// Machine-readable description of the most recently produced distribution artifact.
960#[derive(Debug, Serialize)]
961struct DistributionManifest<'a> {
962    /// Manifest schema; currently exactly `2`.
963    schema_version: u32,
964    /// Reverse-DNS application identity.
965    application_id: &'a str,
966    /// Human-readable application name.
967    display_name: &'a str,
968    /// Original Cargo package name.
969    package: &'a str,
970    /// Selected Cargo binary target name.
971    binary: &'a str,
972    /// Cargo package version.
973    version: &'a str,
974    /// Cargo build profile.
975    profile: &'a str,
976    /// Explicit target triple or sentinel string `host`.
977    target: &'a str,
978    /// Lowercase platform label.
979    platform: &'a str,
980    /// Lowercase hexadecimal SHA-256 of the source SVG.
981    icon_sha256: &'a str,
982    /// Filename, byte size, and digest of the generated artifact.
983    artifact: ArtifactEntry,
984    /// Optional package homepage; `None` serializes as JSON `null`.
985    homepage: Option<&'a str>,
986    /// Optional source repository; `None` serializes as JSON `null`.
987    repository: Option<&'a str>,
988    /// Optional Debian configuration digest; absent configuration becomes `null`.
989    packaging_config_sha256: Option<&'a str>,
990    /// Sorted Linux payload attestations; non-Linux artifacts use an empty slice.
991    payloads: &'a [linux::PayloadAttestation],
992}
993
994/// Integrity metadata for one generated artifact.
995#[derive(Debug, Serialize)]
996struct ArtifactEntry {
997    /// Artifact basename, without its distribution-directory prefix.
998    file: String,
999    /// Artifact length in bytes.
1000    size: u64,
1001    /// Lowercase 64-character SHA-256 digest.
1002    sha256: String,
1003}
1004
1005/// Atomically replaces the distribution manifest and GNU-style checksum file.
1006///
1007/// # Errors
1008///
1009/// Propagates artifact hashing/metadata, JSON serialization, and either atomic
1010/// manifest/checksum write failure.
1011///
1012/// # Panics
1013///
1014/// Panics if `artifact` has no filename component; callers pass a concrete file
1015/// below the distribution directory.
1016fn 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
1062/// Replaces `path` by writing a process-specific sibling and renaming it.
1063///
1064/// On platforms where rename-over-existing is unavailable, the old file is
1065/// removed first, leaving a small interval in which the final path is absent.
1066///
1067/// # Errors
1068///
1069/// Propagates temporary-file write, previous-file removal, or final rename errors.
1070fn 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
1080/// Builds an artifact at a sibling temporary path and installs it on success.
1081///
1082/// Failed callbacks trigger best-effort temporary-file cleanup and preserve an
1083/// existing destination. A successful callback removes an old destination
1084/// before renaming the temporary file.
1085///
1086/// # Errors
1087///
1088/// Propagates stale-temporary cleanup, callback, old-destination removal, or
1089/// final rename errors. Callback failure preserves an existing destination.
1090fn 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
1109/// Streams a file through SHA-256 using a fixed 64-KiB buffer.
1110///
1111/// Returns a lowercase 64-character hexadecimal digest, including for an empty file.
1112///
1113/// # Errors
1114///
1115/// Propagates file-open and streaming read errors.
1116fn 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
1134/// Normalizes a Cargo name to a lowercase Debian distribution name.
1135///
1136/// ASCII underscores become hyphens. Remaining characters must be lowercase
1137/// ASCII letters, digits, `+`, `-`, or `.`; the result must not be empty.
1138///
1139/// # Errors
1140///
1141/// Returns an error when lowercase/underscore normalization yields an empty
1142/// name or a character outside the Debian-safe set.
1143fn 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
1157/// Resolves an output format and enforces host-native packaging.
1158///
1159/// An explicit target must map to the current host OS. `Auto` selects that OS;
1160/// every explicit format must also match it.
1161///
1162/// # Errors
1163///
1164/// Returns an error for an unsupported host/target triple, a cross-platform
1165/// target, or an explicit format incompatible with the current host.
1166fn 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
1196/// Maps the compile-time host OS to a supported packaging platform.
1197///
1198/// # Errors
1199///
1200/// Returns an error when the compile-time OS is not Linux, Windows, or macOS.
1201fn 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
1212/// Infers a platform from conventional substrings in a Rust target triple.
1213///
1214/// # Errors
1215///
1216/// Returns an error when `target` contains none of the recognized Windows,
1217/// Apple Darwin, or Linux markers.
1218fn 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
1232/// Returns an explicit target verbatim or a stable `<arch>-<platform>` host label.
1233fn 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)]
1248/// Exercises selection ambiguity, name normalization, and receipt invalidation.
1249mod 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}