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:
2026-09-16 21:38:53 +02:00
parent 9228ff448b
commit f27d27ea99
13 changed files with 2023 additions and 97 deletions
+380
View File
@@ -0,0 +1,380 @@
//! `.changes` file parsing and pre-upload validation: signature presence,
//! field extraction, and verification that every file listed in the
//! checksums sections exists and matches on disk.
//!
//! Launchpad authenticates the upload through the GPG signature of the
//! `.changes` file, so an unsigned file is rejected before connecting, and
//! a file whose checksums do not match the artifacts next to it would only
//! fail server-side after a partial upload.
use std::path::{Path, PathBuf};
use crate::debian::checksums::FileChecksums;
use crate::debian::control::{parse_paragraphs, strip_clearsigned_armour};
/// A file listed in a `.changes` checksums section.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
/// File name, relative to the `.changes` file's directory.
pub name: String,
/// Expected size in bytes.
pub size: u64,
/// Expected SHA-256 digest (lowercase hex), from `Checksums-Sha256`.
pub sha256: Option<String>,
/// Expected MD5 digest (lowercase hex), from `Files`.
pub md5: Option<String>,
}
impl FileEntry {
/// Whether `actual` matches every expected digest and the size.
fn matches(&self, actual: &crate::debian::checksums::Entry) -> bool {
if actual.size != self.size {
return false;
}
if let Some(sha256) = &self.sha256
&& actual.sha256 != *sha256
{
return false;
}
if let Some(md5) = &self.md5
&& actual.md5 != *md5
{
return false;
}
true
}
}
/// Parsed `.changes` file, ready for validation and upload.
#[derive(Debug, Clone)]
pub struct ChangesFile {
/// Path of the `.changes` file itself.
pub path: PathBuf,
/// `Source` field.
pub source: String,
/// `Version` field.
pub version: String,
/// `Distribution` field.
pub distribution: String,
/// Files to upload, in listing order. The `.changes` file itself is not
/// part of this list: it is uploaded last, like dput does.
pub files: Vec<FileEntry>,
}
/// Parse a `.changes` file: extract the upload fields and the file list from
/// the checksums sections. Rejects unsigned files (Launchpad requires a
/// signature) and `UNRELEASED` entries (which are not uploadable).
pub fn parse(path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string(path)
.map_err(|e| format!("cannot read '{}': {}", path.display(), e))?;
if !content.contains("-----BEGIN PGP SIGNATURE-----") {
return Err(format!(
"'{}' is not signed. Launchpad rejects unsigned uploads: \
rebuild with `pkh build` (or sign with debsign) and retry",
path.display()
)
.into());
}
// dpkg signs `.changes` either clearsigned (the whole document wrapped
// in a `-----BEGIN PGP SIGNED MESSAGE-----` armor, with the fields only
// starting after the armor headers) or with the signature block merely
// appended. Both need the armor out of the paragraph parse:
// [`strip_clearsigned_armour`] handles the wrapper and returns other
// input unchanged, the split drops an appended signature block.
let body = strip_clearsigned_armour(
content
.split("-----BEGIN PGP SIGNATURE-----")
.next()
.unwrap_or(&content),
);
let paragraphs = parse_paragraphs(body);
let fields = paragraphs
.first()
.ok_or_else(|| format!("'{}' contains no fields", path.display()))?;
let missing = |field: &str| format!("'{}' has no {} field", path.display(), field);
let source = fields.get("Source").ok_or_else(|| missing("Source"))?;
let version = fields.get("Version").ok_or_else(|| missing("Version"))?;
let distribution = fields
.get("Distribution")
.ok_or_else(|| missing("Distribution"))?;
if distribution == "UNRELEASED" {
return Err(format!(
"'{}' targets UNRELEASED: pick a real series with `pkh chlog` and rebuild",
path.display()
)
.into());
}
// `Checksums-Sha256` is the authoritative section; older files only
// carry the MD5 `Files` section
let mut files = parse_checksums(fields.get("Checksums-Sha256"), DigestKind::Sha256)?;
if files.is_empty() {
files = parse_checksums(fields.get("Files"), DigestKind::Md5)?;
}
if files.is_empty() {
return Err(format!(
"'{}' lists no files (no Checksums-Sha256 or Files section)",
path.display()
)
.into());
}
Ok(ChangesFile {
path: path.to_path_buf(),
source: source.to_string(),
version: version.to_string(),
distribution: distribution.to_string(),
files,
})
}
/// Which digest the hash column of a `Checksums-*` / `Files` section holds
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DigestKind {
Sha256,
Md5,
}
/// Parse a `Checksums-*` / `Files` field value: one line per file of the
/// form `<hash> <size> <name>` (leading whitespace is continuation-line
/// indentation). `digest` says which algorithm the hash column carries.
fn parse_checksums(
value: Option<&str>,
digest: DigestKind,
) -> Result<Vec<FileEntry>, Box<dyn std::error::Error>> {
let mut files = Vec::new();
let Some(value) = value else {
return Ok(files);
};
for line in value.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() != 3 {
return Err(format!("malformed checksum line: '{line}'").into());
}
let hash = parts[0].to_lowercase();
let size: u64 = parts[1]
.parse()
.map_err(|_| format!("malformed size '{}' in checksum line: '{line}'", parts[1]))?;
let name = parts[2].to_string();
let entry = FileEntry {
name,
size,
sha256: (digest == DigestKind::Sha256).then_some(hash.clone()),
md5: (digest == DigestKind::Md5).then_some(hash.clone()),
};
files.push(entry);
}
Ok(files)
}
/// Verify every listed file: it must exist next to the `.changes` file and
/// match the expected size and checksums.
pub fn validate(changes: &ChangesFile) -> Result<(), Box<dyn std::error::Error>> {
let dir = changes.path.parent().unwrap_or_else(|| Path::new("."));
for file in &changes.files {
let path = dir.join(&file.name);
let mut checksums = FileChecksums::new();
checksums
.add_file(&path)
.map_err(|e| format!("cannot upload '{}': {}", file.name, e))?;
let actual = checksums.get(&file.name).unwrap();
if !file.matches(actual) {
return Err(format!(
"'{}' does not match its checksums in '{}': \
the file changed since the build (rebuild with `pkh build`)",
path.display(),
changes.path.display()
)
.into());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const SIGNATURE: &str = "\
-----BEGIN PGP SIGNATURE-----
iQIzBAABCgAdFiEEfakefingerprintXfakecommentlength
-----END PGP SIGNATURE-----";
/// Write a minimal valid changes file plus its two artifacts; returns
/// (changes path, dsc content, tarball content)
fn fixture(dir: &Path, distribution: &str) -> (PathBuf, Vec<u8>, Vec<u8>) {
let dsc = b"dsc content";
let tarball = b"tarball content";
let mut cs = FileChecksums::new();
let dsc_path = dir.join("hello_1.0-1.dsc");
let tarball_path = dir.join("hello_1.0.orig.tar.gz");
std::fs::write(&dsc_path, dsc).unwrap();
std::fs::write(&tarball_path, tarball).unwrap();
cs.add_file(&dsc_path).unwrap();
cs.add_file(&tarball_path).unwrap();
let changes_path = dir.join("hello_1.0-1_source.changes");
// dpkg's default signing layout: the whole document clearsigned,
// armor headers (`Hash:`) and a blank line before the fields
let mut content = format!(
"-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n\
Format: 1.8\nSource: hello\nBinary: hello\nArchitecture: source\n\
Version: 1.0-1\nDistribution: {distribution}\nUrgency: medium\n\
Maintainer: A B <a@b>\nChecksums-Sha256:{}\n",
// deb822 continuation lines are indented
cs.field_sha256().replace('\n', "\n ")
);
content.push_str(SIGNATURE);
std::fs::write(&changes_path, content).unwrap();
(changes_path, dsc.to_vec(), tarball.to_vec())
}
#[test]
fn parse_and_validate_valid_changes() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
let changes = parse(&path).unwrap();
assert_eq!(changes.source, "hello");
assert_eq!(changes.version, "1.0-1");
assert_eq!(changes.distribution, "resolute");
assert_eq!(changes.files.len(), 2);
assert_eq!(changes.files[0].name, "hello_1.0-1.dsc");
validate(&changes).unwrap();
}
/// The other dpkg signing layout: plain deb822 content with the
/// signature block merely appended (no `PGP SIGNED MESSAGE` wrapper)
#[test]
fn parse_handles_appended_signature_layout() {
let dir = tempfile::tempdir().unwrap();
let dsc = b"dsc content";
std::fs::write(dir.path().join("hello_1.0-1.dsc"), dsc).unwrap();
let mut cs = FileChecksums::new();
let dsc_path = dir.path().join("hello_1.0-1.dsc");
cs.add_file(&dsc_path).unwrap();
let content = format!(
"Format: 1.8\nSource: hello\nVersion: 1.0-1\nDistribution: resolute\n\
Checksums-Sha256:{}\n{}",
cs.field_sha256().replace('\n', "\n "),
SIGNATURE
);
let path = dir.path().join("hello_1.0-1_source.changes");
std::fs::write(&path, content).unwrap();
let changes = parse(&path).unwrap();
assert_eq!(changes.source, "hello");
validate(&changes).unwrap();
}
#[test]
fn parse_rejects_unsigned_changes() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
let content = std::fs::read_to_string(&path).unwrap();
let unsigned = content
.split("-----BEGIN PGP SIGNATURE-----")
.next()
.unwrap();
std::fs::write(&path, unsigned).unwrap();
let err = parse(&path).unwrap_err().to_string();
assert!(err.contains("not signed"), "unexpected error: {err}");
}
#[test]
fn parse_rejects_unreleased_distribution() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "UNRELEASED");
let err = parse(&path).unwrap_err().to_string();
assert!(err.contains("UNRELEASED"), "unexpected error: {err}");
}
#[test]
fn validate_rejects_corrupted_file() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
std::fs::write(dir.path().join("hello_1.0-1.dsc"), b"tampered!").unwrap();
let changes = parse(&path).unwrap();
let err = validate(&changes).unwrap_err().to_string();
assert!(
err.contains("does not match its checksums"),
"unexpected: {err}"
);
}
#[test]
fn validate_rejects_missing_file() {
let dir = tempfile::tempdir().unwrap();
let (path, _, _) = fixture(dir.path(), "resolute");
std::fs::remove_file(dir.path().join("hello_1.0-1.dsc")).unwrap();
let changes = parse(&path).unwrap();
let err = validate(&changes).unwrap_err().to_string();
assert!(err.contains("cannot upload"), "unexpected: {err}");
}
#[test]
fn parse_falls_back_to_md5_files_section() {
let dir = tempfile::tempdir().unwrap();
let dsc = b"dsc content";
std::fs::write(dir.path().join("hello_1.0-1.dsc"), dsc).unwrap();
let mut cs = FileChecksums::new();
let dsc_path = dir.path().join("hello_1.0-1.dsc");
cs.add_file(&dsc_path).unwrap();
let content = format!(
"Format: 1.8\nSource: hello\nVersion: 1.0-1\nDistribution: resolute\nFiles:{}\n{}",
cs.field_md5().replace('\n', "\n "),
SIGNATURE
);
let path = dir.path().join("hello_1.0-1_source.changes");
std::fs::write(&path, content).unwrap();
let changes = parse(&path).unwrap();
assert_eq!(changes.files.len(), 1);
assert!(changes.files[0].sha256.is_none());
assert!(changes.files[0].md5.is_some());
validate(&changes).unwrap();
}
#[test]
fn parse_checksums_rejects_malformed_lines() {
assert!(parse_checksums(Some("not-a-checksum-line"), DigestKind::Sha256).is_err());
assert!(parse_checksums(Some("abc notanumber hello.dsc"), DigestKind::Sha256).is_err());
assert!(
parse_checksums(None, DigestKind::Sha256)
.unwrap()
.is_empty()
);
}
}
+487
View File
@@ -0,0 +1,487 @@
//! 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.
pub mod changes;
pub mod ssh;
pub mod target;
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::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<PathBuf>,
/// 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<dyn std::error::Error>> {
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 <your-launchpad-id>`, 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)?;
info!(
"Uploading {} {} ({}) to {}",
changes.source,
changes.version,
ui::display_path(&changes_path),
target.label
);
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)
check_control_section(&opts.cwd, "ubuntu")?;
info!("Checking {} on Launchpad...", target.label);
launchpad::ppa_info(&opts.ppa).await?;
launchpad::check_ppa_series(&changes.distribution).await?;
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());
}
info!("Connecting to {login}@{host}:{port}...");
let session = ssh::connect(&host, port, &login, &ssh_config)?;
let sftp = ssh::sftp(&session)?;
// 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()));
for (path, name) in &uploads {
let size = path
.metadata()
.map_err(|e| format!("cannot stat '{}': {}", path.display(), e))?
.len();
// 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!("{}/{}", target.incoming.trim_end_matches('/'), name);
let result = ssh::upload_file(&sftp, path, &remote, &bar);
bar.finish_and_clear();
result?;
}
record_upload(&upload_log_path()?, &record)?;
info!(
"Upload of {changes_name} to {} complete. Launchpad processes it asynchronously; \
watch the PPA page or your inbox for acceptance/rejection",
target.label
);
Ok(())
}
/// 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<dyn std::error::Error>> {
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())
}
/// Find the `.changes` file of the package in `cwd`: the exact
/// `<source>_<version>_source.changes` written by `pkh build` (in the source
/// directory's parent or the directory itself), or the only other
/// `<source>_*.changes` present. Errors listing the candidates when several
/// exist.
fn discover_changes(cwd: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
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<PathBuf> = 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<PathBuf> = 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::<Vec<_>>()
.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, 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<UploadRecord, Box<dyn std::error::Error>> {
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 (`<data dir>/pkh/uploads.json`)
fn upload_log_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
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
fn load_upload_log(path: &Path) -> Vec<UploadRecord> {
std::fs::read_to_string(path)
.ok()
.and_then(|content| serde_json::from_str(&content).ok())
.unwrap_or_default()
}
/// 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<Option<UploadRecord>, Box<dyn std::error::Error>> {
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<dyn std::error::Error>> {
let mut entries = load_upload_log(log);
entries.push(record.clone());
std::fs::write(log, serde_json::to_string_pretty(&entries)?)?;
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 <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}");
}
#[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());
}
}
+611
View File
@@ -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
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Generic upload target vocabulary for `pkh put`. Concrete targets are
//! built by their service modules (Launchpad — [`crate::launchpad`] —
//! today, archive targets later); this module only defines what any upload
//! target looks like and how the SSH username resolves when the service
//! does not pin one.
/// A concrete SFTP upload destination.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UploadTarget {
/// Hostname to connect to.
pub fqdn: String,
/// TCP port (always 22 for now; SFTP only).
pub port: u16,
/// Default SSH username, when the service has one; `None` means the
/// username resolves per machine (service-specific discovery — git
/// configuration for Launchpad — then SSH configuration `User`, then
/// the local user name).
pub login: Option<String>,
/// Incoming directory on the server, relative to the SFTP root. For
/// Launchpad targets this is a literal `~user/...` path: the upload
/// queue is write-only, so the path names the destination rather than a
/// browsable directory.
pub incoming: String,
/// Human-readable target name for logs and the upload record.
pub label: String,
}