Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0b7e341fc | ||
|
|
e8d4b98f52 | ||
|
|
0c2cf0ac5e | ||
|
|
f27d27ea99 |
@@ -29,6 +29,7 @@ Commands:
|
||||
chlog Auto-generate changelog entry, editing it, committing it afterwards
|
||||
build Build the source package (into a .dsc)
|
||||
deb Build the source package into binary package (.deb)
|
||||
put Upload the built source package to a PPA
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
```
|
||||
|
||||
@@ -104,7 +105,7 @@ Missing features:
|
||||
- [ ] `pkh status`
|
||||
- [ ] Show build status
|
||||
- [ ] `pkh put`
|
||||
- [ ] Upload the source package to a PPA
|
||||
- [x] Upload the source package to a PPA (native SFTP, no `dput` dependency)
|
||||
- [ ] Upload the source package to the archive
|
||||
- [ ] `pkh commit`
|
||||
- [ ] Commit the changes to git
|
||||
|
||||
+125
@@ -15,6 +15,70 @@ dist:
|
||||
- updates
|
||||
- security
|
||||
- proposed-updates
|
||||
sections:
|
||||
# Valid Section values for debian/control: the Debian policy section
|
||||
# list unioned with the sections observed in the live Ubuntu archive.
|
||||
# Archives reject uploads carrying an unknown section; only the part
|
||||
# before a '/' (the subsection) is validated.
|
||||
- admin
|
||||
- cli-mono
|
||||
- comm
|
||||
- database
|
||||
- debian-installer
|
||||
- debug
|
||||
- devel
|
||||
- doc
|
||||
- editors
|
||||
- education
|
||||
- electronics
|
||||
- embedded
|
||||
- fonts
|
||||
- games
|
||||
- gnome
|
||||
- gnu-r
|
||||
- golang
|
||||
- graphics
|
||||
- hamradio
|
||||
- haskell
|
||||
- httpd
|
||||
- interpreters
|
||||
- introspection
|
||||
- java
|
||||
- javascript
|
||||
- kde
|
||||
- kernel
|
||||
- libdevel
|
||||
- libs
|
||||
- lisp
|
||||
- localization
|
||||
- mail
|
||||
- math
|
||||
- metapackages
|
||||
- misc
|
||||
- net
|
||||
- news
|
||||
- ocaml
|
||||
- oldlibs
|
||||
- otherosfs
|
||||
- perl
|
||||
- php
|
||||
- python
|
||||
- ruby
|
||||
- rust
|
||||
- science
|
||||
- shells
|
||||
- sound
|
||||
- tasks
|
||||
- tex
|
||||
- text
|
||||
- translations
|
||||
- utils
|
||||
- vcs
|
||||
- video
|
||||
- web
|
||||
- x11
|
||||
- xfce
|
||||
- zope
|
||||
series:
|
||||
local: /usr/share/distro-info/debian.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/debian.csv
|
||||
@@ -25,6 +89,67 @@ dist:
|
||||
- updates
|
||||
- security
|
||||
- proposed
|
||||
sections:
|
||||
# Same list as debian (see the comment there)
|
||||
- admin
|
||||
- cli-mono
|
||||
- comm
|
||||
- database
|
||||
- debian-installer
|
||||
- debug
|
||||
- devel
|
||||
- doc
|
||||
- editors
|
||||
- education
|
||||
- electronics
|
||||
- embedded
|
||||
- fonts
|
||||
- games
|
||||
- gnome
|
||||
- gnu-r
|
||||
- golang
|
||||
- graphics
|
||||
- hamradio
|
||||
- haskell
|
||||
- httpd
|
||||
- interpreters
|
||||
- introspection
|
||||
- java
|
||||
- javascript
|
||||
- kde
|
||||
- kernel
|
||||
- libdevel
|
||||
- libs
|
||||
- lisp
|
||||
- localization
|
||||
- mail
|
||||
- math
|
||||
- metapackages
|
||||
- misc
|
||||
- net
|
||||
- news
|
||||
- ocaml
|
||||
- oldlibs
|
||||
- otherosfs
|
||||
- perl
|
||||
- php
|
||||
- python
|
||||
- ruby
|
||||
- rust
|
||||
- science
|
||||
- shells
|
||||
- sound
|
||||
- tasks
|
||||
- tex
|
||||
- text
|
||||
- translations
|
||||
- utils
|
||||
- vcs
|
||||
- video
|
||||
- web
|
||||
- x11
|
||||
- xfce
|
||||
- zope
|
||||
series:
|
||||
local: /usr/share/distro-info/ubuntu.csv
|
||||
network: https://salsa.debian.org/debian/distro-info-data/-/raw/main/ubuntu.csv
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
## SSH host key fingerprints of known upload targets (`pkh put`).
|
||||
## Like distro_info.yml, this file exists so that trust anchors are data,
|
||||
## quickly updatable in one place, instead of hardcoded in the source.
|
||||
##
|
||||
## A server presenting a key whose fingerprint is listed for its host is
|
||||
## verified without prompting. Fingerprints are the `SHA256:<base64>` values
|
||||
## as displayed by ssh-keygen / pkh; an optional key type prefix (e.g.
|
||||
## `ssh-rsa`) is tolerated as the first word of an entry.
|
||||
##
|
||||
## Source of the Launchpad fingerprints (published "as a stopgap measure
|
||||
## until we have signed DNS records"):
|
||||
## https://ubuntu.com/docs/launchpad/user/reference/ssh-fingerprints/
|
||||
## (formerly https://help.launchpad.net/SSHFingerprints)
|
||||
|
||||
fingerprints:
|
||||
ppa.launchpad.net:
|
||||
- ssh-rsa SHA256:MGq+4hxD7RduVTcfwlwwboZnsgJC6SL/NltM8ye+gNg
|
||||
upload.ubuntu.com:
|
||||
- ssh-rsa SHA256:FN8sNU/MMmyvw/xtY5sAzkLGmkVQt2QpGZcwsHoBzjc
|
||||
@@ -33,6 +33,8 @@ struct DistData {
|
||||
base_url: String,
|
||||
archive_keyring: String,
|
||||
pockets: Vec<String>,
|
||||
#[serde(default)]
|
||||
sections: Vec<String>,
|
||||
series: SeriesInfo,
|
||||
}
|
||||
|
||||
@@ -316,6 +318,20 @@ pub fn get_dist_pockets(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
Ok(pockets)
|
||||
}
|
||||
|
||||
/// Get the valid `Section` values of a distribution's packages, as accepted
|
||||
/// by its archives (a `section/subsection` in debian/control validates on
|
||||
/// the part before the '/')
|
||||
pub fn get_sections(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let dist_data = DATA.dist.get(dist).ok_or_else(|| {
|
||||
format!(
|
||||
"Unknown distribution '{}'. Supported distributions are: {}.",
|
||||
dist,
|
||||
supported_dists().join(", ")
|
||||
)
|
||||
})?;
|
||||
Ok(dist_data.sections.clone())
|
||||
}
|
||||
|
||||
/// Get the sources URL for a distribution, series, pocket, and component
|
||||
pub fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() {
|
||||
@@ -487,6 +503,19 @@ pub async fn get_debian_series_number(series: &str) -> Result<Option<String>, Bo
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_sections() {
|
||||
// Both distributions bundle the policy section list
|
||||
for dist in ["debian", "ubuntu"] {
|
||||
let sections = get_sections(dist).unwrap();
|
||||
assert!(sections.contains(&"utils".to_string()));
|
||||
assert!(sections.contains(&"devel".to_string()));
|
||||
// 'unknown' is exactly what archives reject
|
||||
assert!(!sections.contains(&"unknown".to_string()));
|
||||
}
|
||||
assert!(get_sections("not-a-distro").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_series_csv_malformed_rows() {
|
||||
// A short row (missing 'codename') is skipped, a row with an invalid
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
//! Launchpad integration for `pkh put`: PPA upload targets, Launchpad
|
||||
//! account (username) discovery and pre-upload checks against the Launchpad
|
||||
//! API.
|
||||
//!
|
||||
//! Launchpad's SFTP upload server requires the SSH username to be a real
|
||||
//! Launchpad account name — anonymous logins are rejected ("Launchpad user
|
||||
//! 'anonymous' doesn't have a registered SSH key") — and authenticates it
|
||||
//! with the SSH keys registered on that account
|
||||
//! (<https://launchpad.net/~/+editsshkeys>). The username therefore has to
|
||||
//! be discovered on the machine rather than hardcoded: first from the git
|
||||
//! configuration ([`username`], the `lp.user` key), then through the generic
|
||||
//! fallbacks (SSH configuration `User`, local user name — see
|
||||
//! [`crate::put::ssh`]).
|
||||
//!
|
||||
//! The upload queue itself is a blind write: the SFTP server accepts any
|
||||
//! file an authenticated user puts into their incoming area, and invalid
|
||||
//! targets are only rejected later, during queue processing. The
|
||||
//! [`ppa_info`] check makes sure the target actually exists before anything
|
||||
//! is uploaded.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::put::target::UploadTarget;
|
||||
|
||||
/// Git configuration key holding the Launchpad account name
|
||||
const LP_USER_KEY: &str = "lp.user";
|
||||
|
||||
/// Base URL of the Launchpad REST API
|
||||
const API_BASE: &str = "https://api.launchpad.net/1.0";
|
||||
|
||||
/// The Launchpad username configured in git: the repository-local
|
||||
/// configuration wins over the global one, like git's own precedence.
|
||||
/// `None` when no git repository is found or the key is unset.
|
||||
pub fn username(cwd: &Path) -> Option<String> {
|
||||
// A repository's config covers the local file; the global/system levels
|
||||
// are consulted separately so the key is found in both setups
|
||||
if let Ok(repo) = git2::Repository::discover(cwd)
|
||||
&& let Ok(config) = repo.config()
|
||||
&& let Some(value) = config_value(&config)
|
||||
{
|
||||
return Some(value);
|
||||
}
|
||||
|
||||
if let Ok(config) = git2::Config::open_default() {
|
||||
return config_value(&config);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Trimmed, non-empty `lp.user` value of a configuration, `None` when unset
|
||||
fn config_value(config: &git2::Config) -> Option<String> {
|
||||
config
|
||||
.get_string(LP_USER_KEY)
|
||||
.ok()
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
/// Split a `user/ppa_name` PPA argument, rejecting malformed ones
|
||||
fn split_ppa(ppa: &str) -> Result<(String, String), String> {
|
||||
let parts: Vec<&str> = ppa.split('/').collect();
|
||||
if parts.len() != 2 || parts.iter().any(|p| p.is_empty()) {
|
||||
return Err(format!(
|
||||
"Invalid PPA format: '{ppa}'. Expected: user/ppa_name"
|
||||
));
|
||||
}
|
||||
|
||||
Ok((parts[0].to_string(), parts[1].to_string()))
|
||||
}
|
||||
|
||||
/// URL of the Launchpad API resource of a Launchpad account
|
||||
fn person_url(user: &str) -> String {
|
||||
format!("{API_BASE}/~{user}")
|
||||
}
|
||||
|
||||
/// URL of the Launchpad API resource of a PPA (`~user/+archive/ubuntu/name`
|
||||
/// covers the default `ppa` archive and named archives alike)
|
||||
fn archive_url(user: &str, ppa: &str) -> String {
|
||||
format!("{API_BASE}/~{user}/+archive/ubuntu/{ppa}")
|
||||
}
|
||||
|
||||
/// Resolve a `user/ppa_name` PPA argument into its upload target
|
||||
/// (`ppa.launchpad.net`, incoming `~user/ppa_name`), like dput-ng's
|
||||
/// `ppa:user/ppa` profile expansion.
|
||||
pub fn ppa_target(ppa: &str) -> Result<UploadTarget, String> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
|
||||
Ok(UploadTarget {
|
||||
fqdn: "ppa.launchpad.net".to_string(),
|
||||
port: 22,
|
||||
login: None,
|
||||
incoming: format!("~{user}/{name}"),
|
||||
label: format!("ppa:{ppa}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// The subset of the Launchpad Archive API resource relevant for uploads
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PpaInfo {
|
||||
/// Display name of the archive (e.g. "Noctalia")
|
||||
pub displayname: String,
|
||||
/// The archive's self-description
|
||||
pub description: Option<String>,
|
||||
/// Disabled archives accept no uploads; absent/null on many archives
|
||||
/// (treated as enabled)
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// Look up the PPA `user/name` (same format as `pkh put --ppa`) in the
|
||||
/// Launchpad API, failing with a precise message when the account or the
|
||||
/// archive does not exist, or the archive is disabled. This is the
|
||||
/// pre-flight check the SFTP queue itself never does.
|
||||
pub async fn ppa_info(ppa: &str) -> Result<PpaInfo, Box<dyn Error>> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
let client = crate::distro_info::http_client();
|
||||
|
||||
let response = client
|
||||
.get(person_url(&user))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
||||
return Err(format!(
|
||||
"Launchpad user '~{user}' does not exist: check the PPA argument '{ppa}'"
|
||||
)
|
||||
.into());
|
||||
} else if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Launchpad API returned {} for user '~{user}'",
|
||||
response.status()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let response = client
|
||||
.get(archive_url(&user, &name))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::OK => {
|
||||
let info: PpaInfo = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
|
||||
if info.enabled == Some(false) {
|
||||
return Err(
|
||||
format!("PPA '{ppa}' is disabled: it exists but accepts no uploads").into(),
|
||||
);
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
||||
}
|
||||
status => Err(format!("Launchpad API returned {status} for PPA '{ppa}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-encode a query-string value (RFC 3986): unreserved characters
|
||||
/// pass through, everything else becomes `%XX`. Debian source package names
|
||||
/// may contain `+` (`g++`), which must not reach the API unencoded — query
|
||||
/// values follow form-urlencoded rules, where a literal `+` decodes to a
|
||||
/// space.
|
||||
fn percent_encode(value: &str) -> String {
|
||||
let mut encoded = String::with_capacity(value.len());
|
||||
for byte in value.bytes() {
|
||||
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') {
|
||||
encoded.push(byte as char);
|
||||
} else {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
/// URL of the `getPublishedSources` API call listing the currently
|
||||
/// `Published` source packages named `source_name` in the PPA `user/name`:
|
||||
/// `exact_match` avoids Launchpad's default case-insensitive substring
|
||||
/// matching, which would return unrelated sources (`data` matching
|
||||
/// `datatables`).
|
||||
fn published_sources_url(user: &str, ppa: &str, source_name: &str) -> String {
|
||||
format!(
|
||||
"{}?ws.op=getPublishedSources&source_name={}&exact_match=true&status=Published",
|
||||
archive_url(user, ppa),
|
||||
percent_encode(source_name)
|
||||
)
|
||||
}
|
||||
|
||||
/// One entry of a `getPublishedSources` answer: the subset of the source
|
||||
/// package publishing history the superseded-upload check needs (the live
|
||||
/// answer carries many more fields, ignored by serde)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PublishedSource {
|
||||
/// Version of the published source package
|
||||
source_package_version: String,
|
||||
}
|
||||
|
||||
/// The `getPublishedSources` collection answer
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PublishedSources {
|
||||
/// The currently published source packages matching the query
|
||||
#[serde(default)]
|
||||
entries: Vec<PublishedSource>,
|
||||
}
|
||||
|
||||
/// Every version of `source_name` currently `Published` in the PPA
|
||||
/// `user/name` (same `user/ppa_name` format as `pkh put --ppa`), in API
|
||||
/// order. Empty when the source was never published there — a 200 answer
|
||||
/// with zero entries, the normal first-upload case.
|
||||
pub async fn published_versions(
|
||||
ppa: &str,
|
||||
source_name: &str,
|
||||
) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let (user, name) = split_ppa(ppa)?;
|
||||
let client = crate::distro_info::http_client();
|
||||
|
||||
let response = client
|
||||
.get(published_sources_url(&user, &name, source_name))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach the Launchpad API: {e}"))?;
|
||||
match response.status() {
|
||||
reqwest::StatusCode::OK => {
|
||||
let sources: PublishedSources = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("cannot parse the Launchpad API response for '{ppa}': {e}"))?;
|
||||
Ok(sources
|
||||
.entries
|
||||
.into_iter()
|
||||
.map(|entry| entry.source_package_version)
|
||||
.collect())
|
||||
}
|
||||
reqwest::StatusCode::NOT_FOUND => {
|
||||
Err(format!("PPA '{ppa}' does not exist: create it on launchpad.net first").into())
|
||||
}
|
||||
status => Err(format!("Launchpad API returned {status} for PPA '{ppa}'").into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// PPA uploads only target Ubuntu series: fail before uploading when the
|
||||
/// changes' distribution is not a known series (typo) or a non-Ubuntu one —
|
||||
/// both are only rejected during queue processing otherwise
|
||||
pub async fn check_ppa_series(distribution: &str) -> Result<(), Box<dyn Error>> {
|
||||
match crate::distro_info::get_dist_from_series(distribution).await {
|
||||
Ok(dist) if dist == "ubuntu" => Ok(()),
|
||||
Ok(dist) => Err(format!(
|
||||
"series '{distribution}' belongs to {dist}: PPA uploads target \
|
||||
Ubuntu series only"
|
||||
)
|
||||
.into()),
|
||||
Err(_) => Err(format!(
|
||||
"'{distribution}' is not a known distribution series: check the \
|
||||
debian/changelog entry, the upload would be rejected"
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ppa_target_expands_user_and_name() {
|
||||
let target = ppa_target("paultag/fluxbox").unwrap();
|
||||
assert_eq!(target.fqdn, "ppa.launchpad.net");
|
||||
assert_eq!(target.port, 22);
|
||||
assert_eq!(target.incoming, "~paultag/fluxbox");
|
||||
assert_eq!(target.label, "ppa:paultag/fluxbox");
|
||||
// No static login: the username is discovered per machine
|
||||
assert_eq!(target.login, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_missing_separator() {
|
||||
assert!(ppa_target("just-a-name").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_extra_components() {
|
||||
assert!(ppa_target("user/ppa/extra").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ppa_target_rejects_empty_components() {
|
||||
assert!(ppa_target("user/").is_err());
|
||||
assert!(ppa_target("/ppa").is_err());
|
||||
assert!(ppa_target("/").is_err());
|
||||
}
|
||||
|
||||
/// The `lp.user` key is read from the git configuration of the
|
||||
/// repository containing the working directory
|
||||
#[test]
|
||||
fn username_comes_from_repo_git_config() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
repo.config()
|
||||
.unwrap()
|
||||
.set_str(LP_USER_KEY, "vhaudiquet")
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(username(dir.path()).as_deref(), Some("vhaudiquet"));
|
||||
}
|
||||
|
||||
/// Values are trimmed, and an empty value counts as unset (it must not
|
||||
/// shadow a real lookup failure with a useless username)
|
||||
#[test]
|
||||
fn username_ignores_blank_values() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let repo = git2::Repository::init(dir.path()).unwrap();
|
||||
repo.config().unwrap().set_str(LP_USER_KEY, " ").unwrap();
|
||||
|
||||
// Blank local value: the resolution keeps looking (and finds
|
||||
// nothing here unless a global lp.user exists — the assertion
|
||||
// accepts either "no value" or a real global value, never the
|
||||
// blank one)
|
||||
let found = username(dir.path());
|
||||
assert_ne!(found.as_deref(), Some(" "));
|
||||
let _ = found;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_urls_match_launchpad_resources() {
|
||||
// Both URL shapes verified against the live API: 200 for an
|
||||
// existing account/archive, 404 for a missing one
|
||||
assert_eq!(
|
||||
person_url("vhaudiquet"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet"
|
||||
);
|
||||
assert_eq!(
|
||||
archive_url("vhaudiquet", "noctalia"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia"
|
||||
);
|
||||
}
|
||||
|
||||
/// The API answer carries many unrelated fields; deserialization must
|
||||
/// pick the relevant ones and tolerate a null `enabled`
|
||||
#[test]
|
||||
fn ppa_info_parses_api_response() {
|
||||
let json = r#"{
|
||||
"self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia",
|
||||
"web_link": "https://launchpad.net/~vhaudiquet/+archive/ubuntu/noctalia",
|
||||
"displayname": "Noctalia",
|
||||
"description": "Noctalia PPA with experimental builds",
|
||||
"enabled": null,
|
||||
"official_bug_tags": ["a11y", "appstream"]
|
||||
}"#;
|
||||
|
||||
let info: PpaInfo = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(info.displayname, "Noctalia");
|
||||
assert_eq!(
|
||||
info.description.as_deref(),
|
||||
Some("Noctalia PPA with experimental builds")
|
||||
);
|
||||
assert_eq!(info.enabled, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn percent_encode_keeps_unreserved_and_escapes_the_rest() {
|
||||
// The characters of Debian source package names pass through
|
||||
assert_eq!(percent_encode("noctalia"), "noctalia");
|
||||
assert_eq!(percent_encode("libfoo-1.0"), "libfoo-1.0");
|
||||
// `+` must be escaped: in query values it would decode to a space
|
||||
assert_eq!(percent_encode("g++"), "g%2B%2B");
|
||||
assert_eq!(percent_encode("a b/c?d&e"), "a%20b%2Fc%3Fd%26e");
|
||||
}
|
||||
|
||||
/// The query matches the verified live `getPublishedSources` call, with
|
||||
/// the source name percent-encoded
|
||||
#[test]
|
||||
fn published_sources_url_matches_launchpad_call() {
|
||||
assert_eq!(
|
||||
published_sources_url("vhaudiquet", "noctalia", "noctalia"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=noctalia&exact_match=true&status=Published"
|
||||
);
|
||||
assert_eq!(
|
||||
published_sources_url("vhaudiquet", "noctalia", "g++"),
|
||||
"https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia?ws.op=getPublishedSources&source_name=g%2B%2B&exact_match=true&status=Published"
|
||||
);
|
||||
}
|
||||
|
||||
/// The live answer carries many unrelated fields per entry; only
|
||||
/// `source_package_version` is needed (shape verified against the API)
|
||||
#[test]
|
||||
fn published_sources_parses_api_response() {
|
||||
let json = r#"{
|
||||
"start": 0,
|
||||
"total_size": 2,
|
||||
"entries": [
|
||||
{
|
||||
"self_link": "https://api.launchpad.net/1.0/~vhaudiquet/+archive/ubuntu/noctalia/+sourcepub/18737497",
|
||||
"resource_type_link": "https://api.launchpad.net/1.0/#source_package_publishing_history",
|
||||
"display_name": "noctalia 5.1.0-1ubuntu2 in stonking",
|
||||
"component_name": "main",
|
||||
"section_name": "x11",
|
||||
"status": "Published",
|
||||
"pocket": "Release",
|
||||
"date_published": "2026-09-16T19:36:46.116930+00:00",
|
||||
"scheduled_deletion_date": null,
|
||||
"source_package_name": "noctalia",
|
||||
"source_package_version": "5.1.0-1ubuntu2",
|
||||
"http_etag": "\"98f12b47\""
|
||||
},
|
||||
{"unknown_extra": {"nested": [1, 2]}, "source_package_version": "2:1.0-1"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let sources: PublishedSources = serde_json::from_str(json).unwrap();
|
||||
let versions: Vec<&str> = sources
|
||||
.entries
|
||||
.iter()
|
||||
.map(|entry| entry.source_package_version.as_str())
|
||||
.collect();
|
||||
assert_eq!(versions, vec!["5.1.0-1ubuntu2", "2:1.0-1"]);
|
||||
}
|
||||
|
||||
/// A 200 answer with zero entries is the normal "nothing published
|
||||
/// there" case, and must deserialize to an empty list
|
||||
#[test]
|
||||
fn published_sources_parses_empty_collection() {
|
||||
let sources: PublishedSources =
|
||||
serde_json::from_str(r#"{"start": 0, "total_size": 0, "entries": []}"#).unwrap();
|
||||
assert!(sources.entries.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,8 @@ pub mod deb;
|
||||
pub mod debian;
|
||||
/// Obtain general information about distribution, series, etc
|
||||
pub mod distro_info;
|
||||
/// Launchpad integration: PPA upload targets and account discovery
|
||||
pub mod launchpad;
|
||||
/// Scaffold a new Debian source package (`pkh new`)
|
||||
pub mod new;
|
||||
/// Obtain information about one or multiple packages
|
||||
@@ -24,6 +26,8 @@ pub mod package_info;
|
||||
pub mod prune;
|
||||
/// Download a source package locally
|
||||
pub mod pull;
|
||||
/// Upload a built source package to a PPA (or archive)
|
||||
pub mod put;
|
||||
/// Handle package-specific quirks and workarounds
|
||||
pub mod quirks;
|
||||
|
||||
|
||||
+38
@@ -164,6 +164,13 @@ fn main() {
|
||||
.about("Build the source package (into a .dsc)")
|
||||
.arg(arg!(--verbose "Show raw tool output instead of the live build view").required(false)),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("put")
|
||||
.about("Upload the built source package to a PPA")
|
||||
.arg(arg!(--ppa <ppa> "Upload to a PPA (format: user/ppa_name)"))
|
||||
.arg(arg!([changes] "Explicit .changes file to upload (default: the one built from this package, next to the source tree)").required(false))
|
||||
.arg(arg!(--force "Upload even if this exact .changes file was already uploaded to the target")),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("deb")
|
||||
.about("Build the source package into binary package (.deb)")
|
||||
@@ -470,6 +477,37 @@ fn main() {
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(("put", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
let ppa = sub_matches.get_one::<String>("ppa").map(|s| s.as_str());
|
||||
let changes = sub_matches
|
||||
.get_one::<String>("changes")
|
||||
.map(std::path::PathBuf::from);
|
||||
let force = sub_matches
|
||||
.get_one::<bool>("force")
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Only PPA targets are implemented for now
|
||||
let Some(ppa) = ppa else {
|
||||
error!(
|
||||
"pkh put needs a target: pass --ppa user/ppa_name \
|
||||
(archive uploads are not supported yet)"
|
||||
);
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
let options = pkh::put::PutOptions {
|
||||
ppa: ppa.to_string(),
|
||||
changes,
|
||||
force,
|
||||
cwd,
|
||||
};
|
||||
if let Err(e) = rt.block_on(async { pkh::put::put(&options, &multi).await }) {
|
||||
error!("{}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(("deb", sub_matches)) => {
|
||||
let cwd = current_dir_or_exit();
|
||||
let series = sub_matches.get_one::<String>("series").map(|s| s.as_str());
|
||||
|
||||
-82
@@ -1,82 +0,0 @@
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use crate::ProgressCallback;
|
||||
use std::fs;
|
||||
use pkh::package_info::parse_control_file;
|
||||
|
||||
/// Execute the `put` subcommand to upload package to PPA or archive
|
||||
///
|
||||
/// # Arguments
|
||||
/// - series: Target distribution series (e.g. "focal")
|
||||
/// - dist: Target distribution (e.g. "ubuntu")
|
||||
/// - version: Package version override
|
||||
/// - ppa: Target PPA in "user/ppa-name" format
|
||||
/// - archive: Set to true for official archive uploads
|
||||
/// - cwd: Current working directory containing source package
|
||||
/// - progress: Progress callback for UI updates
|
||||
pub async fn put(
|
||||
series: Option<&str>,
|
||||
dist: Option<&str>,
|
||||
version: Option<&str>,
|
||||
ppa: Option<&str>,
|
||||
archive: bool,
|
||||
cwd: Option<&Path>,
|
||||
progress: ProgressCallback<'_>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let current_dir = cwd.unwrap_or_else(|| Path::new("."));
|
||||
let control_path = current_dir.join("debian/control");
|
||||
|
||||
let control_content = fs::read_to_string(&control_path).map_err(|e| {
|
||||
format!("Failed to read debian/control: {}. Are you in a source package directory?", e)
|
||||
})?;
|
||||
|
||||
let package_info = parse_control_file(&control_content)?;
|
||||
let package = package_info.source.ok_or("Could not determine package name from debian/control")?;
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb(&package, "Uploading package...", 0, 1);
|
||||
}
|
||||
|
||||
// Find .dsc file in current directory
|
||||
let dsc_files: Vec<_> = current_dir.read_dir()?
|
||||
.filter_map(|entry| {
|
||||
let entry = entry.ok()?;
|
||||
let path = entry.path();
|
||||
if path.extension()? == "dsc" {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let dsc_file = dsc_files.first().ok_or("No .dsc file found in current directory")?;
|
||||
|
||||
if dsc_files.len() > 1 {
|
||||
return Err("Multiple .dsc files found - please make sure only one exists".into());
|
||||
}
|
||||
|
||||
if archive {
|
||||
println!("Uploading {} to official archive", dsc_file.display());
|
||||
// Execute dput with official archive config
|
||||
Command::new("dput")
|
||||
.arg("ubuntu")
|
||||
.arg(dsc_file)
|
||||
.status()?;
|
||||
} else if let Some(ppa) = ppa {
|
||||
println!("Uploading {} to PPA: {}", dsc_file.display(), ppa);
|
||||
// Execute dput with PPA target
|
||||
Command::new("dput")
|
||||
.arg(format!("ppa:{}", ppa))
|
||||
.arg(dsc_file)
|
||||
.status()?;
|
||||
} else {
|
||||
return Err("Must specify either --ppa for PPA upload or --archive for official archive".into());
|
||||
}
|
||||
|
||||
if let Some(cb) = progress {
|
||||
cb(&package, "Upload complete", 1, 1);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
//! `.changes` file parsing and pre-upload validation: OpenPGP signature
|
||||
//! verification, 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 — or one whose signature does not
|
||||
//! verify against the local keyring — 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: verify its OpenPGP signature against the local
|
||||
/// keyring, then 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());
|
||||
}
|
||||
|
||||
// Launchpad authenticates the upload through the OpenPGP signature, so
|
||||
// verify it locally, like dput-ng with `allow_unsigned_uploads = false`:
|
||||
// a tampered document, or one signed by a key absent from the keyring,
|
||||
// must fail before anything is uploaded.
|
||||
crate::utils::gpg::verify_changes_signature(&content)
|
||||
.map_err(|e| format!("'{}': {e}", path.display()))?;
|
||||
|
||||
parse_body(&content, path)
|
||||
}
|
||||
|
||||
/// Parse the deb822 content of an already signature-checked `.changes`
|
||||
/// document: strip the signing armor, extract the upload fields and the
|
||||
/// file list from the checksums sections.
|
||||
fn parse_body(content: &str, path: &Path) -> Result<ChangesFile, Box<dyn std::error::Error>> {
|
||||
// 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)
|
||||
///
|
||||
/// The signature block is a fake: parsing tests exercise the two
|
||||
/// signing layouts only, the cryptographic verification happens before
|
||||
/// [`parse_body`] and is covered in `utils::gpg` (see the ignored
|
||||
/// throwaway-keyring test there).
|
||||
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 content = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
let changes = parse_body(&content, &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_body(&content, &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();
|
||||
|
||||
// Detected before the signature verification, so this stays offline
|
||||
let err = parse(&path).unwrap_err().to_string();
|
||||
assert!(err.contains("not signed"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_unreleased_distribution() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (path, _, _) = fixture(dir.path(), "UNRELEASED");
|
||||
let content = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
let err = parse_body(&content, &path).unwrap_err().to_string();
|
||||
assert!(err.contains("UNRELEASED"), "unexpected: {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 content = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
let changes = parse_body(&content, &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 content = std::fs::read_to_string(&path).unwrap();
|
||||
|
||||
let changes = parse_body(&content, &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_body(&content, &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()
|
||||
);
|
||||
}
|
||||
}
|
||||
+759
@@ -0,0 +1,759 @@
|
||||
//! 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::cmp::Ordering;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use indicatif::{MultiProgress, ProgressBar};
|
||||
use log::info;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::debian::changelog::parse_changelog_entry;
|
||||
use crate::debian::checksums::FileChecksums;
|
||||
use crate::debian::control::ControlInfo;
|
||||
use crate::debian::version::DebianVersion;
|
||||
use crate::launchpad;
|
||||
use crate::ui;
|
||||
|
||||
/// Everything `put` needs to run.
|
||||
pub struct PutOptions {
|
||||
/// PPA to upload to, `user/ppa_name` format.
|
||||
pub ppa: String,
|
||||
/// Explicit `.changes` file to upload; when `None`, the one matching the
|
||||
/// current package (from `debian/changelog`) is discovered next to the
|
||||
/// source tree.
|
||||
pub changes: Option<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)?;
|
||||
|
||||
// The summary line stays up for the whole flow: the completion message
|
||||
// replaces it on success, and the `PutBars` guard clears it on every
|
||||
// early `?` bail so the error logged by main is not preceded by stale
|
||||
// bars
|
||||
let mut bars = PutBars::default();
|
||||
let summary = multi.add(ProgressBar::new(0));
|
||||
// Style and prefix go in before the steady tick: otherwise the first
|
||||
// tick can render one frame with the default bar template
|
||||
summary.set_style(ui::spinner_style());
|
||||
summary.set_prefix(format!(
|
||||
"Uploading {} {} to {}",
|
||||
changes.source, changes.version, target.label
|
||||
));
|
||||
summary.enable_steady_tick(TICK);
|
||||
bars.track(summary.clone());
|
||||
changes::validate(&changes)?;
|
||||
|
||||
// Pre-flight checks for everything the upload queue only rejects after
|
||||
// processing: a valid Section, a known target series, and the target
|
||||
// PPA actually existing (the SFTP queue itself is a blind write)
|
||||
check_control_section(&opts.cwd, "ubuntu")?;
|
||||
|
||||
let checking = multi.add(ProgressBar::new(0));
|
||||
checking.set_style(ui::spinner_style());
|
||||
checking.set_prefix(format!("Checking {} on Launchpad...", target.label));
|
||||
checking.enable_steady_tick(TICK);
|
||||
bars.track(checking.clone());
|
||||
launchpad::ppa_info(&opts.ppa).await?;
|
||||
launchpad::check_ppa_series(&changes.distribution).await?;
|
||||
clear_bar(&checking);
|
||||
|
||||
// Last pre-flight check: a version the PPA already publishes at or
|
||||
// above the changes' one would supersede (or reject) this upload
|
||||
let published = launchpad::published_versions(&opts.ppa, &changes.source).await?;
|
||||
check_not_superseded(&target, &changes, &published, opts.force)?;
|
||||
|
||||
let record = upload_record(&target, &changes_path)?;
|
||||
if !opts.force
|
||||
&& let Some(previous) = find_previous_upload(&upload_log_path()?, &record)?
|
||||
{
|
||||
return Err(format!(
|
||||
"'{}' was already uploaded to {} on {} (use --force to upload again)",
|
||||
record.file, target.label, previous.date
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
let connecting = multi.add(ProgressBar::new(0));
|
||||
connecting.set_style(ui::spinner_style());
|
||||
connecting.set_prefix(format!("Connecting to {login}@{host}:{port}..."));
|
||||
connecting.enable_steady_tick(TICK);
|
||||
bars.track(connecting.clone());
|
||||
let session = ssh::connect(&host, port, &login, &ssh_config)?;
|
||||
let sftp = ssh::sftp(&session)?;
|
||||
clear_bar(&connecting);
|
||||
|
||||
// Payload first, the .changes file last (like dput), so the server-side
|
||||
// queue processor can never pick up an incomplete upload
|
||||
let dir = changes_path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut uploads: Vec<(PathBuf, String)> = changes
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| (dir.join(&f.name), f.name.clone()))
|
||||
.collect();
|
||||
let changes_name = changes_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.ok_or_else(|| format!("invalid .changes path: {}", changes_path.display()))?
|
||||
.to_string();
|
||||
uploads.push((changes_path.clone(), changes_name.clone()));
|
||||
|
||||
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)?;
|
||||
|
||||
// The completion lines replace the summary bar; the guard's own clear
|
||||
// at scope exit is a no-op for the already-finished bars
|
||||
clear_bar(&summary);
|
||||
info!(
|
||||
"Upload of {} {} to {} complete.",
|
||||
changes.source, changes.version, target.label
|
||||
);
|
||||
info!(
|
||||
"Launchpad processes it asynchronously; watch the PPA page or your \
|
||||
inbox for acceptance/rejection"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Steady tick interval of every bar rendered by a `put` run
|
||||
const TICK: std::time::Duration = std::time::Duration::from_millis(50);
|
||||
|
||||
/// Stop `bar`'s steady tick and clear it from the terminal. The tick is
|
||||
/// disabled first: a tick firing right after the clear would redraw a stale
|
||||
/// frame, the race [`crate::ui::deb::DebUi::suspend`] guards against too.
|
||||
/// Clearing twice is harmless: finished bars stay finished.
|
||||
fn clear_bar(bar: &ProgressBar) {
|
||||
bar.disable_steady_tick();
|
||||
bar.finish_and_clear();
|
||||
}
|
||||
|
||||
/// The bars rendered by one `put` run, cleared when the guard drops. The
|
||||
/// error paths bail out early through `?` and `main` logs the error
|
||||
/// afterwards, so a bar left unfinished would linger on screen above it.
|
||||
#[derive(Default)]
|
||||
struct PutBars {
|
||||
bars: Vec<ProgressBar>,
|
||||
}
|
||||
|
||||
impl PutBars {
|
||||
/// Track `bar` so it is cleared with the rest when the guard drops
|
||||
fn track(&mut self, bar: ProgressBar) {
|
||||
self.bars.push(bar);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PutBars {
|
||||
fn drop(&mut self) {
|
||||
for bar in &self.bars {
|
||||
clear_bar(bar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate the source package's `Section` (debian/control source stanza)
|
||||
/// against the distribution's valid sections: a bare section or a
|
||||
/// `section/subsection` is accepted. Archives reject uploads carrying an
|
||||
/// unknown section during queue processing, so this fails before anything
|
||||
/// is uploaded.
|
||||
fn check_control_section(cwd: &Path, dist: &str) -> Result<(), Box<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())
|
||||
}
|
||||
|
||||
/// The highest version among `published` that parses as a Debian version,
|
||||
/// `None` when the list is empty or nothing in it parses (entries that
|
||||
/// cannot be parsed are skipped rather than failing the check: the API
|
||||
/// data is trusted to carry valid Debian versions)
|
||||
fn max_published(published: &[String]) -> Option<DebianVersion> {
|
||||
published
|
||||
.iter()
|
||||
.filter_map(|version| DebianVersion::parse(version).ok())
|
||||
.max()
|
||||
}
|
||||
|
||||
/// Refuse an upload the target PPA already supersedes: when a version equal
|
||||
/// to or higher than the changes' one is published there for the same
|
||||
/// source, the queue would reject the upload (equal version) or silently
|
||||
/// supersede it (older one) — Launchpad never replaces a published version
|
||||
/// with a lower one. `force` downgrades the refusal to a warning.
|
||||
fn check_not_superseded(
|
||||
target: &target::UploadTarget,
|
||||
changes: &changes::ChangesFile,
|
||||
published: &[String],
|
||||
force: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let local = match DebianVersion::parse(&changes.version) {
|
||||
Ok(version) => version,
|
||||
Err(e) => {
|
||||
// Only the pre-flight comparison is skipped: the upload itself
|
||||
// is valid as far as this check is concerned
|
||||
log::warn!(
|
||||
"cannot compare {} against the published versions of {} ({e}): \
|
||||
skipping the published-version check",
|
||||
changes.version,
|
||||
changes.source
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
let Some(latest) = max_published(published) else {
|
||||
return Ok(());
|
||||
};
|
||||
if local.cmp(&latest) == Ordering::Greater {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let bump = "bump the version with `pkh chlog` and rebuild, or pass --force to upload anyway";
|
||||
if force {
|
||||
log::warn!(
|
||||
"{} {} is superseded by {} {} already published in {}: \
|
||||
uploading anyway (--force)",
|
||||
changes.source,
|
||||
local.full(),
|
||||
changes.source,
|
||||
latest.full(),
|
||||
target.label
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
match local.cmp(&latest) {
|
||||
Ordering::Equal => Err(format!(
|
||||
"{} {} is already published in {} (published version {}): {bump}",
|
||||
changes.source,
|
||||
local.full(),
|
||||
target.label,
|
||||
latest.full()
|
||||
)
|
||||
.into()),
|
||||
_ => Err(format!(
|
||||
"{} {} is older than the {} {} already published in {}: {bump}",
|
||||
changes.source,
|
||||
local.full(),
|
||||
changes.source,
|
||||
latest.full(),
|
||||
target.label
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find the `.changes` file of the package in `cwd`: the exact
|
||||
/// `<source>_<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());
|
||||
}
|
||||
|
||||
/// A minimal in-memory changes file for the superseded-version check
|
||||
fn changes_fixture(version: &str) -> changes::ChangesFile {
|
||||
changes::ChangesFile {
|
||||
path: PathBuf::from("hello_1.0-1_source.changes"),
|
||||
source: "hello".to_string(),
|
||||
version: version.to_string(),
|
||||
distribution: "resolute".to_string(),
|
||||
files: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn versions(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|v| v.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_check_rejects_equal_published_version() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let changes = changes_fixture("1.2.3-1");
|
||||
let err = check_not_superseded(&target, &changes, &versions(&["1.2.3-1"]), false)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("hello 1.2.3-1 is already published in ppa:user/ppa"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.contains("published version 1.2.3-1"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
assert!(err.contains("--force"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_check_rejects_older_upload() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let changes = changes_fixture("1.0-1");
|
||||
let err = check_not_superseded(&target, &changes, &versions(&["2.0-1"]), false)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("hello 1.0-1 is older than the hello 2.0-1 already published"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_check_accepts_newer_version() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let changes = changes_fixture("2.0-1");
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_check_accepts_never_published_source() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let changes = changes_fixture("1.0-1");
|
||||
check_not_superseded(&target, &changes, &[], false).unwrap();
|
||||
}
|
||||
|
||||
/// The comparison follows dpkg ordering: `~` pre-releases, epochs and
|
||||
/// Ubuntu revision suffixes all sort correctly
|
||||
#[test]
|
||||
fn superseded_check_uses_dpkg_ordering() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
|
||||
// A tilde pre-release is older than the real release
|
||||
let changes = changes_fixture("1.0~rc1-1");
|
||||
assert!(check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).is_err());
|
||||
|
||||
// The real release supersedes the pre-release
|
||||
let changes = changes_fixture("1.0-1");
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0~rc1-1"]), false).unwrap();
|
||||
|
||||
// An epoch outranks a higher upstream version
|
||||
let changes = changes_fixture("2.0-1");
|
||||
assert!(check_not_superseded(&target, &changes, &versions(&["1:1.0-1"]), false).is_err());
|
||||
|
||||
// ubuntu1.1 point updates supersede the base upload
|
||||
let changes = changes_fixture("1.0-1ubuntu1");
|
||||
assert!(
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0-1ubuntu1.1"]), false).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superseded_check_force_warns_instead_of_erroring() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let changes = changes_fixture("1.0-1");
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0-1", "0.9-1"]), true).unwrap();
|
||||
}
|
||||
|
||||
/// Versions the parser rejects never fail the upload: an unparseable
|
||||
/// local version skips the check, unparseable published entries are
|
||||
/// ignored
|
||||
#[test]
|
||||
fn superseded_check_tolerates_unparseable_versions() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
|
||||
let changes = changes_fixture("not_a_debian_version");
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0-1"]), false).unwrap();
|
||||
|
||||
let changes = changes_fixture("1.0-1");
|
||||
check_not_superseded(&target, &changes, &versions(&["1.0_beta", "0.9-1"]), false).unwrap();
|
||||
}
|
||||
|
||||
/// Several published versions (multiple series/pockets): the highest
|
||||
/// one decides
|
||||
#[test]
|
||||
fn superseded_check_compares_against_highest_published() {
|
||||
let target = launchpad::ppa_target("user/ppa").unwrap();
|
||||
let published = versions(&["1.0-1", "1.6-1", "1.2-3"]);
|
||||
|
||||
// 1.5-1 supersedes everything but the 1.6-1 entry, which decides
|
||||
let changes = changes_fixture("1.5-1");
|
||||
let err = check_not_superseded(&target, &changes, &published, false)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("1.6-1"), "unexpected: {err}");
|
||||
|
||||
// ...and going one higher than the highest entry passes
|
||||
let changes = changes_fixture("1.7-1");
|
||||
check_not_superseded(&target, &changes, &published, false).unwrap();
|
||||
}
|
||||
}
|
||||
+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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -31,6 +31,24 @@ pub fn display_path(path: &Path) -> String {
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
/// Style of an unsized operation: spinner and prefix on one line
|
||||
pub(crate) fn spinner_style() -> ProgressStyle {
|
||||
ProgressStyle::default_bar()
|
||||
.template("> {spinner:.blue} {prefix}")
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// Style of a sized transfer: prefix on the first line, the bar on its own
|
||||
/// indented line below so long prefixes cannot push it out of the terminal
|
||||
pub(crate) fn transfer_style() -> ProgressStyle {
|
||||
ProgressStyle::default_bar()
|
||||
.template(
|
||||
"> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})",
|
||||
)
|
||||
.unwrap()
|
||||
.progress_chars("=> ")
|
||||
}
|
||||
|
||||
/// Create a spinner-style progress bar attached to `multi`, returning the bar
|
||||
/// and a callback compatible with [`crate::ProgressCallback`]
|
||||
pub fn create_progress_bar(
|
||||
@@ -38,26 +56,15 @@ pub fn create_progress_bar(
|
||||
) -> (ProgressBar, impl Fn(&str, &str, usize, usize) + '_) {
|
||||
let pb = multi.add(ProgressBar::new(0));
|
||||
pb.enable_steady_tick(Duration::from_millis(50));
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("> {spinner:.blue} {prefix}")
|
||||
.unwrap(),
|
||||
);
|
||||
pb.set_style(spinner_style());
|
||||
|
||||
let pb_clone = pb.clone();
|
||||
let callback = move |prefix: &str, msg: &str, progress: usize, total: usize| {
|
||||
let pb = &pb_clone;
|
||||
if progress != 0 && total != 0 {
|
||||
pb.set_style(ProgressStyle::default_bar()
|
||||
.template("> {spinner:.blue} {prefix}\n {msg} [{bar:40.cyan/blue}] {pos}/{len} ({eta})")
|
||||
.unwrap()
|
||||
.progress_chars("=> "));
|
||||
pb.set_style(transfer_style());
|
||||
} else {
|
||||
pb.set_style(
|
||||
ProgressStyle::default_bar()
|
||||
.template("> {spinner:.blue} {prefix}")
|
||||
.unwrap(),
|
||||
);
|
||||
pb.set_style(spinner_style());
|
||||
}
|
||||
|
||||
if !prefix.is_empty() {
|
||||
|
||||
+327
-3
@@ -1,11 +1,18 @@
|
||||
//! GPG / OpenPGP helpers: secret key discovery and inline (clear) signing
|
||||
//! of Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files.
|
||||
//! GPG / OpenPGP helpers: secret key discovery, inline (clear) signing of
|
||||
//! Debian artifacts such as `.dsc`, `.buildinfo` and `.changes` files, and
|
||||
//! local verification of signed documents.
|
||||
|
||||
use std::error::Error;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
use gpgme::{Context, Data, Protocol};
|
||||
use gpgme::error::Error as GpgError;
|
||||
use gpgme::{Context, Data, Protocol, Signature, SignatureSummary, VerificationResult};
|
||||
|
||||
/// The armor marker opening an OpenPGP signature block.
|
||||
const SIGNATURE_BEGIN: &str = "-----BEGIN PGP SIGNATURE-----";
|
||||
/// The armor marker opening a clearsigned message.
|
||||
const CLEARSIGNED_BEGIN: &str = "-----BEGIN PGP SIGNED MESSAGE-----";
|
||||
|
||||
/// Check if a GPG key matching 'email' exists
|
||||
/// Returns the key ID if found, None otherwise
|
||||
@@ -111,6 +118,151 @@ pub fn clearsign_file(path: &Path, keyid: &str) -> Result<(), Box<dyn Error>> {
|
||||
.map_err(|e| format!("cannot install signed '{}': {}", path.display(), e).into())
|
||||
}
|
||||
|
||||
/// How a document carries its OpenPGP signature: Debian tools
|
||||
/// (`dpkg-buildpackage`, `debsign`) either wrap the whole document in a
|
||||
/// clearsigned armor or append an armored signature block to the plain
|
||||
/// deb822 body.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum SignatureLayout<'a> {
|
||||
/// Whole document clearsigned: the signed text and the signature live
|
||||
/// in the same armor buffer.
|
||||
Clearsigned(&'a str),
|
||||
/// Signature block appended after the plain body: the signed text and
|
||||
/// the armored detached signature are distinct parts.
|
||||
Detached {
|
||||
/// The signed text, everything before the signature block.
|
||||
body: &'a str,
|
||||
/// The armored signature block, from its BEGIN marker to the end.
|
||||
signature: &'a str,
|
||||
},
|
||||
}
|
||||
|
||||
/// Detect how `content` is signed and split it into signed text and
|
||||
/// signature. Returns `None` when no signature block is present.
|
||||
///
|
||||
/// The split preserves the exact bytes of both parts: the body is
|
||||
/// byte-for-byte what the signer signed, which is what a detached
|
||||
/// verification must hash.
|
||||
fn split_signature(content: &str) -> Option<SignatureLayout<'_>> {
|
||||
if content.starts_with(CLEARSIGNED_BEGIN) {
|
||||
return Some(SignatureLayout::Clearsigned(content));
|
||||
}
|
||||
let (body, _) = content.split_once(SIGNATURE_BEGIN)?;
|
||||
Some(SignatureLayout::Detached {
|
||||
body,
|
||||
signature: &content[body.len()..],
|
||||
})
|
||||
}
|
||||
|
||||
/// Verify the OpenPGP signature of a signed document (`.changes`, `.dsc`,
|
||||
/// `.buildinfo`): both dpkg signing layouts are accepted, the whole
|
||||
/// document clearsigned or the signature block merely appended to the body.
|
||||
///
|
||||
/// Like dput-ng with `allow_unsigned_uploads = false`, the signature must
|
||||
/// verify against the local keyring and the signing key must be present
|
||||
/// locally, but any key will do: Launchpad authenticates the upload
|
||||
/// through this signature, so a tampered document, a corrupt signature or
|
||||
/// an unattributable signer must be refused before anything is uploaded.
|
||||
pub fn verify_changes_signature(content: &str) -> Result<(), Box<dyn Error>> {
|
||||
let layout = split_signature(content)
|
||||
.ok_or("no OpenPGP signature block found: the document is not signed")?;
|
||||
|
||||
let mut ctx = Context::from_protocol(Protocol::OpenPgp)
|
||||
.map_err(|e| format!("cannot initialize GPGME: {e}"))?;
|
||||
ctx.set_armor(true);
|
||||
|
||||
let result = match layout {
|
||||
SignatureLayout::Clearsigned(signed) => {
|
||||
// The clearsigned buffer is both the signed text and the
|
||||
// signature; gpgme only needs a sink for the verified plaintext
|
||||
let mut plaintext = Data::new()?;
|
||||
ctx.verify_opaque(signed, &mut plaintext)
|
||||
}
|
||||
SignatureLayout::Detached { body, signature } => ctx.verify_detached(signature, body),
|
||||
}
|
||||
.map_err(|e| {
|
||||
format!(
|
||||
"signature verification failed ({e}): the signature block is \
|
||||
unreadable, or the document was modified after signing"
|
||||
)
|
||||
})?;
|
||||
|
||||
require_valid_signature(&result)
|
||||
}
|
||||
|
||||
/// Turn gpgme's per-signature verdicts into an accept/reject decision: at
|
||||
/// least one signature must be valid; otherwise the most specific problem
|
||||
/// found is reported.
|
||||
fn require_valid_signature(result: &VerificationResult) -> Result<(), Box<dyn Error>> {
|
||||
let signatures: Vec<Signature<'_>> = result.signatures().collect();
|
||||
if signatures.is_empty() {
|
||||
return Err("the PGP block contains no signature".into());
|
||||
}
|
||||
|
||||
let mut first_failure = None;
|
||||
for signature in &signatures {
|
||||
match signature_verdict(signature) {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(reason) => first_failure = first_failure.or(Some(reason)),
|
||||
}
|
||||
}
|
||||
|
||||
Err(first_failure
|
||||
.unwrap_or_else(|| "signature verification failed".to_string())
|
||||
.into())
|
||||
}
|
||||
|
||||
/// A human-readable verdict for one signature: `Ok` when it is valid, or an
|
||||
/// actionable reason to reject the document otherwise.
|
||||
fn signature_verdict(signature: &Signature<'_>) -> Result<(), String> {
|
||||
let fingerprint = signature
|
||||
.fingerprint()
|
||||
.unwrap_or("<unknown key>")
|
||||
.to_string();
|
||||
let summary = signature.summary();
|
||||
|
||||
// The signing key must be resolvable in the local keyring: without it
|
||||
// the signature cannot be attributed at all. That is a keyring
|
||||
// configuration problem, distinct from a corrupt document.
|
||||
if summary.contains(SignatureSummary::KEY_MISSING)
|
||||
|| signature
|
||||
.status()
|
||||
.err()
|
||||
.is_some_and(|e| e.code() == GpgError::NO_PUBKEY.code())
|
||||
{
|
||||
return Err(format!(
|
||||
"the signing key {fingerprint} is not in your local keyring, \
|
||||
its signature cannot be verified; check it with \
|
||||
`gpg --list-keys {fingerprint}` (and receive the key if missing)"
|
||||
));
|
||||
}
|
||||
|
||||
for (flag, reason) in [
|
||||
(SignatureSummary::KEY_REVOKED, "the signing key is revoked"),
|
||||
(SignatureSummary::KEY_EXPIRED, "the signing key is expired"),
|
||||
(SignatureSummary::SIG_EXPIRED, "the signature is expired"),
|
||||
] {
|
||||
if summary.contains(flag) {
|
||||
return Err(format!("invalid signature of key {fingerprint}: {reason}"));
|
||||
}
|
||||
}
|
||||
|
||||
// The canonical GPGME validity rule: accept a green signature, or one
|
||||
// neither red nor errored (such a signature is valid although the key
|
||||
// lacks full owner trust, which does not matter here: any locally known
|
||||
// key is accepted). Everything else means the content does not match.
|
||||
if summary.contains(SignatureSummary::GREEN)
|
||||
|| (!summary.contains(SignatureSummary::RED) && signature.status().is_ok())
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"invalid signature of key {fingerprint}: the document was modified \
|
||||
after signing, or the signature block is corrupt"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -122,4 +274,176 @@ mod tests {
|
||||
assert!(validate_key_id(&"a".repeat(40)).is_ok());
|
||||
assert!(validate_key_id(&"b".repeat(64)).is_ok());
|
||||
}
|
||||
|
||||
/// The deb822 body shared by the signing-layout fixtures below
|
||||
const BODY: &str = "Format: 1.8\nSource: hello\nVersion: 1.0-1\n\n";
|
||||
|
||||
/// An unparseable armor body: enough shape for the layout split, but
|
||||
/// never a signature gpg could verify
|
||||
const FAKE_ARMOR: &str = "\
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAABCgAdFiEEfakesignatureblock
|
||||
-----END PGP SIGNATURE-----
|
||||
";
|
||||
|
||||
fn clearsigned_fixture() -> String {
|
||||
format!("-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA512\n\n{BODY}{FAKE_ARMOR}")
|
||||
}
|
||||
|
||||
fn appended_fixture() -> String {
|
||||
format!("{BODY}{FAKE_ARMOR}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_detects_clearsigned_layout() {
|
||||
let content = clearsigned_fixture();
|
||||
assert_eq!(
|
||||
split_signature(&content),
|
||||
Some(SignatureLayout::Clearsigned(&content))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_detects_appended_signature_layout() {
|
||||
let content = appended_fixture();
|
||||
let Some(SignatureLayout::Detached { body, signature }) = split_signature(&content) else {
|
||||
panic!("expected the appended-signature layout for: {content}");
|
||||
};
|
||||
// The body is the exact signed text: byte-for-byte everything
|
||||
// before the signature marker
|
||||
assert_eq!(body, BODY);
|
||||
assert!(signature.starts_with("-----BEGIN PGP SIGNATURE-----"));
|
||||
assert!(signature.ends_with("-----END PGP SIGNATURE-----\n"));
|
||||
assert_eq!(format!("{body}{signature}"), content);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_rejects_unsigned_content() {
|
||||
assert_eq!(split_signature("Format: 1.8\nSource: hello\n"), None);
|
||||
}
|
||||
|
||||
/// Unsigned content is refused before any GPG context is created, so
|
||||
/// this stays offline and keyring-independent
|
||||
#[test]
|
||||
fn verify_rejects_unsigned_content() {
|
||||
let err = verify_changes_signature("Format: 1.8\nSource: hello\n")
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("not signed"), "unexpected: {err}");
|
||||
}
|
||||
|
||||
/// Full verification round-trip in a throwaway GNUPGHOME: a generated
|
||||
/// key signs a document in both layouts, then a tampered copy and an
|
||||
/// empty keyring must be rejected with distinct errors.
|
||||
///
|
||||
/// Ignored by default: it shells out to `gpg` and generates keys (a few
|
||||
/// seconds). Run it explicitly with `cargo test -- --ignored gpg`. It
|
||||
/// never touches the real keyring: GNUPGHOME points at a temporary
|
||||
/// directory for the whole run and is restored afterwards.
|
||||
#[test]
|
||||
#[ignore = "shells out to gpg and generates keys in a throwaway GNUPGHOME"]
|
||||
fn verify_signed_documents_in_throwaway_gnupghome() {
|
||||
let gpg_home = tempfile::tempdir().unwrap();
|
||||
let empty_home = tempfile::tempdir().unwrap();
|
||||
|
||||
// SAFETY: the test runs alone (`-- --ignored` selects only it), and
|
||||
// the previous value is saved and restored below: neither gpg nor
|
||||
// gpgme ever see the user's real keyring.
|
||||
let saved_home = std::env::var("GNUPGHOME").ok();
|
||||
unsafe { std::env::set_var("GNUPGHOME", gpg_home.path()) };
|
||||
|
||||
let run_gpg = |args: &[&str]| {
|
||||
let output = std::process::Command::new("gpg")
|
||||
.args([
|
||||
"--batch",
|
||||
"--yes",
|
||||
"--pinentry-mode",
|
||||
"loopback",
|
||||
"--passphrase",
|
||||
"",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("the gpg binary must be installed");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"gpg {args:?} failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
};
|
||||
|
||||
run_gpg(&[
|
||||
"--quick-gen-key",
|
||||
"pkh test <pkh-test@invalid>",
|
||||
"ed25519",
|
||||
"sign",
|
||||
"2d",
|
||||
]);
|
||||
|
||||
let scratch = gpg_home.path().join("document");
|
||||
std::fs::write(&scratch, BODY).unwrap();
|
||||
let plain = scratch.to_str().unwrap();
|
||||
|
||||
// Sign in both dpkg layouts: a clearsigned wrapper and a detached
|
||||
// armor block appended after the plain body
|
||||
run_gpg(&["--clearsign", "--output", &format!("{plain}.clear"), plain]);
|
||||
run_gpg(&[
|
||||
"--detach-sign",
|
||||
"--armor",
|
||||
"--output",
|
||||
&format!("{plain}.sig"),
|
||||
plain,
|
||||
]);
|
||||
let clearsigned = std::fs::read_to_string(format!("{plain}.clear")).unwrap();
|
||||
let appended = format!(
|
||||
"{}{}",
|
||||
BODY,
|
||||
std::fs::read_to_string(format!("{plain}.sig")).unwrap()
|
||||
);
|
||||
|
||||
// A valid signature from any local key is accepted, in both layouts
|
||||
verify_changes_signature(&clearsigned).unwrap();
|
||||
verify_changes_signature(&appended).unwrap();
|
||||
|
||||
// A tampered body no longer matches its signature
|
||||
let tampered = appended.replace("Source: hello", "Source: evil");
|
||||
let err = verify_changes_signature(&tampered).unwrap_err().to_string();
|
||||
assert!(err.contains("modified after signing"), "unexpected: {err}");
|
||||
|
||||
// So does a corrupt signature payload: the armor still parses but
|
||||
// the signature packet can no longer be verified
|
||||
let mut corrupt_lines: Vec<String> = clearsigned.lines().map(str::to_string).collect();
|
||||
let block_begin = corrupt_lines
|
||||
.iter()
|
||||
.position(|line| line == SIGNATURE_BEGIN)
|
||||
.expect("fixture must contain a signature block");
|
||||
// The first base64 line of the signature packet, after the blank
|
||||
// armor-header line
|
||||
corrupt_lines[block_begin + 2].replace_range(..4, "AAAA");
|
||||
let corrupt = corrupt_lines.join("\n");
|
||||
let err = verify_changes_signature(&corrupt).unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("modified after signing")
|
||||
|| err.contains("signature verification failed")
|
||||
|| err.contains("no signature"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
|
||||
// The same valid document is refused when the signing key is not in
|
||||
// the keyring, with the dedicated missing-key error
|
||||
unsafe { std::env::set_var("GNUPGHOME", empty_home.path()) };
|
||||
let err = verify_changes_signature(&clearsigned)
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(
|
||||
err.contains("not in your local keyring"),
|
||||
"unexpected: {err}"
|
||||
);
|
||||
|
||||
match saved_home {
|
||||
Some(home) => unsafe { std::env::set_var("GNUPGHOME", home) },
|
||||
None => unsafe { std::env::remove_var("GNUPGHOME") },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user