//! Native upload of built source packages (`pkh put`): the dput //! replacement. Resolves the upload target, discovers and validates the //! `.changes` file and its artifacts, then pushes them over SFTP with //! host-key verification and an upload record preventing accidental //! duplicate uploads. //! //! Payload files are uploaded first and the `.changes` file last, like //! dput does, so a partially uploaded set cannot be picked up by the //! server-side queue processors. A run that fails mid-upload removes its //! already-uploaded files from the incoming queue (best effort), so a //! retried upload starts from a clean queue; a failed upload is never //! recorded in the upload log, so a re-run replays every file. pub mod changes; pub mod ssh; pub mod target; use std::cmp::Ordering; use std::path::{Path, PathBuf}; use indicatif::{MultiProgress, ProgressBar}; use log::info; use serde::{Deserialize, Serialize}; use crate::debian::changelog::parse_changelog_entry; use crate::debian::checksums::FileChecksums; use crate::debian::control::ControlInfo; use crate::debian::version::DebianVersion; use crate::launchpad; use crate::ui; /// Everything `put` needs to run. pub struct PutOptions { /// PPA to upload to, `user/ppa_name` format. pub ppa: String, /// Explicit `.changes` file to upload; when `None`, the one matching the /// current package (from `debian/changelog`) is discovered next to the /// source tree. pub changes: Option, /// Upload even if this exact `.changes` file was already uploaded to the /// target. pub force: bool, /// Source package directory (the one containing `debian/`). pub cwd: PathBuf, } /// Upload the package described by `opts` to its target through `multi`'s /// progress bars. pub async fn put( opts: &PutOptions, multi: &MultiProgress, ) -> Result<(), Box> { let target = launchpad::ppa_target(&opts.ppa)?; let ssh_config = ssh::lookup_ssh_config(&target.fqdn); let host = ssh_config .host_name .clone() .unwrap_or_else(|| target.fqdn.clone()); let port = ssh_config.port.unwrap_or(target.port); // Launchpad's SFTP server requires a real Launchpad account name as the // username: the git configuration (`lp.user`) first, then an SSH // configuration `User`, then the local user name let login = launchpad::username(&opts.cwd) .or(ssh_config.user.clone()) .or(target.login.clone()) .or_else(|| std::env::var("USER").ok()) .ok_or_else(|| { format!( "cannot determine the Launchpad username for {host}: set it \ with `git config --global lp.user `, or \ with a 'User' in the SSH configuration for {host}" ) })?; let changes_path = match &opts.changes { Some(path) => path.clone(), None => discover_changes(&opts.cwd)?, }; let changes = changes::parse(&changes_path)?; // The summary line stays up for the whole flow: the completion message // replaces it on success, and the `PutBars` guard clears it on every // early `?` bail so the error logged by main is not preceded by stale // bars let mut bars = PutBars::default(); let summary = multi.add(ProgressBar::new(0)); // Style and prefix go in before the steady tick: otherwise the first // tick can render one frame with the default bar template summary.set_style(ui::spinner_style()); summary.set_prefix(format!( "Uploading {} {} to {}", changes.source, changes.version, target.label )); summary.enable_steady_tick(TICK); bars.track(summary.clone()); changes::validate(&changes)?; // Pre-flight checks for everything the upload queue only rejects after // processing: a valid Section, a known target series, and the target // PPA actually existing (the SFTP queue itself is a blind write) match section_check_target(opts.changes.as_deref(), &opts.cwd) { SectionCheckTarget::Dir(dir) => check_control_section(&dir, "ubuntu")?, SectionCheckTarget::Skip => log::warn!( "cannot check the Section: '{}' has no debian/control next to \ it, skipping the pre-flight (the archive still rejects uploads \ with unknown sections)", changes_path.display() ), } let checking = multi.add(ProgressBar::new(0)); checking.set_style(ui::spinner_style()); checking.set_prefix(format!("Checking {} on Launchpad...", target.label)); checking.enable_steady_tick(TICK); bars.track(checking.clone()); launchpad::ppa_info(&opts.ppa).await?; launchpad::check_ppa_series(&changes.distribution).await?; clear_bar(&checking); // Last pre-flight check: a version the PPA already publishes at or // above the changes' one would supersede (or reject) this upload let published = launchpad::published_versions(&opts.ppa, &changes.source).await?; check_not_superseded(&target, &changes, &published, opts.force)?; let record = upload_record(&target, &changes_path)?; if !opts.force && let Some(previous) = find_previous_upload(&upload_log_path()?, &record)? { return Err(format!( "'{}' was already uploaded to {} on {} (use --force to upload again)", record.file, target.label, previous.date ) .into()); } let connecting = multi.add(ProgressBar::new(0)); connecting.set_style(ui::spinner_style()); connecting.set_prefix(format!("Connecting to {login}@{host}:{port}...")); connecting.enable_steady_tick(TICK); bars.track(connecting.clone()); let session = ssh::connect(&host, port, &login, &ssh_config)?; let sftp = ssh::sftp(&session)?; clear_bar(&connecting); // Payload first, the .changes file last (like dput), so the server-side // queue processor can never pick up an incomplete upload let dir = changes_path.parent().unwrap_or_else(|| Path::new(".")); let mut uploads: Vec<(PathBuf, String)> = changes .files .iter() .map(|f| (dir.join(&f.name), f.name.clone())) .collect(); let changes_name = changes_path .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| format!("invalid .changes path: {}", changes_path.display()))? .to_string(); uploads.push((changes_path.clone(), changes_name.clone())); let incoming = target.incoming.trim_end_matches('/'); // Remote names of the files uploaded so far, in upload order (the // .changes last). A run failing mid-upload removes these from the // write-only incoming queue before returning: the uploaded payloads // would otherwise linger in the queue area forever, and a .changes // truncated by a failed close could even be picked up by the scanner. let mut uploaded: Vec = Vec::new(); for (path, name) in &uploads { let size = match path.metadata() { Ok(metadata) => metadata.len(), Err(e) => { // Nothing was attempted for this file: only what earlier // iterations uploaded needs removing let error: Box = format!("cannot stat '{}': {}", path.display(), e).into(); cleanup_partial_upload(&sftp, incoming, &uploaded, None, &host); return Err(error); } }; // Same transfer view as pull: prefix line, bar on its own line let bar = multi.add(ProgressBar::new(size)); bar.enable_steady_tick(std::time::Duration::from_millis(50)); bar.set_style(ui::transfer_style()); bar.set_prefix(format!("Uploading {name}...")); let remote = format!("{incoming}/{name}"); let result = ssh::upload_file(&sftp, path, &remote, &host, &bar); bar.finish_and_clear(); if let Err(e) = result { // The failed file itself joins the cleanup: its remote `create` // may have succeeded before the failure, leaving a partial — or, // on a failed close, a truncated — file behind cleanup_partial_upload(&sftp, incoming, &uploaded, Some(name), &host); return Err(e); } uploaded.push(name.clone()); } // Recorded only once the whole upload succeeded: the log backs the // duplicate-upload guard, and a failed upload must not count as // uploaded (a re-run replays every file — `sftp.create` truncates, so // replaying is safe). record_upload(&upload_log_path()?, &record)?; // The completion lines replace the summary bar; the guard's own clear // at scope exit is a no-op for the already-finished bars clear_bar(&summary); info!( "Upload of {} {} to {} complete.", changes.source, changes.version, target.label ); info!( "Launchpad processes it asynchronously; watch the PPA page or your \ inbox for acceptance/rejection" ); Ok(()) } /// The remote names to attempt removing after a failed upload: everything /// already uploaded plus, when set, `failed` (the file whose upload just /// failed — its remote `create` may have succeeded before the failure, /// leaving a partial or truncated file behind) — in reverse upload order, /// so a `.changes` is removed before the payloads it references and the /// queue scanner never observes the payload set shrinking under a /// still-present `.changes`. Pure so the ordering decision is testable /// without a server; the network side is [`cleanup_partial_upload`]. fn cleanup_list(uploaded: &[String], failed: Option<&str>) -> Vec { let mut names: Vec = uploaded.to_vec(); if let Some(failed) = failed { names.push(failed.to_string()); } names.reverse(); names } /// Best-effort removal of what a failed upload left in the target's /// incoming queue: the already-uploaded payloads, and a `.changes` /// truncated by a failed close, would otherwise linger in the write-only /// area until the queue is manually cleaned. Launchpad re-validates every /// upload, so this is pure hygiene: a removal failure is logged and /// skipped, and the caller returns the original upload error — never a /// cleanup one. fn cleanup_partial_upload( sftp: &ssh2::Sftp, incoming: &str, uploaded: &[String], failed: Option<&str>, host: &str, ) { for name in cleanup_list(uploaded, failed) { let remote = format!("{incoming}/{name}"); match ssh::remove_file(sftp, &remote, host) { Ok(()) => info!("Removed leftover {remote} from the failed upload"), Err(e) => { log::warn!("Could not remove the leftover {remote} of the failed upload: {e}") } } } } /// Steady tick interval of every bar rendered by a `put` run const TICK: std::time::Duration = std::time::Duration::from_millis(50); /// Stop `bar`'s steady tick and clear it from the terminal. The tick is /// disabled first: a tick firing right after the clear would redraw a stale /// frame, the race [`crate::ui::deb::DebUi::suspend`] guards against too. /// Clearing twice is harmless: finished bars stay finished. fn clear_bar(bar: &ProgressBar) { bar.disable_steady_tick(); bar.finish_and_clear(); } /// The bars rendered by one `put` run, cleared when the guard drops. The /// error paths bail out early through `?` and `main` logs the error /// afterwards, so a bar left unfinished would linger on screen above it. #[derive(Default)] struct PutBars { bars: Vec, } impl PutBars { /// Track `bar` so it is cleared with the rest when the guard drops fn track(&mut self, bar: ProgressBar) { self.bars.push(bar); } } impl Drop for PutBars { fn drop(&mut self) { for bar in &self.bars { clear_bar(bar); } } } /// Validate the source package's `Section` (debian/control source stanza) /// against the distribution's valid sections: a bare section or a /// `section/subsection` is accepted. Archives reject uploads carrying an /// unknown section during queue processing, so this fails before anything /// is uploaded. fn check_control_section(cwd: &Path, dist: &str) -> Result<(), Box> { let control_path = cwd.join("debian/control"); let control = ControlInfo::parse(&control_path)?; let section = control.section(); if section == "-" { return Err(format!( "'{}' has no Section field: add one (e.g. utils, devel, net), \ the upload would be rejected otherwise", control_path.display() ) .into()); } let base = section.split('/').next().unwrap_or(section); if crate::distro_info::get_sections(dist)? .iter() .any(|valid| valid == base) { return Ok(()); } Err(format!( "Invalid section '{section}' in '{}': {dist} rejects uploads with \ unknown sections. Pick a valid one in debian/control (e.g. utils, \ devel, net, graphics, sound...)", control_path.display() ) .into()) } /// What the Section pre-flight decided to validate: a directory holding a /// `debian/control` ([`check_control_section`]'s expected layout), or that /// it cannot run where the upload lives and must be skipped. #[derive(Debug, PartialEq, Eq)] enum SectionCheckTarget { /// Validate `debian/control` in this directory Dir(PathBuf), /// No `debian/control` next to the `.changes`: skip the check Skip, } /// Decide which tree the Section pre-flight validates. An explicit /// `--changes` file must be followed by its own directory — it can name a /// different package than the current tree — so the check runs against the /// `.changes`' directory when it directly holds a `debian/control` (the /// `.changes` inside the package root), and is skipped otherwise: pkh /// writes the `.changes` next to the source tree (an artifacts-only /// directory as far as `debian/control` goes), and checking `cwd` there /// would validate whatever package the user happens to be in. Without /// `--changes` the upload comes from the current tree, which keeps getting /// checked exactly as before. Pure decision, factored out so the /// wrong-tree rule is testable with plain tempdirs. fn section_check_target(changes_path: Option<&Path>, cwd: &Path) -> SectionCheckTarget { let Some(changes) = changes_path else { return SectionCheckTarget::Dir(cwd.to_path_buf()); }; let dir = changes.parent().unwrap_or_else(|| Path::new(".")); if dir.join("debian/control").is_file() { SectionCheckTarget::Dir(dir.to_path_buf()) } else { SectionCheckTarget::Skip } } /// The highest version among `published` that parses as a Debian version, /// `None` when the list is empty or nothing in it parses (entries that /// cannot be parsed are skipped rather than failing the check: the API /// data is trusted to carry valid Debian versions) fn max_published(published: &[String]) -> Option { published .iter() .filter_map(|version| DebianVersion::parse(version).ok()) .max() } /// Refuse an upload the target PPA already supersedes: when a version equal /// to or higher than the changes' one is published there for the same /// source, the queue would reject the upload (equal version) or silently /// supersede it (older one) — Launchpad never replaces a published version /// with a lower one. `force` downgrades the refusal to a warning. fn check_not_superseded( target: &target::UploadTarget, changes: &changes::ChangesFile, published: &[String], force: bool, ) -> Result<(), Box> { let local = match DebianVersion::parse(&changes.version) { Ok(version) => version, Err(e) => { // Only the pre-flight comparison is skipped: the upload itself // is valid as far as this check is concerned log::warn!( "cannot compare {} against the published versions of {} ({e}): \ skipping the published-version check", changes.version, changes.source ); return Ok(()); } }; let Some(latest) = max_published(published) else { return Ok(()); }; if local.cmp(&latest) == Ordering::Greater { return Ok(()); } let bump = "bump the version with `pkh chlog` and rebuild, or pass --force to upload anyway"; if force { log::warn!( "{} {} is superseded by {} {} already published in {}: \ uploading anyway (--force)", changes.source, local.full(), changes.source, latest.full(), target.label ); return Ok(()); } match local.cmp(&latest) { Ordering::Equal => Err(format!( "{} {} is already published in {} (published version {}): {bump}", changes.source, local.full(), target.label, latest.full() ) .into()), _ => Err(format!( "{} {} is older than the {} {} already published in {}: {bump}", changes.source, local.full(), changes.source, latest.full(), target.label ) .into()), } } /// Find the `.changes` file of the package in `cwd`: the exact /// `__source.changes` written by `pkh build` (in the source /// directory's parent or the directory itself), or the only other /// `_*.changes` present. Errors listing the candidates when several /// exist. fn discover_changes(cwd: &Path) -> Result> { let entry = parse_changelog_entry(&cwd.join("debian/changelog"))?; let sversion = entry.version.no_epoch(); let expected_name = format!("{}_{}_source.changes", entry.source, sversion); let mut search_dirs: Vec = Vec::new(); if let Some(parent) = cwd.parent() { search_dirs.push(parent.to_path_buf()); } search_dirs.push(cwd.to_path_buf()); for dir in &search_dirs { let candidate = dir.join(&expected_name); if candidate.is_file() { return Ok(candidate); } } // The exact build output is gone: accept any other single .changes of // this package, preferring source uploads let prefix = format!("{}_", entry.source); let mut candidates: Vec = Vec::new(); for dir in &search_dirs { if let Ok(entries) = dir.read_dir() { for entry in entries.flatten() { let path = entry.path(); let Some(name) = path.file_name().and_then(|n| n.to_str()) else { continue; }; if name.starts_with(&prefix) && name.ends_with(".changes") && path.is_file() { candidates.push(path); } } } } let source_only: Vec<&PathBuf> = candidates .iter() .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.ends_with("_source.changes")) }) .collect(); if let [only] = source_only.as_slice() { log::warn!( "'{expected_name}' not found, uploading the other build output {}", only.display() ); return Ok((*only).clone()); } match candidates.as_slice() { [only] => { log::warn!( "'{expected_name}' not found, uploading the other build output {}", only.display() ); Ok(only.clone()) } [] => Err(format!( "no .changes file for package '{}' found (looked in the package \ directory and its parent): build one with `pkh build` first", entry.source ) .into()), many => Err(format!( "multiple .changes files found for package '{}': {}. \ Pass the one to upload explicitly", entry.source, many.iter() .map(|p| ui::display_path(p)) .collect::>() .join(", ") ) .into()), } } /// One recorded upload: target, `.changes` name, its SHA-256 digest and the /// upload date. Matching on the digest makes `--force` the only way to /// re-upload an identical file, while a rebuilt file (new digest) never /// trips the guard. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct UploadRecord { target: String, file: String, sha256: String, date: String, } /// Build the upload record for a `.changes` file, hashing its content fn upload_record( target: &target::UploadTarget, changes: &Path, ) -> Result> { let mut checksums = FileChecksums::new(); checksums.add_file(changes)?; let entry = checksums .get(changes.file_name().and_then(|n| n.to_str()).unwrap_or("")) .ok_or_else(|| format!("cannot hash '{}'", changes.display()))?; Ok(UploadRecord { target: target.label.clone(), file: changes .file_name() .and_then(|n| n.to_str()) .unwrap_or_default() .to_string(), sha256: entry.sha256.clone(), date: chrono::Local::now().format("%Y-%m-%d %H:%M").to_string(), }) } /// Path of the upload log (`/pkh/uploads.json`) fn upload_log_path() -> Result> { let dirs = directories::ProjectDirs::from("com", "pkh", "pkh") .ok_or("cannot determine the pkh data directory")?; let path = dirs.data_dir().join("uploads.json"); if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } Ok(path) } /// Load the upload log, empty when the file does not exist yet. /// /// The log only backs the advisory duplicate-upload guard (Launchpad /// re-verifies every upload), so a corrupt log must not abort the upload: /// like the context configuration in [`crate::context::manager`], the corrupt /// file is first backed up to `.bak` (best effort) so a later /// [`record_upload`] cannot destroy it, then an empty log is used. fn load_upload_log(path: &Path) -> Vec { let content = match std::fs::read_to_string(path) { Ok(content) => content, Err(e) => { if e.kind() != std::io::ErrorKind::NotFound { log::error!( "Cannot read upload log {}: {e}; continuing as if nothing had been uploaded", path.display() ); } return Vec::new(); } }; match serde_json::from_str(&content) { Ok(entries) => entries, Err(e) => { log::error!( "Upload log {} is corrupt ({e}); ignoring its content, earlier uploads may not be detected as duplicates anymore", path.display() ); backup_corrupt_log(path); Vec::new() } } } /// Back up a corrupt upload log (best effort) so a later /// [`record_upload`] cannot silently destroy its content. fn backup_corrupt_log(path: &Path) { let mut os = path.as_os_str().to_os_string(); os.push(".bak"); let backup_path = PathBuf::from(os); match std::fs::copy(path, &backup_path) { Ok(_) => log::warn!("Corrupt upload log backed up to {}", backup_path.display()), Err(e) => log::warn!( "Could not back up corrupt upload log to {}: {e}", backup_path.display() ), } } /// The previous upload of `record` from the log at `log`, if any (same /// target, file and content) fn find_previous_upload( log: &Path, record: &UploadRecord, ) -> Result, Box> { Ok(load_upload_log(log) .into_iter() .find(|r| r.target == record.target && r.file == record.file && r.sha256 == record.sha256)) } /// Append `record` to the upload log at `log` fn record_upload(log: &Path, record: &UploadRecord) -> Result<(), Box> { let mut entries = load_upload_log(log); entries.push(record.clone()); // Write to a temporary file next to the log, then rename it over the // log: rename is atomic within a filesystem, so a crash mid-write leaves // the previous log intact instead of a truncated (corrupt) file. // The temporary file is created with default permissions; the log holds // no secrets and lives in the user's data directory, so the previous // mode is not carried over (same choice as `Files::save_atomic`). let tmp = log.with_extension("new"); std::fs::write(&tmp, serde_json::to_string_pretty(&entries)?) .map_err(|e| format!("cannot write '{}': {}", tmp.display(), e))?; std::fs::rename(&tmp, log).map_err(|e| format!("cannot install '{}': {}", log.display(), e))?; Ok(()) } #[cfg(test)] mod tests { use super::*; /// Changelog of a package `hello` at version `1.0-1` fn changelog_fixture(dir: &Path) { std::fs::create_dir_all(dir.join("debian")).unwrap(); std::fs::write( dir.join("debian/changelog"), "hello (1.0-1) resolute; urgency=medium\n\n * Something.\n\n \ -- A B Mon, 01 Sep 2025 10:00:00 +0000\n", ) .unwrap(); } #[test] fn discover_finds_exact_build_output_in_parent() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("hello"); changelog_fixture(&pkg); std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); let found = discover_changes(&pkg).unwrap(); assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); } #[test] fn discover_finds_exact_build_output_in_cwd() { let dir = tempfile::tempdir().unwrap(); changelog_fixture(dir.path()); std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); let found = discover_changes(dir.path()).unwrap(); assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); } #[test] fn discover_prefers_source_changes_among_several() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("hello"); changelog_fixture(&pkg); // A stale exact match does not exist; a source changes and a binary // changes are present std::fs::write(dir.path().join("hello_1.0-1_source.changes"), b"changes").unwrap(); std::fs::write(dir.path().join("hello_1.0-1_amd64.changes"), b"binary").unwrap(); let found = discover_changes(&pkg).unwrap(); assert_eq!(found, dir.path().join("hello_1.0-1_source.changes")); } #[test] fn discover_errors_when_nothing_matches() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("hello"); changelog_fixture(&pkg); let err = discover_changes(&pkg).unwrap_err().to_string(); assert!(err.contains("no .changes file"), "unexpected: {err}"); } /// debian/control's Section must be a known distribution section; the /// `section/subsection` form validates on the part before the '/' #[test] fn control_section_check() { let dir = tempfile::tempdir().unwrap(); std::fs::create_dir_all(dir.path().join("debian")).unwrap(); let control = dir.path().join("debian/control"); std::fs::write(&control, "Source: hello\nSection: utils\n").unwrap(); assert!(check_control_section(dir.path(), "ubuntu").is_ok()); std::fs::write(&control, "Source: hello\nSection: devel/i386\n").unwrap(); assert!(check_control_section(dir.path(), "ubuntu").is_ok()); std::fs::write(&control, "Source: hello\nSection: unknown\n").unwrap(); let err = check_control_section(dir.path(), "ubuntu") .unwrap_err() .to_string(); assert!( err.contains("Invalid section 'unknown'"), "unexpected: {err}" ); std::fs::write(&control, "Source: hello\n").unwrap(); let err = check_control_section(dir.path(), "ubuntu") .unwrap_err() .to_string(); assert!(err.contains("has no Section"), "unexpected: {err}"); } /// An explicit `--changes` file of a different package must be checked /// in its own directory (A), not in the current tree (B): B's control /// file decided the pre-flight before the fix #[test] fn section_check_follows_explicit_changes_directory() { let dir = tempfile::tempdir().unwrap(); let artifacts = dir.path().join("artifacts"); let elsewhere = dir.path().join("elsewhere"); std::fs::create_dir_all(artifacts.join("debian")).unwrap(); std::fs::create_dir_all(elsewhere.join("debian")).unwrap(); let changes = artifacts.join("hello_1.0-1_source.changes"); std::fs::write(&changes, b"changes").unwrap(); std::fs::write( artifacts.join("debian/control"), "Source: hello\nSection: utils\n", ) .unwrap(); std::fs::write( elsewhere.join("debian/control"), "Source: other\nSection: unknown\n", ) .unwrap(); assert_eq!( section_check_target(Some(&changes), &elsewhere), SectionCheckTarget::Dir(artifacts.clone()) ); // The decided directory carries the valid section, while the tree // the old code checked would have failed the upload match section_check_target(Some(&changes), &elsewhere) { SectionCheckTarget::Dir(d) => check_control_section(&d, "ubuntu").unwrap(), SectionCheckTarget::Skip => panic!("the .changes directory has debian/control"), } assert!(check_control_section(&elsewhere, "ubuntu").is_err()); } /// A `.changes` in a directory without `debian/control` — pkh's own /// layout puts it next to the source tree, and artifacts can be /// collected away from any source — skips the check instead of /// validating whatever tree `cwd` points at #[test] fn section_check_skips_when_changes_dir_has_no_control() { let dir = tempfile::tempdir().unwrap(); let artifacts = dir.path().join("artifacts"); std::fs::create_dir_all(&artifacts).unwrap(); let changes = artifacts.join("hello_1.0-1_source.changes"); std::fs::write(&changes, b"changes").unwrap(); assert_eq!( section_check_target(Some(&changes), dir.path()), SectionCheckTarget::Skip ); } /// Without `--changes` the upload comes from the current tree: the /// decision stays the cwd, byte-identical to the pre-fix behavior #[test] fn section_check_without_changes_validates_cwd() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("hello"); std::fs::create_dir_all(pkg.join("debian")).unwrap(); assert_eq!( section_check_target(None, &pkg), SectionCheckTarget::Dir(pkg) ); } #[test] fn discover_errors_when_several_candidates() { let dir = tempfile::tempdir().unwrap(); let pkg = dir.path().join("hello"); changelog_fixture(&pkg); std::fs::write(dir.path().join("hello_1.0-1_amd64.changes"), b"binary").unwrap(); std::fs::write(dir.path().join("hello_1.0-2_amd64.changes"), b"binary2").unwrap(); let err = discover_changes(&pkg).unwrap_err().to_string(); assert!(err.contains("multiple .changes"), "unexpected: {err}"); } #[test] fn upload_record_is_stable_and_content_hashed() { let dir = tempfile::tempdir().unwrap(); let changes = dir.path().join("hello_1.0-1_source.changes"); std::fs::write(&changes, b"changes content").unwrap(); let target = launchpad::ppa_target("user/ppa").unwrap(); let record = upload_record(&target, &changes).unwrap(); assert_eq!(record.target, "ppa:user/ppa"); assert_eq!(record.file, "hello_1.0-1_source.changes"); // sha256("changes content") assert_eq!( record.sha256, "104a1c78c7f8e6d28da700ac5eed27fd9cdace4a5d9b3caeeb08ab7558ece6b0" ); } #[test] fn upload_log_round_trip_and_dedup() { let dir = tempfile::tempdir().unwrap(); let log = dir.path().join("uploads.json"); let record = UploadRecord { target: "ppa:user/ppa".to_string(), file: "hello_1.0-1_source.changes".to_string(), sha256: "abc".to_string(), date: "2025-09-16 12:00".to_string(), }; assert!(find_previous_upload(&log, &record).unwrap().is_none()); record_upload(&log, &record).unwrap(); record_upload(&log, &record).unwrap(); let previous = find_previous_upload(&log, &record).unwrap().unwrap(); assert_eq!(previous.date, record.date); // A rebuilt file (different digest) is not a duplicate let mut rebuilt = record.clone(); rebuilt.sha256 = "def".to_string(); assert!(find_previous_upload(&log, &rebuilt).unwrap().is_none()); } /// A plain upload record for the log tests fn upload_log_record(sha256: &str) -> UploadRecord { UploadRecord { target: "ppa:user/ppa".to_string(), file: "hello_1.0-1_source.changes".to_string(), sha256: sha256.to_string(), date: "2025-09-16 12:00".to_string(), } } /// A corrupt log must not be silently reset: it is backed up byte for /// byte, and the guard restarts from an empty log. #[test] fn corrupt_upload_log_is_backed_up_and_reset() { let dir = tempfile::tempdir().unwrap(); let log = dir.path().join("uploads.json"); let corrupt = b"{ \"not\": valid json"; std::fs::write(&log, corrupt).unwrap(); assert!(load_upload_log(&log).is_empty()); let backup = dir.path().join("uploads.json.bak"); assert_eq!(std::fs::read(&backup).unwrap(), corrupt); // Recording after a corrupt log starts a fresh, parseable log // without clobbering the backup record_upload(&log, &upload_log_record("abc")).unwrap(); assert_eq!(load_upload_log(&log), vec![upload_log_record("abc")]); assert_eq!(std::fs::read(&backup).unwrap(), corrupt); } /// `record_upload` leaves a log that parses back, with no temporary /// file left behind #[test] fn recorded_upload_parses_back_atomically() { let dir = tempfile::tempdir().unwrap(); let log = dir.path().join("uploads.json"); record_upload(&log, &upload_log_record("abc")).unwrap(); assert_eq!(load_upload_log(&log), vec![upload_log_record("abc")]); assert!(!dir.path().join("uploads.new").exists()); } /// Recording a second upload appends to the log instead of replacing it #[test] fn record_over_existing_log_preserves_prior_entries() { let dir = tempfile::tempdir().unwrap(); let log = dir.path().join("uploads.json"); record_upload(&log, &upload_log_record("abc")).unwrap(); record_upload(&log, &upload_log_record("def")).unwrap(); assert_eq!( load_upload_log(&log), vec![upload_log_record("abc"), upload_log_record("def")] ); } /// A minimal in-memory changes file for the superseded-version check fn changes_fixture(version: &str) -> changes::ChangesFile { changes::ChangesFile { path: PathBuf::from("hello_1.0-1_source.changes"), source: "hello".to_string(), version: version.to_string(), distribution: "resolute".to_string(), files: Vec::new(), } } fn versions(list: &[&str]) -> Vec { list.iter().map(|v| v.to_string()).collect() } #[test] fn superseded_check_rejects_equal_published_version() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("1.2.3-1"); let err = check_not_superseded(&target, &changes, &versions(&["1.2.3-1"]), false) .unwrap_err() .to_string(); assert!( err.contains("hello 1.2.3-1 is already published in ppa:user/ppa"), "unexpected: {err}" ); assert!( err.contains("published version 1.2.3-1"), "unexpected: {err}" ); assert!(err.contains("--force"), "unexpected: {err}"); } #[test] fn superseded_check_rejects_older_upload() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("1.0-1"); let err = check_not_superseded(&target, &changes, &versions(&["2.0-1"]), false) .unwrap_err() .to_string(); assert!( err.contains("hello 1.0-1 is older than the hello 2.0-1 already published"), "unexpected: {err}" ); } #[test] fn superseded_check_accepts_newer_version() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("2.0-1"); check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap(); } #[test] fn superseded_check_accepts_never_published_source() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("1.0-1"); check_not_superseded(&target, &changes, &[], false).unwrap(); } /// The comparison follows dpkg ordering: `~` pre-releases, epochs and /// Ubuntu revision suffixes all sort correctly #[test] fn superseded_check_uses_dpkg_ordering() { let target = launchpad::ppa_target("user/ppa").unwrap(); // A tilde pre-release is older than the real release let changes = changes_fixture("1.0~rc1-1"); assert!(check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).is_err()); // The real release supersedes the pre-release let changes = changes_fixture("1.0-1"); check_not_superseded(&target, &changes, &versions(&["1.0~rc1-1"]), false).unwrap(); // An epoch outranks a higher upstream version let changes = changes_fixture("2.0-1"); assert!(check_not_superseded(&target, &changes, &versions(&["1:1.0-1"]), false).is_err()); // ubuntu1.1 point updates supersede the base upload let changes = changes_fixture("1.0-1ubuntu1"); assert!( check_not_superseded(&target, &changes, &versions(&["1.0-1ubuntu1.1"]), false).is_err() ); } #[test] fn superseded_check_force_warns_instead_of_erroring() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("1.0-1"); check_not_superseded(&target, &changes, &versions(&["1.0-1", "0.9-1"]), true).unwrap(); } /// Versions the parser rejects never fail the upload: an unparseable /// local version skips the check, unparseable published entries are /// ignored #[test] fn superseded_check_tolerates_unparseable_versions() { let target = launchpad::ppa_target("user/ppa").unwrap(); let changes = changes_fixture("not_a_debian_version"); check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap(); let changes = changes_fixture("1.0-1"); check_not_superseded(&target, &changes, &versions(&["1.0_beta", "0.9-1"]), false).unwrap(); } /// Several published versions (multiple series/pockets): the highest /// one decides #[test] fn superseded_check_compares_against_highest_published() { let target = launchpad::ppa_target("user/ppa").unwrap(); let published = versions(&["1.0-1", "1.6-1", "1.2-3"]); // 1.5-1 supersedes everything but the 1.6-1 entry, which decides let changes = changes_fixture("1.5-1"); let err = check_not_superseded(&target, &changes, &published, false) .unwrap_err() .to_string(); assert!(err.contains("1.6-1"), "unexpected: {err}"); // ...and going one higher than the highest entry passes let changes = changes_fixture("1.7-1"); check_not_superseded(&target, &changes, &published, false).unwrap(); } /// The upload loop is network-bound and `ssh2::Sftp` cannot be built /// without a live SSH session, so there is no stub seam for it without /// heavy refactoring; the cleanup decision is factored into the pure /// [`cleanup_list`] and tested directly instead. /// /// Failure during the final `.changes` upload (the dangerous case: a /// partial or close-truncated `.changes` is scanner-visible): the /// `.changes` is removed first, then the payloads it references, so the /// queue never sees a `.changes` over a shrinking payload set. #[test] fn cleanup_list_removes_changes_first_then_payloads_in_reverse() { let uploaded = vec![ "hello_1.0.orig.tar.xz".to_string(), "hello_1.0-1.debian.tar.xz".to_string(), "hello_1.0-1.dsc".to_string(), ]; assert_eq!( cleanup_list(&uploaded, Some("hello_1.0-1_source.changes")), vec![ "hello_1.0-1_source.changes", "hello_1.0-1.dsc", "hello_1.0-1.debian.tar.xz", "hello_1.0.orig.tar.xz", ] ); } /// A failure during a payload upload: the `.changes` was never /// attempted (it is uploaded last), so it must not appear in the /// cleanup list — only the failed payload (a partial may exist) and the /// earlier payloads. #[test] fn cleanup_list_before_the_changes_covers_the_failed_payload() { let uploaded = vec!["hello_1.0.orig.tar.xz".to_string()]; assert_eq!( cleanup_list(&uploaded, Some("hello_1.0-1.debian.tar.xz")), vec!["hello_1.0-1.debian.tar.xz", "hello_1.0.orig.tar.xz"] ); } /// A stat failure happens before anything was attempted for that file, /// so only what earlier iterations uploaded is removed — and with /// nothing uploaded yet there is nothing to remove at all. #[test] fn cleanup_list_without_failed_file_keeps_uploaded_only() { assert!(cleanup_list(&[], None).is_empty()); assert_eq!( cleanup_list(&["hello_1.0.orig.tar.xz".to_string()], None), vec!["hello_1.0.orig.tar.xz"] ); } }