use launchpadlib::blocking::v1_0::{self, ArchiveFull, PersonOrTeam};
use launchpadlib::blocking::Client;
use launchpadlib::Error as WadlError;
use std::fmt;
use url::Url;
const CONSUMER_KEY: &str = "apt-add-repository";
pub const LAUNCHPAD_PPA_URL: &str = "https://ppa.launchpadcontent.net";
pub const VALID_PPA_COMPONENTS: &[&str] = &["main", "main/debug"];
#[derive(Debug, Clone)]
pub struct PpaInfo {
pub user: String,
pub name: String,
}
impl PpaInfo {
pub fn parse(ppa_spec: &str) -> Result<PpaInfo, String> {
if !ppa_spec.starts_with("ppa:") {
return Err("Not a PPA format".to_string());
}
let ppa_part = &ppa_spec[4..];
let parts: Vec<&str> = ppa_part.split('/').collect();
if parts.len() != 2 {
return Err("Invalid PPA format. Expected ppa:user/ppa-name".to_string());
}
Ok(PpaInfo {
user: parts[0].to_string(),
name: parts[1].to_string(),
})
}
pub fn from_repository(repo: &crate::Repository) -> Option<PpaInfo> {
for uri in &repo.uris {
let url_str = uri.as_str();
if url_str.contains("ppa.launchpadcontent.net") || url_str.contains("ppa.launchpad.net")
{
let path = uri.path();
let parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
if parts.len() >= 2 {
return Some(PpaInfo {
user: parts[0].to_string(),
name: parts[1].to_string(),
});
}
}
}
None
}
pub fn repository_url(&self, _codename: &str) -> Result<Url, String> {
Url::parse(&format!(
"{}/{}/{}/ubuntu",
LAUNCHPAD_PPA_URL, self.user, self.name
))
.map_err(|e| format!("Failed to construct PPA URL: {e}"))
}
pub fn filename(&self, extension: &str) -> String {
format!("{}-ubuntu-{}.{}", self.user, self.name, extension)
}
pub fn keyring_filename(&self) -> String {
format!("{}-{}-keyring.asc", self.user, self.name)
}
}
#[derive(Debug, Clone)]
pub struct PpaValidationResult {
pub exists: bool,
pub is_private: bool,
pub publishes_debug_symbols: bool,
pub display_name: String,
}
pub fn validate_ppa_components(components: &[String]) -> Result<(), String> {
for component in components {
if !VALID_PPA_COMPONENTS.contains(&component.as_str()) {
return Err(format!(
"Invalid component '{}' for PPA.\n\
Valid components are: {}\n\
Suggestion: Use 'main' for regular packages or 'main/debug' for debug symbols.",
component,
VALID_PPA_COMPONENTS.join(", ")
));
}
}
Ok(())
}
#[derive(Debug)]
pub enum Error {
Authentication(String),
Api(WadlError),
UserNotFound(String),
PpaNotFound {
user: String,
name: String,
},
NoSigningKey,
KeyDownload(reqwest::Error),
KeyserverError(reqwest::StatusCode),
KeyRead(reqwest::Error),
#[cfg(feature = "key-management")]
KeyVerification(String),
NotAuthenticated,
MissingSelfLink,
SubscriptionUrl(WadlError),
InvalidSubscriptionUrl {
url: String,
error: url::ParseError,
},
HttpClient(reqwest::Error),
UnexpectedResponse(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Authentication(msg) => {
write!(f, "Failed to authenticate with Launchpad: {}", msg)
}
Error::Api(e) => write!(f, "Launchpad API error: {}", e),
Error::UserNotFound(user) => write!(f, "User '{}' not found on Launchpad", user),
Error::PpaNotFound { user, name } => {
write!(f, "PPA '{}/{}' not found", user, name)
}
Error::NoSigningKey => write!(f, "PPA has no signing key configured"),
Error::KeyDownload(e) => write!(f, "Failed to download signing key: {}", e),
Error::KeyserverError(status) => {
write!(f, "Keyserver returned error status: {}", status)
}
Error::KeyRead(e) => write!(f, "Failed to read key data: {}", e),
#[cfg(feature = "key-management")]
Error::KeyVerification(msg) => {
write!(f, "Key fingerprint verification failed: {}", msg)
}
Error::NotAuthenticated => {
write!(f, "Not authenticated - no current user available")
}
Error::MissingSelfLink => write!(f, "PPA has no self link"),
Error::SubscriptionUrl(e) => write!(f, "Failed to get subscription URL: {}", e),
Error::InvalidSubscriptionUrl { url, error } => {
write!(f, "Invalid subscription URL '{}': {}", url, error)
}
Error::HttpClient(e) => write!(f, "Failed to create HTTP client: {}", e),
Error::UnexpectedResponse(msg) => {
write!(f, "Unexpected response from Launchpad: {}", msg)
}
}
}
}
impl std::error::Error for Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Error::Api(e) => Some(e),
Error::KeyDownload(e) => Some(e),
Error::KeyRead(e) => Some(e),
Error::SubscriptionUrl(e) => Some(e),
Error::InvalidSubscriptionUrl { error, .. } => Some(error),
Error::HttpClient(e) => Some(e),
_ => None,
}
}
}
impl From<WadlError> for Error {
fn from(e: WadlError) -> Self {
Error::Api(e)
}
}
#[derive(Debug, Clone)]
pub struct PpaSigningKey {
pub key_data: String,
pub fingerprint: String,
}
fn get_ppa(client: &Client, user: &str, name: &str) -> Result<ArchiveFull, Error> {
let service_root = v1_0::service_root(client)?;
let people = service_root
.people()
.ok_or_else(|| Error::UnexpectedResponse("No people collection".to_string()))?;
let person_or_team = people.get_by_name(client, user).map_err(|e| {
if matches!(e, WadlError::UnhandledStatus(s) if s.as_u16() == 404) {
Error::UserNotFound(user.to_string())
} else {
Error::Api(e)
}
})?;
let ppa = match person_or_team {
PersonOrTeam::Person(person) => person.get_ppaby_name(client, None, name).map_err(|e| {
if matches!(e, WadlError::UnhandledStatus(s) if s.as_u16() == 404) {
Error::PpaNotFound {
user: user.to_string(),
name: name.to_string(),
}
} else {
Error::Api(e)
}
})?,
PersonOrTeam::Team(team) => team.get_ppaby_name(client, None, name).map_err(|e| {
if matches!(e, WadlError::UnhandledStatus(s) if s.as_u16() == 404) {
Error::PpaNotFound {
user: user.to_string(),
name: name.to_string(),
}
} else {
Error::Api(e)
}
})?,
};
Ok(ppa)
}
pub fn validate_ppa(ppa_info: &PpaInfo, auth_required: bool) -> Result<PpaValidationResult, Error> {
let client = if auth_required {
Client::authenticated(None, CONSUMER_KEY)
.map_err(|e| Error::Authentication(e.to_string()))?
} else {
Client::anonymous(CONSUMER_KEY)
};
match get_ppa(&client, &ppa_info.user, &ppa_info.name) {
Ok(ppa) => Ok(PpaValidationResult {
exists: true,
is_private: ppa.private,
publishes_debug_symbols: false, display_name: ppa.displayname,
}),
Err(Error::PpaNotFound { .. }) | Err(Error::UserNotFound(_)) => Ok(PpaValidationResult {
exists: false,
is_private: false,
publishes_debug_symbols: false,
display_name: String::new(),
}),
Err(e) => Err(e),
}
}
pub fn get_private_ppa_url(ppa_info: &PpaInfo) -> Result<Url, Error> {
let client = Client::authenticated(None, CONSUMER_KEY)
.map_err(|e| Error::Authentication(e.to_string()))?;
let service_root = v1_0::service_root(&client)?;
let me = service_root.me().ok_or(Error::NotAuthenticated)?;
let ppa = get_ppa(&client, &ppa_info.user, &ppa_info.name)?;
let archive = ppa.self_().ok_or(Error::MissingSelfLink)?;
let subscription_url = me
.get_archive_subscription_url(&client, &archive)
.map_err(Error::SubscriptionUrl)?;
Url::parse(&subscription_url).map_err(|e| Error::InvalidSubscriptionUrl {
url: subscription_url,
error: e,
})
}
pub fn download_ppa_signing_key(
ppa_info: &PpaInfo,
auth_required: bool,
) -> Result<PpaSigningKey, Error> {
let client = if auth_required {
Client::authenticated(None, CONSUMER_KEY)
.map_err(|e| Error::Authentication(e.to_string()))?
} else {
Client::anonymous(CONSUMER_KEY)
};
let ppa = get_ppa(&client, &ppa_info.user, &ppa_info.name)?;
let fingerprint = ppa.signing_key_fingerprint;
if fingerprint.is_empty() {
return Err(Error::NoSigningKey);
}
let key_url = format!(
"https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x{}",
fingerprint
);
let http_client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(Error::HttpClient)?;
let response = http_client
.get(&key_url)
.send()
.map_err(Error::KeyDownload)?;
if !response.status().is_success() {
return Err(Error::KeyserverError(response.status()));
}
let key_data = response.text().map_err(Error::KeyRead)?;
#[cfg(feature = "key-management")]
crate::key_management::verify_key_fingerprint(&key_data, &fingerprint)
.map_err(|e| Error::KeyVerification(e.to_string()))?;
Ok(PpaSigningKey {
key_data,
fingerprint,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ppa_format() {
let ppa = PpaInfo::parse("ppa:user/repo").unwrap();
assert_eq!(ppa.user, "user");
assert_eq!(ppa.name, "repo");
assert!(PpaInfo::parse("not-a-ppa").is_err());
assert!(PpaInfo::parse("ppa:invalid").is_err());
assert!(PpaInfo::parse("ppa:too/many/parts").is_err());
}
#[test]
fn test_validate_ppa_components() {
assert!(validate_ppa_components(&["main".to_string()]).is_ok());
assert!(validate_ppa_components(&["main/debug".to_string()]).is_ok());
assert!(validate_ppa_components(&["invalid".to_string()]).is_err());
}
#[test]
fn test_ppa_filename() {
let ppa = PpaInfo {
user: "test-user".to_string(),
name: "test-repo".to_string(),
};
assert_eq!(ppa.filename("list"), "test-user-ubuntu-test-repo.list");
assert_eq!(
ppa.filename("sources"),
"test-user-ubuntu-test-repo.sources"
);
assert_eq!(ppa.filename(""), "test-user-ubuntu-test-repo.");
let ppa_special = PpaInfo {
user: "user_123".to_string(),
name: "repo-name".to_string(),
};
assert_eq!(
ppa_special.filename("list"),
"user_123-ubuntu-repo-name.list"
);
}
}