From 54cb04ba2779c6231fbb6c93b6bf61c1c094e136 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Fri, 18 Sep 2026 20:42:17 +0200 Subject: [PATCH] put: report through the view and ask the host-key question through the Prompter put() loses its MultiProgress parameter: the summary, pre-flight and connection spinners become view messages, the per-file SFTP transfer reports determinate progress through view.progress (upload_file takes a byte-count callback instead of an indicatif bar), and the display is released through view.suspend on every exit path. The hardcoded trust-on-first-use prompt in the SSH host-key verification becomes the Prompter::accept_host_key port (fail-closed by default; the terminal prompter prints the authenticity banner and confirms), so a remote frontend can surface its own host-key dialog. --- src/main.rs | 6 ++- src/put/mod.rs | 109 +++++++++++++---------------------------------- src/put/ssh.rs | 20 ++++----- src/report.rs | 9 ++++ src/ui/deb.rs | 3 +- src/ui/prompt.rs | 8 ++++ 6 files changed, 62 insertions(+), 93 deletions(-) diff --git a/src/main.rs b/src/main.rs index 73b02d6..e67ecd6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -564,13 +564,17 @@ fn main() { std::process::exit(1); }; + let view = pkh::ui::deb::DebUi::new(&multi); + let prompter = pkh::ui::prompt::TerminalPrompter; let options = pkh::put::PutOptions { ppa: ppa.to_string(), changes, force, cwd, + view: &view, + prompter: &prompter, }; - if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) { + if let Err(e) = rt.block_on(async { pkh::put::put(&options).await }) { error!("{}", e); std::process::exit(1); } diff --git a/src/put/mod.rs b/src/put/mod.rs index d696109..40b033d 100644 --- a/src/put/mod.rs +++ b/src/put/mod.rs @@ -18,7 +18,6 @@ pub mod target; use std::cmp::Ordering; use std::path::{Path, PathBuf}; -use indicatif::{MultiProgress, ProgressBar}; use log::info; use serde::{Deserialize, Serialize}; @@ -27,10 +26,10 @@ use crate::debian::checksums::FileChecksums; use crate::debian::control::ControlInfo; use crate::debian::version::DebianVersion; use crate::launchpad; -use crate::ui; +use crate::report::{BuildView, Prompter}; /// Everything `put` needs to run. -pub struct PutOptions { +pub struct PutOptions<'a> { /// PPA to upload to, `user/ppa_name` format. pub ppa: String, /// Explicit `.changes` file to upload; when `None`, the one matching the @@ -42,14 +41,25 @@ pub struct PutOptions { pub force: bool, /// Source package directory (the one containing `debian/`). pub cwd: PathBuf, + /// Where the upload progress (status messages, per-file byte counts) is + /// reported. + pub view: &'a dyn BuildView, + /// Who answers the host-key question on first contact with the target + /// server (fail-closed when nobody can be asked). + pub prompter: &'a dyn Prompter, } -/// Upload the package described by `opts` to its target through `multi`'s -/// progress bars. -pub async fn put( - opts: &PutOptions, - multi: &MultiProgress, -) -> Result<(), Box> { +/// Upload the package described by `opts` to its target, reporting progress +/// through the view and asking the prompter when the server is unknown. +pub async fn put(opts: &PutOptions<'_>) -> Result<(), Box> { + // The display is released on every path (success and early `?` bails) + // before the outcome is logged, so no stale status line lingers above it + let result = put_impl(opts).await; + opts.view.suspend(); + result +} + +async fn put_impl(opts: &PutOptions<'_>) -> Result<(), Box> { let target = launchpad::ppa_target(&opts.ppa)?; let ssh_config = ssh::lookup_ssh_config(&target.fqdn); @@ -79,21 +89,10 @@ pub async fn put( }; 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!( + opts.view.message(&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 @@ -109,14 +108,10 @@ pub async fn put( ), } - 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()); + opts.view + .message(&format!("Checking {} on Launchpad...", target.label)); 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 @@ -134,14 +129,10 @@ pub async fn put( .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)?; + opts.view + .message(&format!("Connecting to {login}@{host}:{port}...")); + let session = ssh::connect(&host, port, &login, &ssh_config, opts.prompter)?; 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 @@ -179,15 +170,11 @@ pub async fn put( 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(); + let label = format!("Uploading {name}"); + let view = opts.view; + let on_progress = |uploaded: u64| view.progress(&label, uploaded as usize, size as usize); + let result = ssh::upload_file(&sftp, path, &remote, &host, &on_progress); 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, @@ -204,9 +191,6 @@ pub async fn put( // 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 @@ -260,41 +244,6 @@ fn cleanup_partial_upload( } } -/// 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 diff --git a/src/put/ssh.rs b/src/put/ssh.rs index 8ce79a5..38e8d14 100644 --- a/src/put/ssh.rs +++ b/src/put/ssh.rs @@ -20,14 +20,13 @@ use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::path::{Path, PathBuf}; use std::time::Duration; -use indicatif::ProgressBar; use log::debug; use serde::Deserialize; use sha2::{Digest, Sha256}; use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session}; use crate::data::embed_data; -use crate::ui::prompt; +use crate::report::Prompter; /// Pinned SSH host key fingerprints, loaded from the bundled /// `host_keys.yml` data file (same pattern as `distro_info.yml`): data @@ -303,6 +302,7 @@ pub fn connect( port: u16, login: &str, config: &SshConfig, + prompter: &dyn Prompter, ) -> Result> { let tcp = tcp_connect(host, port)?; @@ -326,7 +326,7 @@ pub fn connect( let (key, key_type) = session .host_key() .ok_or_else(|| format!("{host} offered no host key"))?; - verify_host_key(host, port, key, key_type)?; + verify_host_key(host, port, key, key_type, prompter)?; authenticate(&session, host, login, config)?; @@ -353,6 +353,7 @@ fn verify_host_key( port: u16, key: &[u8], key_type: HostKeyType, + prompter: &dyn Prompter, ) -> Result<(), Box> { let fingerprint = fingerprint(key); @@ -387,12 +388,7 @@ fn verify_host_key( format!("[{host}]:{port}") }; - // The banner is plain output: the confirmation prompt itself - // must stay a single line for its redraw logic - println!("The authenticity of host '{display}' can't be established."); - println!("{key_type_desc} key fingerprint is {fingerprint}."); - let accepted = prompt::confirm("Accept and store this host key?", false)?; - if !accepted { + if !prompter.accept_host_key(&display, key_type_desc, &fingerprint) { return Err(format!("Host key for {display} rejected, aborting upload").into()); } if let Some(name) = key_type_name(key_type) { @@ -592,7 +588,7 @@ pub fn upload_file( local: &Path, remote: &str, host: &str, - bar: &ProgressBar, + on_progress: &dyn Fn(u64), ) -> Result<(), Box> { let mut local_file = fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?; @@ -602,6 +598,7 @@ pub fn upload_file( .map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?; let mut buf = [0u8; 32 * 1024]; + let mut uploaded: u64 = 0; loop { let n = local_file.read(&mut buf)?; if n == 0 { @@ -610,7 +607,8 @@ pub fn upload_file( remote_file .write_all(&buf[..n]) .map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?; - bar.inc(n as u64); + uploaded += n as u64; + on_progress(uploaded); } // Close explicitly: quota-exceeded and similar failures only surface in diff --git a/src/report.rs b/src/report.rs index 9cc43ed..bb1e5e9 100644 --- a/src/report.rs +++ b/src/report.rs @@ -147,6 +147,15 @@ pub trait Prompter: Send + Sync { ) -> Result> { Ok(default.to_string()) } + + /// Ask whether to accept and store an unverified SSH host key (trust on + /// first use): `host` is the display form (`host` or `[host]:port`), + /// `key_type` the key type name ("ssh-ed25519", ...) and `fingerprint` + /// the human-readable digest. Fail-closed: implementations that cannot + /// ask anyone answer `false`, refusing the connection. + fn accept_host_key(&self, _host: &str, _key_type: &str, _fingerprint: &str) -> bool { + false + } } /// Inert view and prompter: drops every event and answers every question diff --git a/src/ui/deb.rs b/src/ui/deb.rs index 67992de..ee8501b 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -83,7 +83,8 @@ impl DebUi { let pb = multi.add(ProgressBar::new(0)); pb.enable_steady_tick(Duration::from_millis(80)); pb.set_style(spinner_style()); - pb.set_prefix("Building package"); + // Neutral identity until a `target` event names the build + pb.set_prefix("pkh"); pb.set_message("(starting…)"); pb } else { diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index 0e68d05..0cf9c94 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -159,6 +159,14 @@ impl crate::report::Prompter for TerminalPrompter { ) -> Result> { text(label, default, validate) } + + fn accept_host_key(&self, host: &str, key_type: &str, fingerprint: &str) -> bool { + // The banner is plain output: the confirmation prompt itself must + // stay a single line for its redraw logic + println!("The authenticity of host '{host}' can't be established."); + println!("{key_type} key fingerprint is {fingerprint}."); + confirm("Accept and store this host key?", false).unwrap_or(false) + } } /// Run `prompt` with the terminal in raw mode, always restoring it