use std::{ ffi::{OsStr, OsString}, fs::File, io::{BufWriter, Read}, path::{Path, PathBuf}, process::ExitStatus, }; use anyhow::{Context, Result, anyhow, bail}; use serde::Serialize; use sha2::{Digest, Sha256}; use tempfile::TempDir; use walkdir::WalkDir; use zip::write::SimpleFileOptions; use crate::{ cli::{PackageLocalArgs, ReleaseSmokeVersionArgs}, workspace::{ archive_extension_for_target, default_binary_path, host_triple, load_workspace_metadata, root_manifest_path, run_command, utc_now_rfc3339, write_utf8_text, }, }; #[derive(Debug, Serialize)] /// Summary printed after validating a release binary version. struct ReleaseSmokeSummary { #[serde(rename = "BinaryPath")] /// Binary that was executed for the smoke check. binary_path: String, #[serde(rename = "HostTriple")] /// Build host target triple. host_triple: String, #[serde(rename = "TargetTriple")] /// Release artifact target triple. target_triple: String, #[serde(rename = "Version")] /// Expected package version. version: String, #[serde(rename = "VersionOutput")] /// Captured `--version` output. version_output: String, } #[derive(Debug, Serialize)] /// Machine-readable manifest written beside a release archive. struct ReleaseArchiveManifest { /// Package version in the archive. version: String, /// Build host target triple. host_triple: String, /// Release artifact target triple. target_triple: String, /// Packaged archive metadata. archive: ManifestArtifact, /// Source binary metadata. binary: ManifestArtifact, /// Path to the aggregate checksum list. checksum_list: String, /// Version smoke result included in the package manifest. version_smoke: VersionSmokeManifest, /// Files included in the staged archive. included_files: Vec, /// UTC generation timestamp for the manifest. generated_at_utc: String, } #[derive(Debug, Serialize)] /// File artifact metadata embedded in release manifests. struct ManifestArtifact { /// Artifact file name. file_name: String, /// Full artifact path. path: String, /// Hex-encoded SHA-256 digest. sha256: String, } #[derive(Debug, Serialize)] /// Captured release version-smoke output. struct VersionSmokeManifest { /// Whether the smoke check was intentionally skipped. skipped: bool, /// Captured output lines from the smoke check. output: Vec, } #[derive(Debug, Serialize)] /// File included in the staged release archive. struct IncludedFile { #[serde(rename = "RelativePath")] /// Archive-relative file path. relative_path: String, #[serde(rename = "Size")] /// File size in bytes. size: u64, } #[derive(Debug, Serialize)] /// Summary printed after creating a local release package. struct PackageSummary { #[serde(rename = "Version")] /// Package version in the archive. version: String, #[serde(rename = "HostTriple")] /// Build host target triple. host_triple: String, #[serde(rename = "TargetTriple")] /// Release artifact target triple. target_triple: String, #[serde(rename = "SourceBinary")] /// Binary copied into the archive. source_binary: String, #[serde(rename = "ArchivePath")] /// Path to the generated archive. archive_path: String, #[serde(rename = "ManifestPath")] /// Path to the generated manifest. manifest_path: String, #[serde(rename = "ChecksumList")] /// Path to the aggregate checksum list. checksum_list: String, } /// Runs the release binary version smoke workflow. pub fn run_release_smoke_version(args: &ReleaseSmokeVersionArgs) -> Result<()> { let metadata = load_workspace_metadata()?; let host_triple = host_triple()?; let target_triple = args .target_triple .clone() .unwrap_or_else(|| host_triple.clone()); if args.build { invoke_release_build(&target_triple, &host_triple)?; } let binary_path = args .binary_path .clone() .unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple)); let version_output = invoke_version_smoke(&binary_path, &metadata.package_version)?; let binary_path_text = path_display_string(&binary_path); let summary = ReleaseSmokeSummary { binary_path: binary_path_text, host_triple, target_triple, version: metadata.package_version, version_output: version_output.join("\n"), }; println!( "{}", serde_json::to_string_pretty(&summary) .context("failed to serialize release smoke summary")? ); Ok(()) } /// Builds a local release archive and manifest from a compiled binary. pub fn run_package_local(args: &PackageLocalArgs) -> Result<()> { let metadata = load_workspace_metadata()?; let host_triple = host_triple()?; let target_triple = args .target_triple .clone() .unwrap_or_else(|| host_triple.clone()); let output_root = args .output_root .clone() .unwrap_or_else(|| metadata.workspace_root.join("dist").join("release")); if args.build { invoke_release_build(&target_triple, &host_triple)?; } let source_binary = args .source_binary .clone() .unwrap_or_else(|| default_binary_path(&metadata, &target_triple, &host_triple)); ensure_file_exists(&source_binary, "release packaging could not find binary")?; let artifact_base_name = format!( "aria2-rust-pro-v{}-{}", metadata.package_version, target_triple ); let archive_extension = archive_extension_for_target(&target_triple); let version_dir = output_root.join(format!("v{}", metadata.package_version)); std::fs::create_dir_all(&version_dir) .with_context(|| format!("failed to create {}", version_dir.display()))?; let version_output = if args.skip_version_smoke { Vec::new() } else { invoke_version_smoke(&source_binary, &metadata.package_version)? }; let stage = StageDirectory::new(&artifact_base_name)?; let binary_name = file_name_os_string(&source_binary)?; std::fs::copy(&source_binary, stage.stage_root.join(&binary_name)) .with_context(|| format!("failed to stage {}", source_binary.display()))?; if target_triple.contains("windows") && let Some(pdb_path) = find_windows_pdb_path(&source_binary) { let pdb_name = file_name_os_string(&pdb_path)?; std::fs::copy(&pdb_path, stage.stage_root.join(&pdb_name)) .with_context(|| format!("failed to stage {}", pdb_path.display()))?; } add_optional_file( &metadata.workspace_root.join("README.md"), &stage.stage_root, Path::new("README.md"), )?; add_optional_file( &metadata .workspace_root .join("docs") .join("release") .join("README.md"), &stage.stage_root, Path::new("docs") .join("release") .join("README.md") .as_path(), )?; let archive_path = version_dir.join(format!("{artifact_base_name}{archive_extension}")); if archive_extension == ".zip" { create_zip_archive(&stage.stage_root, &archive_path)?; } else { create_tar_gz_archive(&stage.stage_root, &archive_path)?; } let checksum_list = write_checksum_files(std::slice::from_ref(&archive_path), &version_dir)?; let binary_hash = sha256_hex(&source_binary)?; let archive_hash = sha256_hex(&archive_path)?; let manifest_path = version_dir.join(format!("{artifact_base_name}.manifest.json")); let included_files = collect_included_files(&stage.stage_root)?; let archive_path_text = path_display_string(&archive_path); let source_binary_text = path_display_string(&source_binary); let checksum_list_text = path_display_string(&checksum_list); let manifest_path_text = path_display_string(&manifest_path); let manifest = ReleaseArchiveManifest { version: metadata.package_version.clone(), host_triple: host_triple.clone(), target_triple: target_triple.clone(), archive: ManifestArtifact { file_name: file_name_string(&archive_path)?, path: archive_path_text.clone(), sha256: archive_hash, }, binary: ManifestArtifact { file_name: file_name_string(&source_binary)?, path: source_binary_text.clone(), sha256: binary_hash, }, checksum_list: checksum_list_text.clone(), version_smoke: VersionSmokeManifest { skipped: args.skip_version_smoke, output: version_output, }, included_files, generated_at_utc: utc_now_rfc3339()?, }; let manifest_text = serde_json::to_string_pretty(&manifest).context("failed to serialize release manifest")?; write_utf8_text(&manifest_path, &(manifest_text + "\n"))?; let summary = PackageSummary { version: metadata.package_version, host_triple, target_triple, source_binary: source_binary_text, archive_path: archive_path_text, manifest_path: manifest_path_text, checksum_list: checksum_list_text, }; println!( "{}", serde_json::to_string_pretty(&summary) .context("failed to serialize release package summary")? ); Ok(()) } /// Executes a binary and validates its aria2-compatible version banner. fn invoke_version_smoke(binary_path: &Path, expected_version: &str) -> Result> { ensure_file_exists(binary_path, "version smoke could not find binary")?; let output = std::process::Command::new(binary_path) .arg("--version") .output() .with_context(|| format!("failed to launch {}", binary_path.display()))?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); bail!( "version smoke failed with status {}: {}", render_exit_status(output.status), stderr.trim() ); } let stdout = String::from_utf8(output.stdout) .with_context(|| format!("{} emitted non-UTF-8 stdout", binary_path.display()))?; let lines = stdout.lines().map(str::to_owned).collect::>(); let first_line = lines .first() .ok_or_else(|| anyhow!("version smoke output was empty"))?; let expected_compat_line = "aria2 version 1.37.0"; if !first_line.starts_with(expected_compat_line) { bail!("version smoke output did not start with `{expected_compat_line}`: {first_line}"); } let expected_package_line = format!("Rust rewrite package: aria2-rust-pro {expected_version}"); let package_line = lines .iter() .find(|line| line.starts_with("Rust rewrite package:")) .ok_or_else(|| { anyhow!("version smoke output did not include a Rust rewrite package line") })?; if package_line != &expected_package_line { bail!("version smoke package line did not match `{expected_package_line}`: {package_line}"); } Ok(lines) } /// Invokes the release build for the requested target triple. fn invoke_release_build(target_triple: &str, host_triple: &str) -> Result<()> { let manifest_path = root_manifest_path(); let mut args = vec![ os("+nightly"), os("build"), os("-Zbuild-std=std,panic_abort"), os("--manifest-path"), manifest_path.into_os_string(), os("-p"), os("aria2-rust-pro-cli"), os("--bin"), os("aria2-rust-pro"), os("--release"), ]; if target_triple != host_triple { args.push(os("--target")); args.push(os(target_triple)); } run_command("cargo", &args) } /// Temporary release staging directory. struct StageDirectory { /// Temporary directory whose lifetime keeps the staging tree alive. _temp_dir: TempDir, /// Root directory containing staged archive contents. stage_root: PathBuf, } impl StageDirectory { /// Creates a new temporary staging directory for one artifact. fn new(artifact_base_name: &str) -> Result { let temp_dir = TempDir::new().context("failed to create temporary release staging directory")?; let stage_root = temp_dir.path().join(artifact_base_name); std::fs::create_dir_all(&stage_root) .with_context(|| format!("failed to create {}", stage_root.display()))?; Ok(Self { _temp_dir: temp_dir, stage_root, }) } } /// Ensures an expected release input file exists. fn ensure_file_exists(path: &Path, label: &str) -> Result<()> { if path.is_file() { Ok(()) } else { bail!("{label}: {}", path.display()) } } /// Finds the optional Windows PDB companion for a release binary. fn find_windows_pdb_path(binary_path: &Path) -> Option { let mut candidates = Vec::new(); let mut direct = binary_path.to_path_buf(); direct.set_extension("pdb"); candidates.push(direct); let alt = binary_path .parent() .map(|parent| parent.join("aria2_rust_pro.pdb")); if let Some(path) = alt { candidates.push(path); } candidates.into_iter().find(|candidate| candidate.is_file()) } /// Copies an optional repository file into the staging tree. fn add_optional_file( source_path: &Path, destination_root: &Path, relative_path: &Path, ) -> Result { if !source_path.is_file() { return Ok(false); } let destination_path = destination_root.join(relative_path); if let Some(parent) = destination_path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("failed to create {}", parent.display()))?; } std::fs::copy(source_path, &destination_path).with_context(|| { format!( "failed to copy {} to {}", source_path.display(), destination_path.display() ) })?; Ok(true) } /// Creates a zip archive from the staged release tree. fn create_zip_archive(stage_root: &Path, archive_path: &Path) -> Result<()> { if archive_path.exists() { std::fs::remove_file(archive_path) .with_context(|| format!("failed to remove {}", archive_path.display()))?; } let archive_root = file_name_string(stage_root)?; let file = File::create(archive_path) .with_context(|| format!("failed to create {}", archive_path.display()))?; let writer = BufWriter::new(file); let mut zip = zip::ZipWriter::new(writer); let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); for entry in WalkDir::new(stage_root).sort_by_file_name() { let entry = entry.with_context(|| format!("failed to walk {}", stage_root.display()))?; let path = entry.path(); let relative = path.strip_prefix(stage_root).with_context(|| { format!( "failed to strip {} from {}", stage_root.display(), path.display() ) })?; let archive_name = if relative.as_os_str().is_empty() { archive_root.clone() } else { format!( "{archive_root}/{}", relative.to_string_lossy().replace('\\', "/") ) }; if entry.file_type().is_dir() { if !relative.as_os_str().is_empty() { zip.add_directory(format!("{archive_name}/"), options) .with_context(|| { format!("failed to add directory {} to zip", path.display()) })?; } continue; } zip.start_file(&archive_name, options) .with_context(|| format!("failed to add file {} to zip", path.display()))?; let mut input = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; std::io::copy(&mut input, &mut zip) .with_context(|| format!("failed to copy {} into zip", path.display()))?; } zip.finish().context("failed to finalize zip archive")?; Ok(()) } /// Creates a gzip-compressed tar archive from the staged release tree. fn create_tar_gz_archive(stage_root: &Path, archive_path: &Path) -> Result<()> { if archive_path.exists() { std::fs::remove_file(archive_path) .with_context(|| format!("failed to remove {}", archive_path.display()))?; } let file = File::create(archive_path) .with_context(|| format!("failed to create {}", archive_path.display()))?; let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default()); let mut builder = tar::Builder::new(encoder); let archive_root = stage_root.file_name().ok_or_else(|| { anyhow!( "stage root did not include a directory name: {}", stage_root.display() ) })?; builder .append_dir_all(archive_root, stage_root) .with_context(|| format!("failed to package {}", stage_root.display()))?; builder .finish() .context("failed to finalize tar.gz archive")?; Ok(()) } /// Writes per-artifact and aggregate SHA-256 checksum files. fn write_checksum_files(paths: &[PathBuf], output_directory: &Path) -> Result { let mut archive_candidates = std::fs::read_dir(output_directory) .with_context(|| format!("failed to read {}", output_directory.display()))? .filter_map(std::result::Result::ok) .map(|entry| entry.path()) .filter(|path| path.is_file() && is_archive_path(path)) .collect::>(); archive_candidates.sort(); for path in paths { let hash = sha256_hex(path)?; let file_name = file_name_string(path)?; let line = format!("{hash} *{file_name}\n"); write_utf8_text(&PathBuf::from(format!("{}.sha256", path.display())), &line)?; } let mut lines = Vec::with_capacity(archive_candidates.len()); for path in archive_candidates { let hash = sha256_hex(&path)?; let file_name = file_name_string(&path)?; lines.push(format!("{hash} *{file_name}")); } let checksum_list_path = output_directory.join("SHA256SUMS.txt"); write_utf8_text(&checksum_list_path, &(lines.join("\n") + "\n"))?; Ok(checksum_list_path) } /// Computes a hex-encoded SHA-256 digest for a file. fn sha256_hex(path: &Path) -> Result { let mut file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; let mut hasher = Sha256::new(); let mut buffer = Box::new([0_u8; 64 * 1024]); loop { let read = file .read(&mut buffer[..]) .with_context(|| format!("failed to read {}", path.display()))?; if read == 0 { break; } hasher.update(&buffer[..read]); } Ok(format!("{:x}", hasher.finalize())) } /// Collects files included in the staged release tree. fn collect_included_files(stage_root: &Path) -> Result> { WalkDir::new(stage_root) .sort_by_file_name() .into_iter() .filter_map(std::result::Result::ok) .filter(|entry| entry.file_type().is_file()) .map(|entry| { let relative = entry.path().strip_prefix(stage_root).with_context(|| { format!( "failed to strip {} from {}", stage_root.display(), entry.path().display() ) })?; let metadata = entry.metadata().with_context(|| { format!("failed to read metadata for {}", entry.path().display()) })?; Ok(IncludedFile { relative_path: relative.to_string_lossy().replace('\\', "/"), size: metadata.len(), }) }) .collect() } /// Returns the trailing file name as UTF-8 text for manifest entries. fn file_name_string(path: &Path) -> Result { path.file_name() .ok_or_else(|| anyhow!("path did not include a file name: {}", path.display())) .map(|name| OsStr::to_string_lossy(name).into_owned()) } /// Returns the trailing file name as an owned OS string. fn file_name_os_string(path: &Path) -> Result { path.file_name() .ok_or_else(|| anyhow!("path did not include a file name: {}", path.display())) .map(OsStr::to_os_string) } /// Renders a path display adapter into an owned string. fn path_display_string(path: &Path) -> String { format!("{}", path.display()) } /// Converts a string slice into an owned OS string for command arguments. fn os(value: &str) -> OsString { OsString::from(value) } /// Renders a process exit status for release diagnostics. fn render_exit_status(status: ExitStatus) -> String { status .code() .map_or_else(|| String::from("signal"), |code| code.to_string()) } /// Returns whether a path looks like a release archive. fn is_archive_path(path: &Path) -> bool { let file_name = path .file_name() .map(|name| name.to_string_lossy()) .unwrap_or_default(); file_name.ends_with(".zip") || file_name.ends_with(".tar.gz") || file_name.ends_with(".tar") }