1119 lines
43 KiB
Rust
1119 lines
43 KiB
Rust
//! SSH/SFTP transport for uploads: SSH configuration lookup, connection
|
|
//! with host-key verification — Launchpad's published fingerprints are
|
|
//! pinned (no prompt on first use), other hosts fall back to
|
|
//! `~/.ssh/known_hosts` with an ask-to-accept for unknown keys —,
|
|
//! authentication (every ssh-agent identity first, then configured and
|
|
//! default key files) and chunked SFTP upload with progress reporting.
|
|
//!
|
|
//! Every network phase is time-bounded — the TCP connect, the SSH
|
|
//! handshake/authentication, and each low-level call of the SFTP data
|
|
//! transfer (see the `*_TIMEOUT` constants) — so a black-holed or stalled
|
|
//! server fails the upload instead of hanging it forever.
|
|
//!
|
|
//! This replaces dput-ng's paramiko transport with the `ssh2` (libssh2)
|
|
//! stack the rest of pkh already uses for build contexts.
|
|
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::io::{Read, Write};
|
|
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
|
|
use std::path::{Path, PathBuf};
|
|
use std::time::Duration;
|
|
|
|
use indicatif::ProgressBar;
|
|
use lazy_static::lazy_static;
|
|
use log::debug;
|
|
use serde::Deserialize;
|
|
use sha2::{Digest, Sha256};
|
|
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, KnownHosts, Session};
|
|
|
|
use crate::ui::prompt;
|
|
|
|
const HOST_KEYS_YAML: &str = include_str!("../../data/host_keys.yml");
|
|
|
|
/// Pinned SSH host key fingerprints, loaded from the bundled
|
|
/// `host_keys.yml` data file (same pattern as `distro_info.yml`): data
|
|
/// rather than code, so trust anchors are updatable without touching the
|
|
/// source.
|
|
#[derive(Debug, Deserialize)]
|
|
struct PinnedHostKeys {
|
|
/// Host name → list of accepted `SHA256:<base64>` fingerprints; an
|
|
/// optional key type prefix (`ssh-rsa SHA256:...`) is tolerated.
|
|
fingerprints: HashMap<String, Vec<String>>,
|
|
}
|
|
|
|
lazy_static! {
|
|
// The YAML is include_str!'d at compile time and statically valid; if it
|
|
// ever failed to parse it would be a build-time bug that cannot be
|
|
// recovered from at runtime, so panicking here is acceptable.
|
|
static ref PINNED_HOST_KEYS: PinnedHostKeys = serde_yaml::from_str(HOST_KEYS_YAML)
|
|
.expect("built-in host_keys.yml data is statically valid and must parse");
|
|
}
|
|
|
|
/// Whether `fingerprint` is one of the pinned (published) fingerprints of
|
|
/// `host`, in which case the server is trusted without prompting
|
|
fn host_key_is_pinned(host: &str, fingerprint: &str) -> bool {
|
|
PINNED_HOST_KEYS.fingerprints.get(host).is_some_and(|pins| {
|
|
pins.iter()
|
|
.any(|pin| pin.split_whitespace().any(|token| token == fingerprint))
|
|
})
|
|
}
|
|
|
|
/// Per-host settings extracted from the SSH configuration files, read in
|
|
/// the OpenSSH order (`~/.ssh/config`, then `/etc/ssh/ssh_config`). Every
|
|
/// option keeps the first value obtained from the matching `Host` blocks,
|
|
/// like OpenSSH: user settings override system ones.
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
|
pub struct SshConfig {
|
|
/// `HostName`: real host behind a configured alias.
|
|
pub host_name: Option<String>,
|
|
/// `User`: overrides the target's default login.
|
|
pub user: Option<String>,
|
|
/// `Port`: overrides the target's default port.
|
|
pub port: Option<u16>,
|
|
/// `IdentityFile`s to try for authentication, in declaration order.
|
|
pub identity_files: Vec<PathBuf>,
|
|
}
|
|
|
|
/// Look up `host` in the user and system SSH configuration files, in
|
|
/// OpenSSH's read order: `~/.ssh/config` first, then
|
|
/// `/etc/ssh/ssh_config` (command line > user > system, first obtained
|
|
/// value wins). Missing files yield an empty configuration.
|
|
pub fn lookup_ssh_config(host: &str) -> SshConfig {
|
|
let mut config = SshConfig::default();
|
|
// User file first: with first-obtained-value-wins semantics below, this
|
|
// makes user settings override the system ones, like OpenSSH
|
|
let mut files = Vec::new();
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
files.push(Path::new(&home).join(".ssh/config"));
|
|
}
|
|
files.push(PathBuf::from("/etc/ssh/ssh_config"));
|
|
for file in files {
|
|
if let Ok(content) = fs::read_to_string(&file) {
|
|
apply_config_file(&mut config, &content, host);
|
|
}
|
|
}
|
|
config
|
|
}
|
|
|
|
/// Fold one configuration file's `Host` blocks matching `host` into
|
|
/// `config`, first obtained value wins per option. A block applies iff at
|
|
/// least one positive pattern matches and no negated (`!`-prefixed)
|
|
/// pattern matches — the OpenSSH `Host` rule. `Include` and `Match` are
|
|
/// not supported: a `Match` block's criteria are not evaluated, and it
|
|
/// conservatively stops attributing the following lines to the previous
|
|
/// `Host` block so its options are ignored rather than mis-applied.
|
|
fn apply_config_file(config: &mut SshConfig, content: &str, host: &str) {
|
|
let mut matching = false;
|
|
|
|
for line in content.lines() {
|
|
let line = line.trim();
|
|
if line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
|
|
let (keyword, rest) = match line.split_once(['=', ' ']) {
|
|
Some((k, r)) => (k.trim_end_matches('='), r.trim()),
|
|
None => (line, ""),
|
|
};
|
|
|
|
if keyword.eq_ignore_ascii_case("host") {
|
|
matching = match_pattern_list(host, rest);
|
|
continue;
|
|
}
|
|
|
|
if keyword.eq_ignore_ascii_case("match") {
|
|
// A conditional block whose criteria we do not evaluate: stop
|
|
// attributing the following options to the previous `Host`
|
|
// block, so they are ignored instead of mis-applied
|
|
matching = false;
|
|
continue;
|
|
}
|
|
|
|
if !matching {
|
|
continue;
|
|
}
|
|
|
|
if keyword.eq_ignore_ascii_case("hostname") && config.host_name.is_none() {
|
|
config.host_name = Some(rest.to_string());
|
|
} else if keyword.eq_ignore_ascii_case("user") && config.user.is_none() {
|
|
config.user = Some(rest.to_string());
|
|
} else if keyword.eq_ignore_ascii_case("port") && config.port.is_none() {
|
|
if let Ok(port) = rest.parse() {
|
|
config.port = Some(port);
|
|
}
|
|
} else if keyword.eq_ignore_ascii_case("identityfile") {
|
|
let path = expand_tilde(rest);
|
|
if !config.identity_files.contains(&path) {
|
|
config.identity_files.push(path);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The current user's home directory: the `directories` crate first (which
|
|
/// consults `$HOME` on Unix), with a direct `$HOME` fallback.
|
|
fn current_home() -> Option<PathBuf> {
|
|
directories::UserDirs::new()
|
|
.map(|dirs| dirs.home_dir().to_path_buf())
|
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from))
|
|
}
|
|
|
|
/// Expand a leading `~` or `~/` in `path` against `home`: the near-universal
|
|
/// `IdentityFile ~/.ssh/key` spelling. Only a leading tilde is handled —
|
|
/// `~user/...` (another user's home) and tildes appearing anywhere else are
|
|
/// kept verbatim — and without a known home directory the path is returned
|
|
/// unchanged.
|
|
fn expand_tilde_with(path: &str, home: Option<&Path>) -> PathBuf {
|
|
let Some(home) = home else {
|
|
return PathBuf::from(path);
|
|
};
|
|
if path == "~" {
|
|
home.to_path_buf()
|
|
} else if let Some(rest) = path.strip_prefix("~/") {
|
|
home.join(rest)
|
|
} else {
|
|
PathBuf::from(path)
|
|
}
|
|
}
|
|
|
|
/// [`expand_tilde_with`] against the current user's home directory
|
|
fn expand_tilde(path: &str) -> PathBuf {
|
|
expand_tilde_with(path, current_home().as_deref())
|
|
}
|
|
|
|
/// One `Host` block pattern list against a host name, with the OpenSSH
|
|
/// rule: the block applies iff at least one positive pattern matches AND
|
|
/// no negated pattern matches — a matching `!pattern` excludes the host
|
|
/// from the whole block, regardless of the positive patterns.
|
|
fn match_pattern_list(host: &str, patterns: &str) -> bool {
|
|
let mut any_positive = false;
|
|
let mut any_negated = false;
|
|
for pattern in patterns.split_whitespace() {
|
|
match pattern.strip_prefix('!') {
|
|
Some(negated) => any_negated |= wildmatch(host, negated),
|
|
None => any_positive |= wildmatch(host, pattern),
|
|
}
|
|
}
|
|
any_positive && !any_negated
|
|
}
|
|
|
|
/// `*`/`?` glob matching without allocation, enough for `Host` patterns
|
|
fn wildmatch(text: &str, pattern: &str) -> bool {
|
|
let text: Vec<char> = text.chars().collect();
|
|
let pattern: Vec<char> = pattern.chars().collect();
|
|
|
|
// Greedy backtracking over the pattern's last '*' position
|
|
let (mut t, mut p) = (0usize, 0usize);
|
|
let (mut star, mut mark) = (None::<usize>, 0usize);
|
|
|
|
while t < text.len() {
|
|
if p < pattern.len() && (pattern[p] == '?' || pattern[p] == text[t]) {
|
|
t += 1;
|
|
p += 1;
|
|
} else if p < pattern.len() && pattern[p] == '*' {
|
|
star = Some(p);
|
|
mark = t;
|
|
p += 1;
|
|
} else if let Some(s) = star {
|
|
p = s + 1;
|
|
mark += 1;
|
|
t = mark;
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
pattern[p..].iter().all(|&c| c == '*')
|
|
}
|
|
|
|
/// Bound on one TCP connection attempt to one resolved address: `connect(2)`
|
|
/// would otherwise block for minutes (or forever, behind a silent firewall)
|
|
/// per address. Generous enough for slow links to Launchpad, short enough
|
|
/// that a dead target fails in seconds.
|
|
const TCP_CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
|
|
|
/// Timeout for the blocking libssh2 calls of the connection phase (banner
|
|
/// exchange and key exchange of the handshake, host key check,
|
|
/// authentication): when it expires the pending call fails with
|
|
/// `LIBSSH2_ERROR_TIMEOUT` (surfaced as "timed out") instead of hanging
|
|
/// forever on a stalled server. See [`connect`] for what this does and does
|
|
/// not cover.
|
|
const SSH_API_TIMEOUT: Duration = Duration::from_secs(30);
|
|
|
|
/// Timeout for the blocking libssh2 calls of the SFTP data transfer (file
|
|
/// creation, chunk writes, final close handshake). It applies *per
|
|
/// low-level libssh2 call*, not to the whole transfer: the clock restarts
|
|
/// at every API entry, so the wall-clock duration of a transfer is not
|
|
/// bounded by design — but a stalled or black-holed server fails one call
|
|
/// after this budget instead of hanging `pkh put` forever.
|
|
const SSH_TRANSFER_TIMEOUT: Duration = Duration::from_secs(300);
|
|
|
|
/// libssh2 timeouts are millisecond counts; the constants above are exact
|
|
/// multiples of a millisecond, and anything larger saturates instead of
|
|
/// truncating
|
|
fn duration_ms(timeout: Duration) -> u32 {
|
|
u32::try_from(timeout.as_millis()).unwrap_or(u32::MAX)
|
|
}
|
|
|
|
/// Resolve `host:port` and open a TCP connection, trying each resolved
|
|
/// address in order (like `TcpStream::connect` does) with
|
|
/// [`TCP_CONNECT_TIMEOUT`] per attempt instead of blocking indefinitely.
|
|
/// Fails with a message naming the target and every per-address error.
|
|
fn tcp_connect(host: &str, port: u16) -> Result<TcpStream, String> {
|
|
let addrs: Vec<SocketAddr> = (host, port)
|
|
.to_socket_addrs()
|
|
.map_err(|e| format!("cannot resolve {host}:{port}: {e}"))?
|
|
.collect();
|
|
if addrs.is_empty() {
|
|
return Err(format!(
|
|
"cannot connect to {host}:{port}: '{host}' resolved to no addresses"
|
|
));
|
|
}
|
|
|
|
let mut attempts: Vec<(SocketAddr, std::io::Error)> = Vec::new();
|
|
for addr in addrs {
|
|
match TcpStream::connect_timeout(&addr, TCP_CONNECT_TIMEOUT) {
|
|
Ok(stream) => return Ok(stream),
|
|
Err(e) => attempts.push((addr, e)),
|
|
}
|
|
}
|
|
Err(connect_failed_message(host, port, &attempts))
|
|
}
|
|
|
|
/// Message for a target none of whose resolved addresses accepted a
|
|
/// connection within [`TCP_CONNECT_TIMEOUT`]. Pure so tests can assert it
|
|
/// without any network.
|
|
fn connect_failed_message(
|
|
host: &str,
|
|
port: u16,
|
|
attempts: &[(SocketAddr, std::io::Error)],
|
|
) -> String {
|
|
let details = attempts
|
|
.iter()
|
|
.map(|(addr, e)| format!("{addr}: {e}"))
|
|
.collect::<Vec<_>>()
|
|
.join("; ");
|
|
format!(
|
|
"cannot connect to {host}:{port} (tried {} address(es)): {details}",
|
|
attempts.len()
|
|
)
|
|
}
|
|
|
|
/// Connect to `host:port`, verify the server host key and authenticate as
|
|
/// `login`: every ssh-agent identity first, then the configured and default
|
|
/// identity files. The TCP connect, handshake and authentication steps are
|
|
/// time-bounded ([`TCP_CONNECT_TIMEOUT`], [`SSH_API_TIMEOUT`]).
|
|
pub fn connect(
|
|
host: &str,
|
|
port: u16,
|
|
login: &str,
|
|
config: &SshConfig,
|
|
) -> Result<Session, Box<dyn std::error::Error>> {
|
|
let tcp = tcp_connect(host, port)?;
|
|
|
|
let mut session = Session::new()?;
|
|
// In blocking mode (the libssh2 default), a call that would block loops
|
|
// in `_libssh2_wait_socket` (via the `BLOCK_ADJUST` macros of
|
|
// session.h in the vendored libssh2-sys sources), which bounds the
|
|
// underlying poll()/select() wait by this timeout and fails the call
|
|
// with LIBSSH2_ERROR_TIMEOUT when it expires. Verified against
|
|
// libssh2-sys 0.3.3: the handshake (session.c), host key check,
|
|
// authentication, channel and SFTP calls all route through it, so a
|
|
// stalled server errors out instead of hanging forever. It bounds
|
|
// *each* libssh2 call, not the whole phase: a server that trickles
|
|
// bytes often enough keeps resuming every call in time.
|
|
session.set_timeout(duration_ms(SSH_API_TIMEOUT));
|
|
session.set_tcp_stream(tcp);
|
|
session
|
|
.handshake()
|
|
.map_err(|e| format!("SSH handshake with {host} failed: {e}"))?;
|
|
|
|
let (key, key_type) = session
|
|
.host_key()
|
|
.ok_or_else(|| format!("{host} offered no host key"))?;
|
|
verify_host_key(host, port, key, key_type)?;
|
|
|
|
authenticate(&session, host, login, config)?;
|
|
|
|
// Only SFTP open/data calls remain on this session: switch from the
|
|
// connection-phase budget to the generous per-call transfer one
|
|
session.set_timeout(duration_ms(SSH_TRANSFER_TIMEOUT));
|
|
|
|
Ok(session)
|
|
}
|
|
|
|
/// Check the server's host key, in decreasing order of trust:
|
|
///
|
|
/// 1. matching one of the host's pinned (published, `host_keys.yml`)
|
|
/// fingerprints — accepted (with a diagnostic warning when the user's
|
|
/// known_hosts records a *different* key for the host, so a stale
|
|
/// entry gets cleaned up; the pin stays authoritative);
|
|
/// 2. matching `~/.ssh/known_hosts` — accepted;
|
|
/// 3. known-and-different — refused loudly (possible man-in-the-middle);
|
|
/// 4. unknown — fingerprint shown, explicit confirmation required, and on
|
|
/// acceptance the key is appended to the user's known hosts file
|
|
/// (dput's "ask to accept" policy).
|
|
fn verify_host_key(
|
|
host: &str,
|
|
port: u16,
|
|
key: &[u8],
|
|
key_type: HostKeyType,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let fingerprint = fingerprint(key);
|
|
|
|
if host_key_is_pinned(host, &fingerprint) {
|
|
debug!("{host} host key matches the published fingerprint");
|
|
// Purely diagnostic — the pin already decided — but a known_hosts
|
|
// entry disagreeing with the published key is worth surfacing
|
|
log_known_hosts_discrepancy(host, port, key);
|
|
return Ok(());
|
|
}
|
|
|
|
let mut known_hosts = Session::new()?.known_hosts()?;
|
|
load_known_hosts(&mut known_hosts, &known_hosts_files());
|
|
|
|
let check = check_known_hosts(&known_hosts, host, port, key);
|
|
|
|
match check {
|
|
CheckResult::Match => Ok(()),
|
|
CheckResult::Mismatch => Err(format!(
|
|
"Host key verification failed for {host}: the server now presents a \
|
|
DIFFERENT key than the one recorded in your known_hosts files. \
|
|
This could be a man-in-the-middle attack, or the server was \
|
|
re-keyed. If you are sure it was re-keyed, remove the '{host}' \
|
|
line(s) from ~/.ssh/known_hosts and retry"
|
|
)
|
|
.into()),
|
|
CheckResult::NotFound | CheckResult::Failure => {
|
|
let key_type_desc = key_type_name(key_type).unwrap_or("Host");
|
|
let display = if port == 22 {
|
|
host.to_string()
|
|
} else {
|
|
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 {
|
|
return Err(format!("Host key for {display} rejected, aborting upload").into());
|
|
}
|
|
if let Some(name) = key_type_name(key_type) {
|
|
append_known_hosts_line(&format!("{display} {name} {}", base64_nopad(key)))?;
|
|
} else {
|
|
log::warn!("Unknown host key type: accepted for this session, not stored");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read `files` into `known_hosts`: silently skips missing files (the
|
|
/// normal first-use case — there is nothing to consult), warns about a
|
|
/// file that exists but cannot be read or parsed, naming the file and the
|
|
/// error — the user should know why their maintained entries are not being
|
|
/// consulted — and skips it. Unreadable/unknown-format *lines* are skipped
|
|
/// by libssh2 itself. Returns the emitted warnings, so the decision is
|
|
/// testable without capturing log output.
|
|
fn load_known_hosts(known_hosts: &mut KnownHosts, files: &[PathBuf]) -> Vec<String> {
|
|
let mut warnings = Vec::new();
|
|
for file in files {
|
|
if !file.exists() {
|
|
continue;
|
|
}
|
|
if let Err(e) = known_hosts.read_file(file, KnownHostFileKind::OpenSSH) {
|
|
let warning = format!(
|
|
"cannot read the known hosts file {}: {e} — its entries are ignored",
|
|
file.display()
|
|
);
|
|
log::warn!("{warning}");
|
|
warnings.push(warning);
|
|
}
|
|
}
|
|
warnings
|
|
}
|
|
|
|
/// Look up `host`'s `key` in the loaded entries, using the `[host]:port`
|
|
/// spelling for non-standard ports (the known_hosts encoding)
|
|
fn check_known_hosts(known_hosts: &KnownHosts, host: &str, port: u16, key: &[u8]) -> CheckResult {
|
|
if port == 22 {
|
|
known_hosts.check(host, key)
|
|
} else {
|
|
known_hosts.check_port(host, port, key)
|
|
}
|
|
}
|
|
|
|
/// What to tell the user when the server's key matched a pinned (published)
|
|
/// fingerprint while their known_hosts records a different key for the same
|
|
/// host — a stale entry, or worse an old compromise artifact they should
|
|
/// clean up; `None` for every other check result, since the pin is
|
|
/// authoritative and needs no corroboration. Pure so the decision and the
|
|
/// wording are testable without a server or a filesystem.
|
|
fn known_hosts_discrepancy(host: &str, check: CheckResult) -> Option<String> {
|
|
match check {
|
|
CheckResult::Mismatch => Some(format!(
|
|
"the host key of {host} matches Launchpad's published \
|
|
fingerprint, but your known_hosts file(s) record a DIFFERENT \
|
|
key for it — proceeding on the published fingerprint; consider \
|
|
removing the stale '{host}' entry from your known_hosts"
|
|
)),
|
|
CheckResult::Match | CheckResult::NotFound | CheckResult::Failure => None,
|
|
}
|
|
}
|
|
|
|
/// Diagnostic-only companion to the pinned-fingerprint acceptance: load the
|
|
/// user's known hosts files and warn when they record a different key for
|
|
/// `host` than the pinned one about to be accepted. The pin stays
|
|
/// authoritative: any failure here is tolerated and the connection
|
|
/// proceeds regardless.
|
|
fn log_known_hosts_discrepancy(host: &str, port: u16, key: &[u8]) {
|
|
let Ok(mut known_hosts) = Session::new().and_then(|session| session.known_hosts()) else {
|
|
return;
|
|
};
|
|
load_known_hosts(&mut known_hosts, &known_hosts_files());
|
|
|
|
let check = check_known_hosts(&known_hosts, host, port, key);
|
|
if let Some(warning) = known_hosts_discrepancy(host, check) {
|
|
log::warn!("{warning}");
|
|
}
|
|
}
|
|
|
|
/// Known hosts files to consult, user file first (so that new keys are
|
|
/// accepted because of the user file, mirroring ssh's own ordering)
|
|
fn known_hosts_files() -> Vec<PathBuf> {
|
|
let mut files = Vec::new();
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
files.push(Path::new(&home).join(".ssh/known_hosts"));
|
|
}
|
|
files.push(PathBuf::from("/etc/ssh/ssh_known_hosts"));
|
|
files
|
|
}
|
|
|
|
/// Append one line to `~/.ssh/known_hosts` (creating the file), in the
|
|
/// plain OpenSSH `host keytype base64key` format
|
|
fn append_known_hosts_line(line: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|
let Some(home) = std::env::var_os("HOME") else {
|
|
return Err("cannot record the host key: HOME is not set".into());
|
|
};
|
|
let path = Path::new(&home).join(".ssh/known_hosts");
|
|
fs::create_dir_all(path.parent().unwrap())?;
|
|
fs::OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)?
|
|
.write_all(format!("{line}\n").as_bytes())
|
|
.map_err(|e| format!("cannot write to {}: {e}", path.display()))?;
|
|
debug!("Recorded host key line in {}", path.display());
|
|
Ok(())
|
|
}
|
|
|
|
/// Authenticate as `login`: every ssh-agent identity first (Launchpad
|
|
/// identifies uploaders by their registered key, which may be any entry of
|
|
/// the agent, not just the first), then identity files from the SSH
|
|
/// configuration and the usual `~/.ssh/id_*` defaults (passphrase-less
|
|
/// only — agent-loaded keys cover the protected ones).
|
|
fn authenticate(
|
|
session: &Session,
|
|
host: &str,
|
|
login: &str,
|
|
config: &SshConfig,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
// Try every identity the agent offers, not just the first: the
|
|
// Launchpad-registered key is not necessarily the agent's default
|
|
let mut agent_identities: Option<usize> = None;
|
|
if let Ok(mut agent) = session.agent()
|
|
&& agent.connect().is_ok()
|
|
&& agent.list_identities().is_ok()
|
|
{
|
|
match agent.identities() {
|
|
Ok(identities) => {
|
|
let count = identities.len();
|
|
agent_identities = Some(count);
|
|
for identity in &identities {
|
|
if agent.userauth(login, identity).is_ok() && session.authenticated() {
|
|
debug!("authenticated through the ssh-agent");
|
|
return Ok(());
|
|
}
|
|
}
|
|
debug!("none of the {count} agent identity(ies) authenticated");
|
|
}
|
|
Err(e) => debug!("cannot list agent identities: {e}"),
|
|
}
|
|
}
|
|
|
|
let mut identity_files: Vec<PathBuf> = config.identity_files.clone();
|
|
if let Some(home) = std::env::var_os("HOME") {
|
|
for name in ["id_ed25519", "id_ecdsa", "id_rsa"] {
|
|
let key = Path::new(&home).join(".ssh").join(name);
|
|
if !identity_files.contains(&key) {
|
|
identity_files.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut tried = Vec::new();
|
|
for key in &identity_files {
|
|
if !key.is_file() {
|
|
continue;
|
|
}
|
|
match session.userauth_pubkey_file(login, None, key, None) {
|
|
Ok(()) => return Ok(()),
|
|
Err(e) => tried.push(format!("{} ({e})", key.display())),
|
|
}
|
|
}
|
|
|
|
if session.authenticated() {
|
|
return Ok(());
|
|
}
|
|
|
|
let agent_desc = match agent_identities {
|
|
Some(count) => format!("{count} ssh-agent identity(ies)"),
|
|
None => "no ssh-agent".to_string(),
|
|
};
|
|
Err(format!(
|
|
"SSH authentication failed for {login}@{host}: tried {agent_desc} \
|
|
and the identity files [{}]. Launchpad authenticates the '{login}' \
|
|
account with the SSH keys registered on it \
|
|
(https://launchpad.net/~/+editsshkeys): make sure such a key is \
|
|
available — load it with `ssh-add <key>` or point pkh at it with a \
|
|
'Host {host}' / 'IdentityFile' entry in ~/.ssh/config \
|
|
(passphrase-protected key files are only usable through the agent) \
|
|
— and check the account name with `git config lp.user`",
|
|
tried.join(", ")
|
|
)
|
|
.into())
|
|
}
|
|
|
|
/// Upload `local` to the remote SFTP `path` on `host`, updating `bar` per
|
|
/// chunk. The remote file is created/truncated; Launchpad's upload queue is
|
|
/// write-only, so failures here mean the upload failed — there is nothing
|
|
/// to inspect server-side. Every SFTP call is bounded by the session's
|
|
/// per-call transfer timeout (see [`SSH_TRANSFER_TIMEOUT`]), so a stalled
|
|
/// server fails the upload instead of hanging it.
|
|
pub fn upload_file(
|
|
sftp: &ssh2::Sftp,
|
|
local: &Path,
|
|
remote: &str,
|
|
host: &str,
|
|
bar: &ProgressBar,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut local_file =
|
|
fs::File::open(local).map_err(|e| format!("cannot open '{}': {}", local.display(), e))?;
|
|
|
|
let mut remote_file = sftp
|
|
.create(Path::new(remote))
|
|
.map_err(|e| format!("cannot create remote file {remote} on {host}: {e}"))?;
|
|
|
|
let mut buf = [0u8; 32 * 1024];
|
|
loop {
|
|
let n = local_file.read(&mut buf)?;
|
|
if n == 0 {
|
|
break;
|
|
}
|
|
remote_file
|
|
.write_all(&buf[..n])
|
|
.map_err(|e| format!("failed uploading to {remote} on {host}: {e}"))?;
|
|
bar.inc(n as u64);
|
|
}
|
|
|
|
// Close explicitly: quota-exceeded and similar failures only surface in
|
|
// the final ACKs and the close handshake, and the `Drop` impl of
|
|
// `ssh2::File` discards that error ("too late to recover"), recording a
|
|
// truncated remote file as a successful upload. `ssh2::File::write` is
|
|
// unbuffered (`Write::flush` is a documented no-op) and `close`
|
|
// finalizes the pending writes server-side, so no flush is needed.
|
|
remote_file.close().map_err(|e| {
|
|
format!("failed to close remote file '{remote}' on {host} after upload: {e}")
|
|
})?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Remove the remote file `remote` on `host` (SFTP remove). Used by the
|
|
/// best-effort cleanup of an interrupted upload: the queue is write-only,
|
|
/// so the only remote operation ever needed besides `create` is this one.
|
|
pub fn remove_file(
|
|
sftp: &ssh2::Sftp,
|
|
remote: &str,
|
|
host: &str,
|
|
) -> Result<(), Box<dyn std::error::Error>> {
|
|
sftp.unlink(Path::new(remote))
|
|
.map_err(|e| format!("cannot remove remote file {remote} on {host}: {e}").into())
|
|
}
|
|
|
|
/// Open the SFTP subsystem on `session`
|
|
pub fn sftp(session: &Session) -> Result<ssh2::Sftp, Box<dyn std::error::Error>> {
|
|
session
|
|
.sftp()
|
|
.map_err(|e| format!("cannot open the SFTP subsystem: {e}").into())
|
|
}
|
|
|
|
/// ssh-keygen-style `SHA256:<base64>` fingerprint of a raw host key
|
|
fn fingerprint(key: &[u8]) -> String {
|
|
let digest = Sha256::digest(key);
|
|
format!("SHA256:{}", base64_nopad(&digest))
|
|
}
|
|
|
|
/// OpenSSH name of a host key type, `None` when it cannot be named (and
|
|
/// thus not recorded in a known hosts file)
|
|
fn key_type_name(key_type: HostKeyType) -> Option<&'static str> {
|
|
match key_type {
|
|
HostKeyType::Rsa => Some("ssh-rsa"),
|
|
HostKeyType::Dss => Some("ssh-dss"),
|
|
HostKeyType::Ecdsa256 => Some("ecdsa-sha2-nistp256"),
|
|
HostKeyType::Ecdsa384 => Some("ecdsa-sha2-nistp384"),
|
|
HostKeyType::Ecdsa521 => Some("ecdsa-sha2-nistp521"),
|
|
HostKeyType::Ed25519 => Some("ssh-ed25519"),
|
|
HostKeyType::Unknown => None,
|
|
}
|
|
}
|
|
|
|
/// Standard-alphabet base64 without padding (the encoding used by SSH
|
|
/// fingerprints and known_hosts key fields)
|
|
fn base64_nopad(data: &[u8]) -> String {
|
|
const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
|
|
for chunk in data.chunks(3) {
|
|
let b0 = chunk[0] as u32;
|
|
let b1 = chunk.get(1).copied().unwrap_or(0) as u32;
|
|
let b2 = chunk.get(2).copied().unwrap_or(0) as u32;
|
|
let n = (b0 << 16) | (b1 << 8) | b2;
|
|
|
|
out.push(TABLE[(n >> 18) as usize & 63] as char);
|
|
out.push(TABLE[(n >> 12) as usize & 63] as char);
|
|
if chunk.len() > 1 {
|
|
out.push(TABLE[(n >> 6) as usize & 63] as char);
|
|
}
|
|
if chunk.len() > 2 {
|
|
out.push(TABLE[n as usize & 63] as char);
|
|
}
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn base64_nopad_rfc4648_vectors() {
|
|
assert_eq!(base64_nopad(b""), "");
|
|
assert_eq!(base64_nopad(b"f"), "Zg");
|
|
assert_eq!(base64_nopad(b"fo"), "Zm8");
|
|
assert_eq!(base64_nopad(b"foo"), "Zm9v");
|
|
assert_eq!(base64_nopad(b"foob"), "Zm9vYg");
|
|
assert_eq!(base64_nopad(b"fooba"), "Zm9vYmE");
|
|
assert_eq!(base64_nopad(b"foobar"), "Zm9vYmFy");
|
|
}
|
|
|
|
/// The known_hosts encoding of a key matches what ssh-keyscan writes:
|
|
/// unpadded base64 of the raw wire-format key
|
|
#[test]
|
|
fn base64_nopad_matches_known_hosts_encoding() {
|
|
// echo -n hello | sha256sum →
|
|
let digest = Sha256::digest(b"hello");
|
|
assert_eq!(
|
|
fingerprint(b"hello"),
|
|
format!("SHA256:{}", base64_nopad(&digest))
|
|
);
|
|
// Verified with: printf 'hello' | sha256sum | xxd -r -p | base64 | tr -d '='
|
|
assert_eq!(
|
|
base64_nopad(&digest),
|
|
"LPJNul+wow4m6DsqxbninhsWHlwfp0JecwQzYpOLmCQ"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wildmatch_handles_exact_and_wildcards() {
|
|
assert!(wildmatch("ppa.launchpad.net", "ppa.launchpad.net"));
|
|
assert!(wildmatch("ppa.launchpad.net", "*.launchpad.net"));
|
|
assert!(wildmatch("ppa.launchpad.net", "*"));
|
|
assert!(wildmatch("host1", "host?"));
|
|
assert!(!wildmatch("ppa.launchpad.net", "*.debian.org"));
|
|
assert!(!wildmatch("host12", "host?"));
|
|
}
|
|
|
|
/// Launchpad's published fingerprints (from the bundled host_keys.yml)
|
|
/// are pinned for its hosts, so the common upload never prompts. The
|
|
/// published entries carry an `ssh-rsa` key type prefix, which must not
|
|
/// prevent matching.
|
|
#[test]
|
|
fn launchpad_hosts_are_pinned() {
|
|
assert!(host_key_is_pinned(
|
|
"ppa.launchpad.net",
|
|
"SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg"
|
|
));
|
|
assert!(host_key_is_pinned(
|
|
"upload.ubuntu.com",
|
|
"SHA256:FN8sNU/MMmyvw/xtY5sAzkLGmkVQt2QpGZcwsHoBzjc"
|
|
));
|
|
// A different key is not pinned, even for a pinned host...
|
|
assert!(!host_key_is_pinned(
|
|
"ppa.launchpad.net",
|
|
"SHA256:totally-different-key-fingerprint"
|
|
));
|
|
// ...and other hosts have no pins: they fall back to known_hosts
|
|
// + prompt
|
|
assert!(!host_key_is_pinned(
|
|
"example.com",
|
|
"SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg"
|
|
));
|
|
}
|
|
|
|
/// A pinned-key host whose known_hosts entries record a DIFFERENT key
|
|
/// is flagged with a message naming the host; every other check result
|
|
/// stays silent — the published fingerprint remains authoritative.
|
|
#[test]
|
|
fn known_hosts_discrepancy_flags_mismatch_only() {
|
|
let message = known_hosts_discrepancy("ppa.launchpad.net", CheckResult::Mismatch)
|
|
.expect("a mismatch under a matching pin must be flagged");
|
|
assert!(
|
|
message.contains("ppa.launchpad.net"),
|
|
"unexpected: {message}"
|
|
);
|
|
assert!(message.contains("DIFFERENT"), "unexpected: {message}");
|
|
assert!(
|
|
message.contains("published fingerprint"),
|
|
"unexpected: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("stale"),
|
|
"the user must be told to clean the entry up: {message}"
|
|
);
|
|
|
|
for quiet in [
|
|
CheckResult::Match,
|
|
CheckResult::NotFound,
|
|
CheckResult::Failure,
|
|
] {
|
|
assert!(
|
|
known_hosts_discrepancy("ppa.launchpad.net", quiet).is_none(),
|
|
"{quiet:?} must not be flagged"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Known hosts loading decisions: a missing file is the normal
|
|
/// first-use case and stays silent, a readable file is loaded and its
|
|
/// entries consulted, and an existing but unreadable file yields a
|
|
/// warning naming it — the user must learn why their maintained
|
|
/// entries are not being consulted. Offline: a bare session suffices
|
|
/// for the known-hosts store.
|
|
#[test]
|
|
fn load_known_hosts_warns_only_on_unreadable_files() {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let mut known_hosts = Session::new().unwrap().known_hosts().unwrap();
|
|
|
|
// A missing file is silent...
|
|
let missing = dir.path().join("known_hosts");
|
|
assert!(load_known_hosts(&mut known_hosts, &[missing]).is_empty());
|
|
|
|
// ...a readable one is loaded and consulted...
|
|
let key = b"a test host key";
|
|
let loaded = dir.path().join("loaded_known_hosts");
|
|
fs::write(
|
|
&loaded,
|
|
format!("host.example.com ssh-ed25519 {}\n", base64_nopad(key)),
|
|
)
|
|
.unwrap();
|
|
assert!(
|
|
load_known_hosts(&mut known_hosts, &[loaded]).is_empty(),
|
|
"a readable file must not warn"
|
|
);
|
|
assert!(
|
|
matches!(
|
|
check_known_hosts(&known_hosts, "host.example.com", 22, key),
|
|
CheckResult::Match
|
|
),
|
|
"the loaded entry must be consulted"
|
|
);
|
|
|
|
// ...and an existing but unreadable one is reported, naming the
|
|
// file. Environments that read through the mode bits (root) cannot
|
|
// exercise the unreadable case and are skipped.
|
|
let locked = dir.path().join("locked_known_hosts");
|
|
fs::write(&locked, b"stale.example.com ssh-ed25519 c3RhbGU=").unwrap();
|
|
let mut permissions = fs::metadata(&locked).unwrap().permissions();
|
|
permissions.set_mode(0o000);
|
|
fs::set_permissions(&locked, permissions).unwrap();
|
|
if fs::read(&locked).is_ok() {
|
|
return;
|
|
}
|
|
let warnings = load_known_hosts(&mut known_hosts, &[locked]);
|
|
assert_eq!(warnings.len(), 1, "unexpected: {warnings:?}");
|
|
assert!(
|
|
warnings[0].contains("locked_known_hosts"),
|
|
"the warning must name the file: {warnings:?}"
|
|
);
|
|
assert!(
|
|
warnings[0].contains("ignored"),
|
|
"the warning must say the entries are not consulted: {warnings:?}"
|
|
);
|
|
}
|
|
|
|
/// Negated `Host` patterns exclude the host from the whole block (the
|
|
/// OpenSSH rule): a block applies iff at least one positive pattern
|
|
/// matches AND no negated pattern matches.
|
|
#[test]
|
|
fn pattern_negation_excludes_host() {
|
|
// The classic catch-all-with-exception: `*` alone would match, but
|
|
// the negated pattern must exclude the host from the whole block
|
|
let config = apply_config_file_all(
|
|
"\
|
|
Host * !*.launchpad.net
|
|
User fallback
|
|
",
|
|
"ppa.launchpad.net",
|
|
);
|
|
assert_eq!(config, SshConfig::default());
|
|
|
|
// A negation with no positive pattern never matches either
|
|
assert!(!match_pattern_list("ppa.launchpad.net", "!*.launchpad.net"));
|
|
// A matching negation excludes despite a positive match...
|
|
assert!(!match_pattern_list(
|
|
"ppa.launchpad.net",
|
|
"example.com !*.launchpad.net"
|
|
));
|
|
// ...while an unrelated negation leaves the positive match intact
|
|
assert!(match_pattern_list("ppa.launchpad.net", "* !*.example.com"));
|
|
}
|
|
|
|
/// OpenSSH's precedence: the user file is read before the system file
|
|
/// and the first value obtained wins, so the second (system) pass must
|
|
/// not override values the first (user) pass already obtained — while
|
|
/// options the user file leaves unset are still taken from the system
|
|
/// file.
|
|
#[test]
|
|
fn user_config_overrides_system() {
|
|
let user = "\
|
|
Host ppa.launchpad.net
|
|
User myuser
|
|
Port 2222
|
|
";
|
|
let system = "\
|
|
Host *
|
|
User login
|
|
Port 22
|
|
HostName system.example.com
|
|
";
|
|
|
|
let mut config = SshConfig::default();
|
|
apply_config_file(&mut config, user, "ppa.launchpad.net"); // ~/.ssh/config
|
|
apply_config_file(&mut config, system, "ppa.launchpad.net"); // /etc/ssh/ssh_config
|
|
|
|
assert_eq!(config.user.as_deref(), Some("myuser"));
|
|
assert_eq!(config.port, Some(2222));
|
|
assert_eq!(config.host_name.as_deref(), Some("system.example.com"));
|
|
}
|
|
|
|
/// `Match` blocks are not evaluated: their options must be ignored, not
|
|
/// mis-attributed to the preceding `Host` block through a stale
|
|
/// matching flag.
|
|
#[test]
|
|
fn match_block_is_ignored_conservatively() {
|
|
let config = apply_config_file_all(
|
|
"\
|
|
Host ppa.launchpad.net
|
|
User myuser
|
|
Match final all
|
|
Port 2223
|
|
IdentityFile ~/.ssh/matched_key
|
|
",
|
|
"ppa.launchpad.net",
|
|
);
|
|
|
|
assert_eq!(config.user.as_deref(), Some("myuser"));
|
|
assert_eq!(config.port, None);
|
|
assert!(config.identity_files.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn config_first_obtained_value_wins() {
|
|
// Like real configurations, the catch-all `Host *` block comes last:
|
|
// values from earlier matching blocks win, IdentityFile accumulates
|
|
let config = apply_config_file_all(
|
|
"\
|
|
Host ppa.launchpad.net
|
|
User myuser
|
|
IdentityFile ~/.ssh/lp_key
|
|
|
|
Host *
|
|
User fallback
|
|
ServerAliveInterval 30
|
|
IdentityFile ~/.ssh/other_key
|
|
",
|
|
"ppa.launchpad.net",
|
|
);
|
|
|
|
assert_eq!(config.user.as_deref(), Some("myuser"));
|
|
// A leading tilde is expanded to the home directory, built from the
|
|
// same lookup the helper uses so no specific username is assumed
|
|
let home = current_home().expect("tests require a home directory");
|
|
assert_eq!(
|
|
config.identity_files,
|
|
vec![home.join(".ssh/lp_key"), home.join(".ssh/other_key")]
|
|
);
|
|
assert_eq!(config.host_name, None);
|
|
assert_eq!(config.port, None);
|
|
}
|
|
|
|
/// Only a leading `~`/`~/` expands to the home directory; `~user/...`
|
|
/// and non-tilde paths are kept verbatim, and nothing is expanded
|
|
/// without a known home directory
|
|
#[test]
|
|
fn expand_tilde_handles_leading_tilde_only() {
|
|
let home = Some(Path::new("/home/testuser"));
|
|
assert_eq!(
|
|
expand_tilde_with("~/x", home),
|
|
PathBuf::from("/home/testuser/x")
|
|
);
|
|
assert_eq!(
|
|
expand_tilde_with("~", home),
|
|
PathBuf::from("/home/testuser")
|
|
);
|
|
assert_eq!(
|
|
expand_tilde_with("~other/x", home),
|
|
PathBuf::from("~other/x")
|
|
);
|
|
assert_eq!(
|
|
expand_tilde_with("relative", home),
|
|
PathBuf::from("relative")
|
|
);
|
|
assert_eq!(expand_tilde_with("/abs", home), PathBuf::from("/abs"));
|
|
assert_eq!(expand_tilde_with("~/x", None), PathBuf::from("~/x"));
|
|
}
|
|
|
|
#[test]
|
|
fn config_wildcard_and_question_marks_match() {
|
|
let config = apply_config_file_all(
|
|
"\
|
|
Host *.launchpad.net
|
|
HostName launchpad-real.example.com
|
|
Port 2222
|
|
",
|
|
"ppa.launchpad.net",
|
|
);
|
|
|
|
assert_eq!(
|
|
config.host_name.as_deref(),
|
|
Some("launchpad-real.example.com")
|
|
);
|
|
assert_eq!(config.port, Some(2222));
|
|
}
|
|
|
|
#[test]
|
|
fn config_ignores_unrelated_blocks_and_comments() {
|
|
let config = apply_config_file_all(
|
|
"\
|
|
# a comment
|
|
Host github.com
|
|
User git
|
|
Host other
|
|
User nope
|
|
",
|
|
"ppa.launchpad.net",
|
|
);
|
|
|
|
assert_eq!(config, SshConfig::default());
|
|
}
|
|
|
|
/// Parse helper for tests: applies `content` for `host` to a fresh config
|
|
fn apply_config_file_all(content: &str, host: &str) -> SshConfig {
|
|
let mut config = SshConfig::default();
|
|
apply_config_file(&mut config, content, host);
|
|
config
|
|
}
|
|
|
|
/// The all-addresses-failed message names the target and reports every
|
|
/// attempted address with its error. Pure: no network involved.
|
|
#[test]
|
|
fn connect_failed_message_names_target_and_attempts() {
|
|
let attempts = vec![
|
|
(
|
|
SocketAddr::new(
|
|
std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 0, 2, 10)),
|
|
22,
|
|
),
|
|
std::io::Error::new(std::io::ErrorKind::TimedOut, "connection timed out"),
|
|
),
|
|
(
|
|
SocketAddr::new(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST), 22),
|
|
std::io::Error::new(std::io::ErrorKind::ConnectionRefused, "refused"),
|
|
),
|
|
];
|
|
|
|
let message = connect_failed_message("ppa.launchpad.net", 22, &attempts);
|
|
assert!(
|
|
message.contains("cannot connect to ppa.launchpad.net:22"),
|
|
"unexpected: {message}"
|
|
);
|
|
assert!(
|
|
message.contains("tried 2 address(es)"),
|
|
"unexpected: {message}"
|
|
);
|
|
assert!(message.contains("192.0.2.10:22"), "unexpected: {message}");
|
|
assert!(
|
|
message.contains("connection timed out"),
|
|
"unexpected: {message}"
|
|
);
|
|
assert!(message.contains("[::1]:22"), "unexpected: {message}");
|
|
assert!(message.contains("refused"), "unexpected: {message}");
|
|
}
|
|
|
|
/// The whole `tcp_connect` failure path on a loopback port with no
|
|
/// listener (numeric literal, so no DNS; loopback only, so no external
|
|
/// network): the per-address `connect_timeout` loop produces the
|
|
/// formatted message naming the target. DNS resolution failures are
|
|
/// deliberately not tested: resolving a bogus name would touch the
|
|
/// system resolver, which is not offline-safe.
|
|
#[test]
|
|
fn tcp_connect_closed_port_fails_naming_the_target() {
|
|
// Grab a free port, then release it: connecting to the now-closed
|
|
// port fails immediately (ECONNREFUSED), never reaching the timeout
|
|
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let addr = listener.local_addr().unwrap();
|
|
drop(listener);
|
|
|
|
let error = tcp_connect("127.0.0.1", addr.port()).unwrap_err();
|
|
assert!(
|
|
error.contains(&format!("cannot connect to 127.0.0.1:{}", addr.port())),
|
|
"unexpected: {error}"
|
|
);
|
|
assert!(error.contains("tried 1 address(es)"), "unexpected: {error}");
|
|
}
|
|
|
|
/// The libssh2 timeout wiring round-trips through the session API with
|
|
/// the configured constants (no network: a bare session suffices)
|
|
#[test]
|
|
fn session_timeouts_use_the_configured_constants() {
|
|
let session = Session::new().unwrap();
|
|
|
|
session.set_timeout(duration_ms(SSH_API_TIMEOUT));
|
|
assert_eq!(session.timeout(), duration_ms(SSH_API_TIMEOUT));
|
|
|
|
// The transfer budget replaces the connection one once authenticated
|
|
session.set_timeout(duration_ms(SSH_TRANSFER_TIMEOUT));
|
|
assert_eq!(session.timeout(), duration_ms(SSH_TRANSFER_TIMEOUT));
|
|
}
|
|
|
|
/// Millisecond conversion is exact for the constants and saturates for
|
|
/// larger values instead of truncating or panicking
|
|
#[test]
|
|
fn duration_ms_converts_and_saturates() {
|
|
assert_eq!(duration_ms(TCP_CONNECT_TIMEOUT), 15_000);
|
|
assert_eq!(duration_ms(SSH_API_TIMEOUT), 30_000);
|
|
assert_eq!(duration_ms(SSH_TRANSFER_TIMEOUT), 300_000);
|
|
assert_eq!(
|
|
duration_ms(Duration::from_millis(u64::from(u32::MAX) + 1)),
|
|
u32::MAX
|
|
);
|
|
}
|
|
}
|