new: add pkh put, a native dput replacement for PPA uploads
Upload built source packages over SFTP with host-key verification (Launchpad fingerprints pinned in host_keys.yml, ask-to-accept otherwise), Launchpad account discovery (git config lp.user), and pre-flight checks the upload queue itself never does: changes file discovery/validation, PPA existence via the Launchpad API, target series validity, and debian/control Section validity (sections bundled in distro_info.yml). Upload log prevents duplicate uploads unless --force.
This commit is contained in:
+611
@@ -0,0 +1,611 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! 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::TcpStream;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indicatif::ProgressBar;
|
||||
use lazy_static::lazy_static;
|
||||
use log::debug;
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
use ssh2::{CheckResult, HostKeyType, KnownHostFileKind, Session};
|
||||
|
||||
use crate::ui::prompt;
|
||||
|
||||
const HOST_KEYS_YAML: &str = include_str!("../../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
|
||||
/// (`/etc/ssh/ssh_config`, `~/.ssh/config`). Every option keeps the first
|
||||
/// value obtained from the matching `Host` blocks, like OpenSSH.
|
||||
#[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 system and user SSH configuration files. Missing
|
||||
/// files yield an empty configuration.
|
||||
pub fn lookup_ssh_config(host: &str) -> SshConfig {
|
||||
let mut config = SshConfig::default();
|
||||
let mut files = vec![PathBuf::from("/etc/ssh/ssh_config")];
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
files.push(Path::new(&home).join(".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 matching `Host` blocks into `config`,
|
||||
/// first obtained value wins per option.
|
||||
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 = rest
|
||||
.split_whitespace()
|
||||
.any(|pattern| match_pattern(host, pattern));
|
||||
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 = PathBuf::from(rest);
|
||||
if !config.identity_files.contains(&path) {
|
||||
config.identity_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `Host` block pattern against a host name: exact match, `*` (any
|
||||
/// run), `?` (one character) and `!pattern` negation (a matching negated
|
||||
/// pattern excludes the host from the block).
|
||||
fn match_pattern(host: &str, pattern: &str) -> bool {
|
||||
if let Some(negated) = pattern.strip_prefix('!') {
|
||||
!match_pattern(host, negated)
|
||||
} else {
|
||||
wildmatch(host, pattern)
|
||||
}
|
||||
}
|
||||
|
||||
/// `*`/`?` 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 == '*')
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn connect(
|
||||
host: &str,
|
||||
port: u16,
|
||||
login: &str,
|
||||
config: &SshConfig,
|
||||
) -> Result<Session, Box<dyn std::error::Error>> {
|
||||
let tcp = TcpStream::connect((host, port))
|
||||
.map_err(|e| format!("cannot connect to {host}:{port}: {e}"))?;
|
||||
|
||||
let mut session = Session::new()?;
|
||||
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)?;
|
||||
|
||||
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 silently;
|
||||
/// 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");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut known_hosts = Session::new()?.known_hosts()?;
|
||||
for file in known_hosts_files() {
|
||||
// Unreadable/unknown-format lines are skipped by libssh2; a missing
|
||||
// file is fine
|
||||
let _ = known_hosts.read_file(&file, KnownHostFileKind::OpenSSH);
|
||||
}
|
||||
|
||||
let check = if port == 22 {
|
||||
known_hosts.check(host, key)
|
||||
} else {
|
||||
known_hosts.check_port(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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`, 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.
|
||||
pub fn upload_file(
|
||||
sftp: &ssh2::Sftp,
|
||||
local: &Path,
|
||||
remote: &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}: {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}: {e}"))?;
|
||||
bar.inc(n as u64);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pattern_negation_excludes_host() {
|
||||
assert!(!match_pattern("ppa.launchpad.net", "!*.launchpad.net"));
|
||||
// A negated pattern only excludes; the other patterns of the block
|
||||
// decide separately
|
||||
assert!(match_pattern("example.com", "!*.launchpad.net example.com"));
|
||||
assert!(!match_pattern(
|
||||
"ppa.launchpad.net",
|
||||
"example.com !*.launchpad.net"
|
||||
));
|
||||
}
|
||||
|
||||
#[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"));
|
||||
assert_eq!(
|
||||
config.identity_files,
|
||||
vec![
|
||||
PathBuf::from("~/.ssh/lp_key"),
|
||||
PathBuf::from("~/.ssh/other_key")
|
||||
]
|
||||
);
|
||||
assert_eq!(config.host_name, None);
|
||||
assert_eq!(config.port, None);
|
||||
}
|
||||
|
||||
#[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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user