Files
pkh/src/package_info.rs
T
vhaudiquet 4fae02bc85 package_info: drop the ANSI color from the not-found warning
The location embedded in the log record was styled with crossterm: a
remote consumer of pkh's log records would receive ANSI codes inside
the message text. Plain text is the logger's business to style.
2026-09-18 20:55:56 +02:00

1163 lines
42 KiB
Rust

use flate2::read::GzDecoder;
use std::collections::HashMap;
use std::error::Error;
use std::io::Read;
use xz2::read::XzDecoder;
use crate::ProgressCallback;
use crate::apt::release::{self, VerifiedRelease};
use log::{debug, warn};
/// Split a PPA reference into its `(user, name)` parts
///
/// A PPA is written `user/ppa_name` (e.g. `user/my-ppa`); anything else —
/// more segments, empty parts — is a format error carrying the canonical
/// message.
pub fn split_ppa(ppa: &str) -> Result<(&str, &str), String> {
let parts: Vec<&str> = ppa.split('/').collect();
if parts.len() == 2 && !parts[0].is_empty() && !parts[1].is_empty() {
Ok((parts[0], parts[1]))
} else {
Err(format!(
"Invalid PPA format: '{ppa}'. Expected: user/ppa_name"
))
}
}
/// Convert a PPA specification to a base URL
///
/// # Arguments
/// * user: user for the PPA
/// * name: name of the PPA
///
/// # Returns
/// * The base URL for the PPA (e.g., "https://ppa.launchpadcontent.net/user/ppa_name/ubuntu/")
pub fn ppa_to_base_url(user: &str, name: &str) -> String {
crate::launchpad::ppa_content_url(user, name)
}
fn check_launchpad_repo_sync(package: &str) -> Result<Option<String>, String> {
let url = crate::launchpad::ubuntu_source_git_url(package);
// Use libgit2 to check if the remote repository exists
// This is more reliable than HTTP HEAD requests when CGIt is disabled
match git2::Remote::create_detached(url.clone()) {
Ok(mut remote) => match remote.connect(git2::Direction::Fetch) {
Ok(_) => Ok(Some(url)),
Err(_) => Ok(None),
},
Err(_) => Ok(None),
}
}
/// Check if a Launchpad git repository exists for the given package.
///
/// This runs the blocking git2 connection check on a dedicated thread with a
/// timeout so that it never stalls the tokio runtime (the git2 connect call
/// has no built-in timeout and can hang for minutes on unreachable hosts).
async fn check_launchpad_repo(
package: &str,
) -> Result<Option<String>, Box<dyn Error + Send + Sync>> {
let package_owned = package.to_string();
let package_for_err = package_owned.clone();
let result = tokio::time::timeout(
std::time::Duration::from_secs(15),
tokio::task::spawn_blocking(move || check_launchpad_repo_sync(&package_owned)),
)
.await
.map_err(|_| {
format!(
"Timed out checking Launchpad repository for '{}'",
package_for_err
)
})?
.map_err(|e| format!("Launchpad check task failed: {}", e))?;
result.map_err(|e| -> Box<dyn Error + Send + Sync> { e.into() })
}
/// A File used in a source package
#[derive(Debug, Clone)]
pub struct FileEntry {
/// Name of the file
pub name: String,
/// Size of the file
pub size: u64,
/// Checksum hash for the file
pub checksum: String,
/// Algorithm used for the checksum
pub checksum_algo: ChecksumAlgo,
}
/// Checksum algorithm used for a file entry
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChecksumAlgo {
/// MD5 (legacy 'Files' field)
Md5,
/// SHA-256 ('Checksums-Sha256' field)
Sha256,
/// SHA-512 ('Checksums-Sha512' field)
Sha512,
}
impl ChecksumAlgo {
/// Compute the hex-encoded digest of the given data using this algorithm
pub fn hex_digest(&self, data: &[u8]) -> String {
match self {
ChecksumAlgo::Md5 => {
use md5::{Digest, Md5};
let mut hasher = Md5::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
ChecksumAlgo::Sha256 => {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
ChecksumAlgo::Sha512 => {
use sha2::{Digest, Sha512};
let mut hasher = Sha512::new();
hasher.update(data);
hex::encode(hasher.finalize())
}
}
}
}
/// A package 'stanza' as found is 'Sources.gz' files, containing basic information about a source package
#[derive(Debug)]
pub struct PackageStanza {
/// Name of the package
pub package: String,
/// Version number for the package
pub version: String,
/// Directory field in the stanza
pub directory: String,
/// Source package format (e.g. '3.0 (quilt)')
pub format: String,
/// Vcs-Git field in the stanza
pub vcs_git: Option<String>,
/// Vcs-Browser field in the stanza
pub vcs_browser: Option<String>,
/// Files present in the source package
pub files: Vec<FileEntry>,
}
/// Source package information
#[derive(Debug)]
pub struct PackageInfo {
/// Source 'stanza' for the package, containing basic information
pub stanza: PackageStanza,
/// Distribution for the package
pub dist: String,
/// Distribution series for the package
pub series: String,
/// Preferred VCS for the source package
///
/// Should be Launchpad on Ubuntu, and Salsa on Debian
pub preferred_vcs: Option<String>,
/// URL for the files of the source package
pub archive_url: String,
}
impl PackageInfo {
/// Returns true if the package is a Debian native package (no orig)
pub fn is_native(&self) -> bool {
self.stanza.format.contains("(native)")
}
}
/// Decompress a package index payload, detecting the compression format from
/// its magic bytes: gzip ('1f 8b'), xz ('fd 7zXZ 00'), or plain text
fn decompress_index(data: &[u8]) -> Result<String, Box<dyn Error>> {
let mut decoded: Box<dyn Read> = if data.starts_with(&[0x1f, 0x8b]) {
Box::new(GzDecoder::new(data))
} else if data.starts_with(&[0xfd, 0x37, b'z', b'X', b'Z', 0x00]) {
Box::new(XzDecoder::new(data))
} else {
return Ok(String::from_utf8(data.to_vec())?);
};
let mut s = String::new();
decoded.read_to_string(&mut s)?;
Ok(s)
}
struct DebianSources {
splitted_sources: std::str::Split<'static, &'static str>,
}
impl DebianSources {
fn new(data: &[u8]) -> Result<DebianSources, Box<dyn Error>> {
// Decode the index into a string, and split it on stanzas
let s = decompress_index(data)?;
// Convert the string to a static lifetime by leaking it
let static_str = Box::leak(s.into_boxed_str());
let splitted = static_str.split("\n\n");
Ok(DebianSources {
splitted_sources: splitted,
})
}
}
impl Iterator for DebianSources {
type Item = PackageStanza;
fn next(&mut self) -> Option<Self::Item> {
// Iterate over stanzas in a loop: package-less (blank) stanzas are
// skipped without recursion, so that a crafted index with many
// consecutive blank stanzas cannot blow the stack
loop {
let stanza = self.splitted_sources.next()?;
// Parse stanza into a hashmap of strings, the fields
let mut fields: HashMap<String, String> = HashMap::new();
let mut current_key = String::new();
for line in stanza.lines() {
if line.is_empty() {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') {
// Continuation line
if let Some(val) = fields.get_mut(&current_key) {
val.push('\n');
val.push_str(line.trim());
}
} else if let Some((key, value)) = line.split_once(':') {
current_key = key.trim().to_string();
fields.insert(current_key.clone(), value.trim().to_string());
}
}
let Some(package) = fields.get("Package") else {
// Skip empty stanza
continue;
};
let package = package.to_string();
// A stanza without a version is malformed remote data: skip it
// rather than panicking
let Some(version) = fields.get("Version") else {
debug!(
"Skipping malformed stanza for package '{}' without a 'Version' field",
package
);
continue;
};
let version = version.to_string();
// Parse package files.
// Prefer the strongest available checksum field: Checksums-Sha256,
// then Checksums-Sha512, then the legacy 'Files' (MD5) field.
// Some archives (e.g. the Ubuntu development series) no longer ship
// Checksums-Sha256, so falling back is required to keep working.
let mut files = Vec::new();
let (checksum_field, algo) = if fields.contains_key("Checksums-Sha256") {
("Checksums-Sha256", ChecksumAlgo::Sha256)
} else if fields.contains_key("Checksums-Sha512") {
("Checksums-Sha512", ChecksumAlgo::Sha512)
} else {
("Files", ChecksumAlgo::Md5)
};
if let Some(checksums) = fields.get(checksum_field) {
for line in checksums.lines() {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 3 {
files.push(FileEntry {
checksum: parts[0].to_string(),
size: parts[1].parse().unwrap_or(0),
name: parts[2].to_string(),
checksum_algo: algo,
});
}
}
}
// Parse Vcs-Git field: it may contain just a URL, or URL followed by -b <branch>
// e.g., "https://salsa.debian.org/science-team/paraview.git -b debian/latest"
let vcs_git = fields.get("Vcs-Git").map(|vcs| {
// Split on whitespace and take the first part (the URL)
// The URL should not contain spaces, so this is safe
vcs.split_whitespace().next().unwrap_or(vcs).to_string()
});
return Some(PackageStanza {
package,
version,
directory: fields.get("Directory").cloned().unwrap_or_default(),
format: fields
.get("Format")
.cloned()
.unwrap_or_else(|| "1.0".to_string()),
vcs_git,
vcs_browser: fields.get("Vcs-Browser").cloned(),
files,
});
}
}
}
/// Parse a 'Sources.gz' debian package file data, to look for a target package and
/// return the data for that package stanza
fn parse_sources(
data: &[u8],
target_package: &str,
target_version: Option<&str>,
) -> Result<Option<PackageStanza>, Box<dyn Error>> {
let mut sources = DebianSources::new(data)?;
// Find the right package, with the right version if requested
Ok(sources.find(|s| {
s.package == target_package
&& (target_version.is_none() || s.version == target_version.unwrap())
}))
}
/// Get package information from a package, distribution series, and pocket
///
/// `launchpad_vcs` is a pre-computed Launchpad VCS URL (if any), checked once
/// by the caller so that the blocking git2 connection test is not repeated for
/// every series/pocket combination.
async fn get(
package_name: &str,
series: &str,
pocket: &str,
version: Option<&str>,
base_url: Option<&str>,
launchpad_vcs: Option<String>,
) -> Result<PackageInfo, Box<dyn Error>> {
let dist = crate::distro_info::get_dist_from_series(series).await?;
// Use the pre-computed Launchpad VCS URL (if any).
// This is checked once in the caller rather than per-series to avoid
// repeated blocking git2 connections.
let mut preferred_vcs = launchpad_vcs;
// Determine the base URL to use (either provided PPA URL or default archive)
let distro_base_url = crate::distro_info::get_base_url(&dist)?;
let base_url = if let Some(ppa_url) = base_url {
ppa_url.to_string()
} else {
distro_base_url.clone()
};
// If using a custom base URL (PPA), disable VCS lookup to force archive download
let from_ppa = base_url != distro_base_url;
// Authenticate the metadata of the suite before trusting any index
// fetched from it: the signed Release file (InRelease, or Release plus
// detached Release.gpg) is verified against the archive keyring (or the
// PPA signing key), and each Sources index downloaded below is
// checksum-checked against it before parsing.
let suite = if pocket.is_empty() {
series.to_string()
} else {
format!("{series}-{pocket}")
};
let suite_url = format!("{base_url}/dists/{suite}");
let keyring_source = if from_ppa {
release::KeyringSource::Ppa {
base_url: base_url.clone(),
}
} else {
release::KeyringSource::Distro {
series: series.to_string(),
}
};
debug!("Verifying the Release file of: {}", suite_url);
let verified = match release::verify_suite(&suite_url, keyring_source, true).await {
Ok(release::Verification::Available(verified)) => verified,
Ok(release::Verification::Unavailable { .. }) => {
// The suite does not exist: same outcome as before verification
// existed (probing callers simply try the next pocket/series)
return Err(format!("No Release file found for suite '{suite}' at {base_url}").into());
}
Ok(release::Verification::KeyringUnavailable { reason, .. }) => {
// An existing suite whose keys are unavailable must not be
// trusted, but this is not tampering: report and let callers
// move on
return Err(reason.into());
}
Err(e) => return Err(e.into()),
};
let components = verified.components();
if components.is_empty() {
return Err(format!("Components not found in the Release file of '{suite_url}'").into());
}
debug!("Found components: {:?}", components);
// Collect the failures of individual fetch attempts so that, if the
// package is not found, the final error explains what actually went
// wrong instead of misleadingly claiming a plain 'not found'
let mut fetch_errors: Vec<String> = Vec::new();
for component in components {
let url = crate::distro_info::get_sources_url(&base_url, series, pocket, &component);
debug!("Fetching sources from: {}", url);
let compressed_data = match fetch_index_bytes(&url).await {
Ok(data) => data,
Err(e) => {
debug!("Failed to fetch {url}: {e}");
fetch_errors.push(format!("{suite}/{component}: {e}"));
continue;
}
};
// The index must match the checksums listed in the signed Release
// file: this is what closes the 'substituted index with matching
// artifact checksums' man-in-the-middle attack
let suite_rel_path = format!("{component}/source/Sources.gz");
let compressed_data = match verified.verify_file(&suite_rel_path, &compressed_data) {
Ok(()) => compressed_data,
Err(verify_error) => {
// Busy mirrors and CDNs can serve an index generation
// slightly older or newer than the Release file fetched
// moments before. Pin the exact generation listed in the
// Release file via Debian's by-hash mechanism before
// failing.
match fetch_index_by_hash(&url, &verified, &suite_rel_path).await {
Ok(pinned) => {
debug!(
"index at '{url}' did not match the Release file: used the by-hash copy"
);
pinned
}
Err(by_hash_error) => {
debug!("by-hash fetch of '{}' failed: {}", url, by_hash_error);
return Err(release::VerifyError(format!(
"{verify_error}; the by-hash retry also failed: {by_hash_error}"
))
.into());
}
}
}
};
debug!(
"Downloaded Sources.gz for {}/{}/{}",
dist, series, component
);
if let Some(stanza) = parse_sources(&compressed_data, package_name, version)? {
if let Some(vcs) = &stanza.vcs_git
&& preferred_vcs.is_none()
{
preferred_vcs = Some(vcs.clone());
}
// If downloading from PPA, make sure we don't use a VCS
if from_ppa {
preferred_vcs = None;
}
let archive_url = format!("{base_url}/{0}", stanza.directory);
return Ok(PackageInfo {
dist,
series: series.to_string(),
stanza,
preferred_vcs,
archive_url,
});
}
}
let details = if fetch_errors.is_empty() {
String::new()
} else {
format!(" (last errors: {})", fetch_errors.join("; "))
};
Err(format!(
"Package '{}' not found in {}/{}{}",
package_name, dist, series, details
)
.into())
}
/// Try to find package information in a distribution, trying all series and pockets
async fn find_package(
package_name: &str,
dist: &str,
pocket: &str,
version: Option<&str>,
base_url: Option<&str>,
launchpad_vcs: Option<String>,
progress: ProgressCallback<'_>,
) -> Result<PackageInfo, Box<dyn Error>> {
let series_list = crate::distro_info::get_ordered_series_name(dist).await?;
// Collect the failures of the individual series/pocket probes so that,
// if nothing is found, the final error summarizes what went wrong
// (e.g. network errors, HTTP statuses) instead of a bare 'not found'
let mut attempt_errors: Vec<String> = Vec::new();
for (i, series) in series_list.iter().enumerate() {
if let Some(cb) = progress {
cb("", &format!("Checking {}...", series), i, series_list.len());
}
let pockets = if pocket.is_empty() {
crate::distro_info::get_dist_pockets(dist)?
} else {
vec![pocket.to_string()]
};
for p in pockets {
match get(
package_name,
series,
&p,
version,
base_url,
launchpad_vcs.clone(),
)
.await
{
Ok(info) => {
if i > 0 {
let location = if p.is_empty() {
format!("{dist}/{series}")
} else {
format!("{dist}/{series}-{p}")
};
warn!(
"Package '{}' not found in development release. Found in {}.",
package_name, location
);
} else {
let location = if p.is_empty() {
format!("{}/{}", dist, series)
} else {
format!("{}/{}-{}", dist, series, p)
};
debug!("Found package '{}' in {}", package_name, location);
}
return Ok(info);
}
Err(e) => {
// A Release verification failure is a security error,
// not a missing package: abort the search instead of
// silently probing other series/pockets
if e.downcast_ref::<release::VerifyError>().is_some() {
return Err(e);
}
// Remember the failure for the final error message, and
// keep probing the other series/pockets
let suite = if p.is_empty() {
series.clone()
} else {
format!("{series}-{p}")
};
attempt_errors.push(format!("{}: {}", suite, e));
continue;
}
}
}
}
// Keep only the last few attempts so the message stays readable
if attempt_errors.len() > 5 {
let drain_to = attempt_errors.len() - 5;
attempt_errors.drain(..drain_to);
}
let details = if attempt_errors.is_empty() {
String::new()
} else {
format!(" (last errors: {})", attempt_errors.join("; "))
};
Err(format!("Package '{}' not found.{}", package_name, details).into())
}
/// Fetch the 'Release' file at the root of a flat repository, and return its suite name
///
/// Flat repositories (e.g. 'https://pkg.example.org/deb/resolute/') serve the
/// Release file at their root, without any 'dists/' hierarchy. The suite is
/// read from the 'Codename' field, falling back to 'Suite'.
async fn get_flat_repo_series(base_url: &str) -> Result<String, Box<dyn Error>> {
let url = format!("{}/Release", base_url.trim_end_matches('/'));
let response = crate::distro_info::http_client().get(&url).send().await?;
if !response.status().is_success() {
return Err(format!(
"No Release file at '{}' (HTTP {}) - is '{}' the suite URL of a flat repository? \
External repositories must be passed as their full suite URL.",
url,
response.status(),
base_url
)
.into());
}
let content = response.text().await?;
for field in ["Codename", "Suite"] {
let prefix = format!("{field}:");
if let Some(line) = content.lines().find(|l| l.starts_with(&prefix)) {
return Ok(line[prefix.len()..].trim().to_string());
}
}
Err(format!("No Codename or Suite field in Release file at '{url}'").into())
}
/// Fetch an index pinned to the exact generation listed in the Release file
///
/// Uses Debian's by-hash mechanism, supported by the main archives (Debian,
/// Ubuntu, PPAs): the server serves the index generation matching the
/// Release file instead of whatever the mirror/CDN currently holds. The
/// returned content is verified again, so a by-hash-incapable mirror can
/// only cause a 404 here, never a silent mismatch.
async fn fetch_index_by_hash(
index_url: &str,
verified: &VerifiedRelease,
rel_path: &str,
) -> Result<Vec<u8>, Box<dyn Error>> {
let entry = verified.hash_for(rel_path).ok_or_else(|| {
format!("'{rel_path}' is not listed in the Release file: cannot fetch it by hash")
})?;
// By-hash URLs use the Release field names (MD5Sum, SHA1, SHA256, ...)
let algo = match entry.kind {
release::ChecksumKind::Md5 => "MD5Sum",
release::ChecksumKind::Sha1 => "SHA1",
release::ChecksumKind::Sha256 => "SHA256",
release::ChecksumKind::Sha512 => "SHA512",
};
// The by-hash layout replaces the file name with the hash reference:
// 'main/source/Sources.gz' is served at 'main/source/by-hash/SHA256/<digest>'
let (dir, _) = index_url
.rsplit_once('/')
.ok_or_else(|| format!("'{index_url}' has no parent directory"))?;
let url = format!("{dir}/by-hash/{algo}/{}", entry.hash);
let response = crate::distro_info::http_get_retried(&url).await?;
if !response.status().is_success() {
return Err(format!("HTTP {} for '{url}'", response.status()).into());
}
let data = response.bytes().await?.to_vec();
if data.is_empty() {
return Err(format!("empty body for '{url}'").into());
}
verified.verify_file(rel_path, &data)?;
Ok(data)
}
/// Fetch the compressed sources index at `url`
///
/// Transient transport failures and truncated bodies are retried; a
/// non-success HTTP status is a meaningful answer (e.g. a component that
/// does not exist in the suite) and is reported without retrying.
async fn fetch_index_bytes(url: &str) -> Result<Vec<u8>, String> {
const ATTEMPTS: u32 = 3;
let mut last_error = String::new();
for attempt in 1..=ATTEMPTS {
match crate::distro_info::http_get_retried(url).await {
Err(e) => {
last_error = e.to_string();
log::debug!("fetch of '{url}' failed (attempt {attempt}/{ATTEMPTS}): {last_error}");
tokio::time::sleep(std::time::Duration::from_millis(300 * u64::from(attempt)))
.await;
}
Ok(response) => {
if !response.status().is_success() {
return Err(format!("HTTP {}", response.status()));
}
match response.bytes().await {
Ok(bytes) if bytes.is_empty() => {
// CDNs occasionally answer 200 with an empty body
// under load: never a valid index, retry from scratch
last_error = "server returned an empty body".to_string();
log::debug!(
"empty body for '{url}' (attempt {attempt}/{ATTEMPTS}), retrying"
);
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
))
.await;
}
Ok(bytes) => return Ok(bytes.to_vec()),
Err(e) => {
// Truncated or corrupted body: retry from scratch
last_error = e.to_string();
log::debug!(
"reading the body of '{url}' failed (attempt {attempt}/{ATTEMPTS}): {last_error}"
);
tokio::time::sleep(std::time::Duration::from_millis(
300 * u64::from(attempt),
))
.await;
}
}
}
}
}
Err(last_error)
}
/// Fetch the sources index of a flat repository
///
/// Flat repositories are free to serve any compressed variant of the index/// (or an uncompressed one), so try the usual candidates in turn. When the
/// repository published a Release file, each index candidate is
/// checksum-verified against it (failing on mismatch, since the artifact
/// hashes would come from the index itself).
async fn get_flat_repo_sources(
base_url: &str,
verified: Option<&VerifiedRelease>,
) -> Result<Vec<u8>, Box<dyn Error>> {
let base = base_url.trim_end_matches('/');
let mut errors = Vec::new();
for name in ["Sources.xz", "Sources.gz", "Sources"] {
let url = format!("{base}/{name}");
match crate::distro_info::http_get_retried(&url).await {
Ok(response) if response.status().is_success() => {
let data = response.bytes().await?.to_vec();
// Some flat Release files list their entries with a './' prefix
if let Some(release) = verified {
let mismatch = match release.verify_file(name, &data) {
Ok(()) => None,
Err(first) => release
.verify_file(&format!("./{name}"), &data)
.err()
.map(|_| first),
};
if let Some(e) = mismatch {
return Err(
format!("Verification of the repository index failed: {e}").into()
);
}
}
return Ok(data);
}
Ok(response) => errors.push(format!("{}: HTTP {}", url, response.status())),
Err(e) => errors.push(format!("{}: {}", url, e)),
}
}
Err(format!(
"Could not fetch the sources index from '{}': {}",
base,
errors.join(", ")
)
.into())
}
/// Lookup package information in an external flat repository
///
/// The URL points at the repository suite directory itself (e.g.
/// 'https://pkg.noctalia.dev/deb/resolute/'), as flat repositories have no
/// 'dists/<suite>' hierarchy. The suite name is read from the 'Release' file
/// unless overridden, and the package files are fetched relative to the URL
/// root, ignoring the 'Directory' field like apt does for flat repositories.
///
/// Packages from external repositories are always downloaded from the
/// repository itself; the 'Vcs-Git' of the stanza is never used, as it may
/// point to an arbitrary source.
///
/// Third-party flat repositories have no known signing key: when they
/// publish a Release file its checksums are enforced on the index (with a
/// hard error on mismatch), but the absence of a verifiable signature only
/// produces a warning, preserving the previous behavior for such repos.
pub async fn lookup_repository(
package: &str,
version: Option<&str>,
repo_url: &str,
series: Option<&str>,
progress: ProgressCallback<'_>,
) -> Result<PackageInfo, Box<dyn Error>> {
if let Some(cb) = progress {
cb(
&format!("Fetching repository info from {}...", repo_url),
"",
0,
0,
);
}
// Attempt to authenticate the repository. In non-strict mode only the
// absence of a Release file (or of a way to verify it) is tolerated:
// tampering evidence — an invalid or malformed signature — is a hard
// error even for third-party repositories.
let verified = match release::verify_suite(
repo_url.trim_end_matches('/'),
release::KeyringSource::None,
false,
)
.await
{
Ok(release::Verification::Available(verified)) => Some(verified),
Ok(release::Verification::Unavailable { .. }) => None,
Ok(release::Verification::KeyringUnavailable { reason, .. }) => {
warn!("Release verification of repository {repo_url} failed: {reason}");
None
}
Err(e) => return Err(e.into()),
};
// Prefer the suite name from the (fetched, possibly verified) Release
// file; fall back to a dedicated fetch when there is none
let resolved_series = if let Some(s) = series {
s.to_string()
} else if let Some(codename) = verified
.as_ref()
.and_then(|v| v.field("Codename").or_else(|| v.field("Suite")))
{
codename.to_string()
} else {
get_flat_repo_series(repo_url).await?
};
if let Some(cb) = progress {
cb(
&format!("Resolving package info for {}...", package),
"",
0,
0,
);
}
let sources = get_flat_repo_sources(repo_url, verified.as_ref()).await?;
let stanza = parse_sources(&sources, package, version)?
.ok_or_else(|| format!("Package '{package}' not found in repository {repo_url}"))?;
// The distribution is only used for naming (e.g. git branch layout);
// fall back to a neutral name for suites unknown to distro-info.
let dist = crate::distro_info::get_dist_from_series(&resolved_series)
.await
.unwrap_or_else(|_| "external".to_string());
Ok(PackageInfo {
dist,
series: resolved_series,
stanza,
preferred_vcs: None,
archive_url: repo_url.trim_end_matches('/').to_string(),
})
}
/// Lookup package information for a source package
///
/// This function obtains package information either directly from a specific series
/// or by searching across all series in a distribution.
///
/// # Arguments
/// * `package` - The name of the package to look up
/// * `version` - Optional specific version to look for
/// * `series` - Optional distribution series (e.g., "noble", "bookworm")
/// * `pocket` - Pocket to search in (e.g., "updates", "security", or "" for main)
/// * `dist` - Optional distribution name (e.g., "ubuntu", "debian")
/// * `base_url` - Optional base URL for the package archive (e.g., "https://ppa.launchpadcontent.net/user/ppa/ubuntu/")
/// * `repository` - Optional URL of an external flat repository suite (e.g., "https://pkg.noctalia.dev/deb/resolute/")
/// * `progress` - Optional progress callback
#[allow(clippy::too_many_arguments)]
pub async fn lookup(
package: &str,
version: Option<&str>,
series: Option<&str>,
pocket: &str,
dist: Option<&str>,
base_url: Option<&str>,
repository: Option<&str>,
progress: ProgressCallback<'_>,
) -> Result<PackageInfo, Box<dyn Error>> {
// External flat repository: the URL is the suite root, distribution
// resolution and pocket logic do not apply
if let Some(repo_url) = repository {
return lookup_repository(package, version, repo_url, series, progress).await;
}
// Resolve the distribution early so we can check Launchpad once
let resolved_dist = dist.unwrap_or_else(||
// Use auto-detection to see if current distro is ubuntu, or fallback to debian by default
if std::process::Command::new("lsb_release").arg("-i").arg("-s").output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_lowercase()).unwrap_or_default() == "ubuntu" {
"ubuntu"
} else {
"debian"
}
);
// Check Launchpad repository once for Ubuntu packages, rather than
// once per series/pocket combination inside get().
let launchpad_vcs = if resolved_dist == "ubuntu" && base_url.is_none() {
if let Some(cb) = progress {
cb("", &format!("Checking Launchpad for {}...", package), 0, 0);
}
match check_launchpad_repo(package).await {
Ok(Some(lp_url)) => {
debug!("Found Launchpad URL: {}", lp_url);
Some(lp_url)
}
Ok(None) => None,
Err(e) => {
debug!(
"Launchpad check failed (will use stanza Vcs-Git if available): {}",
e
);
None
}
}
} else {
None
};
// Obtain the package information, either directly in a series or with a search in all series
let package_info = if let Some(s) = series {
if let Some(cb) = progress {
cb(
&format!("Resolving package info for {}...", package),
"",
0,
0,
);
}
// Get the package information from that series and pocket
get(package, s, pocket, version, base_url, launchpad_vcs).await?
} else {
if let Some(cb) = progress {
cb(
&format!(
"Searching for package {} in {}...",
package,
if base_url.is_none() {
resolved_dist
} else {
"ppa"
}
),
"",
0,
0,
);
}
// Try to find the package in all series from that dist
find_package(
package,
resolved_dist,
pocket,
version,
base_url,
launchpad_vcs,
progress,
)
.await?
};
Ok(package_info)
}
#[cfg(test)]
mod tests {
use super::*;
/// `user/ppa_name` splits into its two parts.
#[test]
fn split_ppa_parses_the_canonical_form() {
assert_eq!(split_ppa("user/my-ppa"), Ok(("user", "my-ppa")));
}
/// Anything but exactly two non-empty segments is rejected, with the
/// canonical message.
#[test]
fn split_ppa_rejects_malformed_references() {
for bad in ["", "user", "user/", "/ppa", "a/b/c"] {
let err = split_ppa(bad).unwrap_err();
assert!(err.contains("Invalid PPA format"), "{err}");
assert!(err.contains(bad), "{err}");
}
}
use super::*;
/// Serve canned byte responses on a local port, one per connection (the
/// last response repeats), and return the base URL
///
/// The canned responses must use 'Connection: close' so the client opens
/// a fresh connection (and receives a fresh response) per request.
fn serve_responses(responses: Vec<Vec<u8>>) -> String {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
std::thread::spawn(move || {
for (served, stream) in listener.incoming().flatten().enumerate() {
let index = served.min(responses.len() - 1);
let mut stream = stream;
// Drain the request first: closing with unread inbound data
// would send a TCP RST and destroy the response in flight
let mut buf = [0u8; 4096];
loop {
match stream.read(&mut buf) {
Ok(0) => break,
Ok(n) if buf[..n].windows(4).any(|w| w == b"\r\n\r\n") => break,
Ok(_) => continue,
Err(_) => break,
}
}
let _ = stream.write_all(&responses[index]);
let _ = stream.flush();
}
});
format!("http://{addr}/Sources.gz")
}
/// A CDN answering 200 with an empty body (observed under load) must be
/// retried instead of failing the index checksum verification
#[tokio::test]
async fn fetch_index_bytes_retries_empty_body() {
let valid = b"Package: hello\nVersion: 1.0\n\n";
let empty_response =
b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_vec();
let mut valid_response = format!(
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
valid.len()
)
.into_bytes();
valid_response.extend_from_slice(valid);
let url = serve_responses(vec![empty_response, valid_response]);
let data = fetch_index_bytes(&url).await.unwrap();
assert_eq!(data, valid);
}
#[tokio::test]
async fn test_check_launchpad_repo() {
// "hello" should exist on Launchpad for Ubuntu
let url = check_launchpad_repo("hello").await.unwrap();
assert!(url.is_some());
assert_eq!(
url.unwrap(),
"https://git.launchpad.net/ubuntu/+source/hello"
);
// "this-package-should-not-exist-12345" should not exist
let url = check_launchpad_repo("this-package-should-not-exist-12345")
.await
.unwrap();
assert!(url.is_none());
}
#[test]
fn test_parse_sources() {
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
let data = "Package: hello
Version: 2.10-2
Format: 3.0 (quilt)
Directory: pool/main/h/hello
Vcs-Git: https://salsa.debian.org/debian/hello.git
Package: other
Version: 1.0
";
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(data.as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
let info = parse_sources(&compressed, "hello", None).unwrap().unwrap();
assert_eq!(info.package, "hello");
assert_eq!(info.version, "2.10-2");
assert_eq!(info.format, "3.0 (quilt)");
assert_eq!(info.directory, "pool/main/h/hello");
assert_eq!(
info.vcs_git.unwrap(),
"https://salsa.debian.org/debian/hello.git"
);
let none = parse_sources(&compressed, "missing", None).unwrap();
assert!(none.is_none());
}
#[test]
fn test_parse_sources_compression_variants() {
use std::io::Write;
use xz2::write::XzEncoder;
let data = "Package: hello
Version: 1.0
Directory: pool/main/h/hello
";
// xz-compressed index (e.g. the 'Sources.xz' of flat repositories)
let mut encoder = XzEncoder::new(Vec::new(), 6);
encoder.write_all(data.as_bytes()).unwrap();
let compressed = encoder.finish().unwrap();
let info = parse_sources(&compressed, "hello", None).unwrap().unwrap();
assert_eq!(info.version, "1.0");
// Uncompressed index
let info = parse_sources(data.as_bytes(), "hello", None)
.unwrap()
.unwrap();
assert_eq!(info.version, "1.0");
}
#[test]
fn test_parse_sources_many_blank_stanzas() {
// A crafted index with many consecutive package-less stanzas must be
// iterated without recursion: 100k blank stanzas would overflow the
// stack with the old recursive 'return self.next()' implementation
let mut data = String::new();
for _ in 0..100_000 {
data.push_str("Not-Really-Package: x\n\n");
}
data.push_str("Package: hello\nVersion: 1.0\n");
let info = parse_sources(data.as_bytes(), "hello", None)
.unwrap()
.unwrap();
assert_eq!(info.package, "hello");
assert_eq!(info.version, "1.0");
}
#[test]
fn test_parse_sources_stanza_without_version() {
// A stanza with a 'Package' but no 'Version' field is malformed
// remote data: it must be skipped rather than panic
let data = "Package: noversion\nDirectory: pool/main/n/noversion\n\n\
Package: hello\nVersion: 1.0\n";
let info = parse_sources(data.as_bytes(), "hello", None)
.unwrap()
.unwrap();
assert_eq!(info.package, "hello");
assert_eq!(info.version, "1.0");
assert!(
parse_sources(data.as_bytes(), "noversion", None)
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn test_find_package_fallback() {
// python2.7 is in bullseye but not above
let info = find_package("python2.7", "debian", "", None, None, None, None)
.await
.unwrap();
assert_eq!(info.stanza.package, "python2.7");
assert_eq!(info.series, "bullseye")
}
#[tokio::test]
async fn test_find_package_devel() {
// hello is in sid
let info = find_package("hello", "debian", "", None, None, None, None)
.await
.unwrap();
assert_eq!(info.stanza.package, "hello");
assert_eq!(info.series, "sid")
}
}