use apt_sources::{
error::{LoadError, RepositoryError},
Repository, RepositoryType,
};
use debian_control::apt::Release;
use flate2::read::MultiGzDecoder;
use lzma_rs::lzma_decompress;
use std::collections::{HashMap, HashSet};
use std::fs::File;
use std::io::{BufRead, BufReader, Read};
use std::path::{Path, PathBuf};
use url::Url;
#[derive(Debug)]
pub enum Error {
AptFileAccessError(String),
FileNotFoundError(String),
IoError(std::io::Error),
DecompressionError(String),
}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Error {
Error::IoError(e)
}
}
impl From<RepositoryError> for Error {
fn from(e: RepositoryError) -> Error {
Error::AptFileAccessError(format!("Repository error: {}", e))
}
}
impl From<LoadError> for Error {
fn from(e: LoadError) -> Error {
Error::AptFileAccessError(format!("Load error: {}", e))
}
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
Error::AptFileAccessError(e) => write!(f, "AptFileAccessError: {}", e),
Error::FileNotFoundError(e) => write!(f, "FileNotFoundError: {}", e),
Error::IoError(e) => write!(f, "IoError: {}", e),
Error::DecompressionError(e) => write!(f, "DecompressionError: {}", e),
}
}
}
impl std::error::Error for Error {}
pub fn read_contents_file<R: Read>(f: R) -> impl Iterator<Item = (String, String)> {
let mut reader = BufReader::new(f);
std::iter::from_fn(move || loop {
let mut buf = Vec::new();
match reader.read_until(b'\n', &mut buf) {
Ok(0) => return None,
Ok(_) => {}
Err(e) => panic!("io error reading contents file: {e}"),
}
let line = String::from_utf8_lossy(&buf);
let line = line.trim_end_matches(['\r', '\n']);
let Some(split) = line.rfind(char::is_whitespace) else {
continue;
};
let (path, rest) = line.split_at(split);
let path = path.trim_end();
let rest = rest.trim_start();
if path.is_empty() || rest.is_empty() {
continue;
}
return Some((format!("/{path}"), rest.to_string()));
})
}
pub fn contents_urls_from_repository<'a>(
repo: &'a Repository,
arches: Vec<&'a str>,
load_url: impl Fn(&url::Url) -> Result<Box<dyn Read>, Error>,
resolve_certs: impl Fn(
Option<&apt_sources::signature::Signature>,
) -> Result<Vec<sequoia_openpgp::Cert>, crate::debian::apt::verify::Error>,
) -> Box<dyn Iterator<Item = url::Url> + 'a> {
if !repo.types.contains(&RepositoryType::Binary) {
return Box::new(vec![].into_iter());
}
let mut all_urls = Vec::new();
for uri in &repo.uris {
for dist in &repo.suites {
let comps = repo
.components
.as_ref()
.map(|c| c.as_slice())
.unwrap_or(&[]);
let base_url = uri.as_str().trim_end_matches('/');
let name = dist.trim_end_matches('/');
let dist_url: url::Url = if comps.is_empty() {
format!("{}/{}/", base_url, name)
} else {
format!("{}/dists/{}/", base_url, name)
}
.parse()
.unwrap();
let certs = match resolve_certs(repo.signature.as_ref()) {
Ok(certs) => certs,
Err(e) => {
log::error!(
"Refusing to use APT Release for {}: no trusted keys: {}",
dist_url,
e
);
return Box::new(vec![].into_iter());
}
};
let inrelease_url: Url = dist_url.join("InRelease").unwrap();
let release = match load_url(&inrelease_url) {
Ok(mut response) => {
let mut signed = Vec::new();
response.read_to_end(&mut signed).unwrap();
match crate::debian::apt::verify::verify_clearsigned(&signed, certs) {
Ok(payload) => String::from_utf8_lossy(&payload).into_owned(),
Err(e) => {
log::error!(
"APT Release signature verification failed for {}: {}",
inrelease_url,
e
);
return Box::new(vec![].into_iter());
}
}
}
Err(_) => {
let release_url = dist_url.join("Release").unwrap();
let signature_url = dist_url.join("Release.gpg").unwrap();
let mut release_bytes = Vec::new();
let mut signature_bytes = Vec::new();
let loaded = load_url(&release_url).and_then(|mut r| {
r.read_to_end(&mut release_bytes)?;
let mut s = load_url(&signature_url)?;
s.read_to_end(&mut signature_bytes)?;
Ok(())
});
if let Err(e) = loaded {
log::warn!(
"Unable to download {} or {} (+ Release.gpg): {}",
inrelease_url,
release_url,
e
);
return Box::new(vec![].into_iter());
}
if let Err(e) = crate::debian::apt::verify::verify_detached(
&release_bytes,
&signature_bytes,
certs,
) {
log::error!(
"APT Release signature verification failed for {}: {}",
release_url,
e
);
return Box::new(vec![].into_iter());
}
String::from_utf8_lossy(&release_bytes).into_owned()
}
};
let mut existing_names = HashMap::new();
let release: Release = release.parse().unwrap();
for name in release
.checksums_md5()
.into_iter()
.map(|x| x.filename)
.chain(release.checksums_sha256().into_iter().map(|x| x.filename))
.chain(release.checksums_sha1().into_iter().map(|x| x.filename))
.chain(release.checksums_sha512().into_iter().map(|x| x.filename))
{
let key = match name.rsplit_once('.') {
Some((stem, "gz" | "xz" | "lz4")) => stem.to_string(),
_ => name.clone(),
};
existing_names.insert(key, name);
}
let mut contents_files = HashSet::new();
if comps.is_empty() {
for arch in &arches {
contents_files.insert(format!("Contents-{}", arch));
}
} else {
for comp in comps {
for arch in &arches {
contents_files.insert(format!("{}/Contents-{}", comp, arch));
}
}
}
let urls: Vec<_> = contents_files
.into_iter()
.filter_map(|f| {
if !existing_names.contains_key(&f) {
return None;
}
Some(dist_url.join(&f).unwrap())
})
.collect();
all_urls.extend(urls);
}
}
Box::new(all_urls.into_iter())
}
pub fn contents_urls_from_sources<'a>(
repositories: &'a apt_sources::Repositories,
arch: &'a str,
load_url: impl Fn(&'_ url::Url) -> Result<Box<dyn Read>, Error> + 'a + Copy,
resolve_certs: impl Fn(
Option<&apt_sources::signature::Signature>,
) -> Result<Vec<sequoia_openpgp::Cert>, crate::debian::apt::verify::Error>
+ 'a
+ Copy,
) -> impl Iterator<Item = url::Url> + 'a {
let arches = vec![arch, "all"];
repositories.iter().flat_map(move |repo| {
contents_urls_from_repository(repo, arches.clone(), load_url, resolve_certs)
})
}
pub fn unwrap<'a, R: Read + 'a>(f: R, ext: &str) -> Result<Box<dyn Read + 'a>, Error> {
match ext {
".gz" => Ok(Box::new(MultiGzDecoder::new(f))),
".xz" => {
let mut compressed_reader = BufReader::new(f);
let mut decompressed_data = Vec::new();
lzma_decompress(&mut compressed_reader, &mut decompressed_data).map_err(|e| {
Error::DecompressionError(format!("LZMA decompression failed: {}", e))
})?;
Ok(Box::new(std::io::Cursor::new(decompressed_data)))
}
".lz4" => Ok(Box::new(lz4_flex::frame::FrameDecoder::new(f))),
_ => Ok(Box::new(f)),
}
}
pub fn load_direct_url(url: &url::Url) -> Result<Box<dyn Read>, Error> {
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(300)) .connect_timeout(std::time::Duration::from_secs(30)) .build()
.map_err(|e| Error::AptFileAccessError(format!("Failed to create HTTP client: {}", e)))?;
for ext in [".xz", ".gz", ""] {
let response = match client.get(url.to_string() + ext).send() {
Ok(response) => response,
Err(e) => {
log::debug!("Failed to fetch APT contents from {}{}: {}", url, ext, e);
return Err(Error::AptFileAccessError(format!(
"Unable to access apt URL {}{}: {}",
url, ext, e
)));
}
};
if response.status() == reqwest::StatusCode::NOT_FOUND {
continue;
}
if !response.status().is_success() {
return Err(Error::AptFileAccessError(format!(
"Unable to access apt URL {}{}: HTTP {}",
url,
ext,
response.status()
)));
}
return unwrap(response, ext);
}
Err(Error::FileNotFoundError(format!("{} not found", url)))
}
fn get_user_cache_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("ognibuild").join("apt-contents"))
}
pub fn load_url_with_cache(url: &url::Url, cache_dirs: &[&Path]) -> Result<Box<dyn Read>, Error> {
for cache_dir in cache_dirs {
match load_apt_cache_file(url, cache_dir) {
Ok(f) => return Ok(Box::new(f)),
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
return Err(e.into());
}
}
}
}
if let Some(user_cache_dir) = get_user_cache_dir() {
match load_apt_cache_file(url, &user_cache_dir) {
Ok(f) => {
log::debug!(
"Found cached APT contents in user cache: {}",
user_cache_dir.display()
);
return Ok(Box::new(f));
}
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
log::debug!("Error reading from user cache: {}", e);
}
}
}
}
download_and_cache_url(url)
}
fn download_and_cache_url(url: &url::Url) -> Result<Box<dyn Read>, Error> {
let content = load_direct_url(url)?;
if let Some(user_cache_dir) = get_user_cache_dir() {
if let Err(e) = std::fs::create_dir_all(&user_cache_dir) {
log::debug!(
"Failed to create cache directory {}: {}",
user_cache_dir.display(),
e
);
} else {
let mut buffer = Vec::new();
let mut reader = content;
if let Err(e) = std::io::Read::read_to_end(&mut reader, &mut buffer) {
log::debug!("Failed to read content for caching: {}", e);
return Ok(reader); }
let cache_file_path = user_cache_dir.join(uri_to_filename(url));
match std::fs::write(&cache_file_path, &buffer) {
Ok(_) => {
log::info!("Cached APT contents to: {}", cache_file_path.display());
}
Err(e) => {
log::debug!(
"Failed to write cache file {}: {}",
cache_file_path.display(),
e
);
}
}
return Ok(Box::new(std::io::Cursor::new(buffer)));
}
}
Ok(content)
}
pub fn uri_to_filename(url: &url::Url) -> String {
let mut url = url.clone();
let _ = url.set_username("");
let _ = url.set_password(None);
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
const BAD_CHARS: &AsciiSet = &CONTROLS
.add(b' ') .add(b'\"') .add(b'\\') .add(b'{')
.add(b'}')
.add(b'[')
.add(b']')
.add(b'<')
.add(b'>')
.add(b'^')
.add(b'~')
.add(b'_')
.add(b'=')
.add(b'!')
.add(b'@')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'^')
.add(b'&')
.add(b'*');
let mut u = url.to_string();
if let Some(pos) = u.find("://") {
u = u[(pos + 3)..].to_string(); }
let encoded_uri = utf8_percent_encode(&u, BAD_CHARS).to_string();
encoded_uri.replace('/', "_")
}
pub fn load_apt_cache_file(
url: &url::Url,
cache_dir: &Path,
) -> Result<Box<dyn Read>, std::io::Error> {
let f = uri_to_filename(url);
for ext in [".xz", ".gz", ".lz4", ""] {
let p = cache_dir.join([&f, ext].concat());
if !p.exists() {
continue;
}
log::debug!("Loading cached contents file {}", p.display());
let f = File::open(p)?;
return unwrap(f, ext).map_err(|e| match e {
Error::IoError(io_err) => io_err,
Error::DecompressionError(msg) => {
std::io::Error::new(std::io::ErrorKind::InvalidData, msg)
}
other => std::io::Error::new(std::io::ErrorKind::Other, format!("{:?}", other)),
});
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("{} not found", url),
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_uri_to_filename() {
assert_eq!(
uri_to_filename(&"http://example.com/foo/bar".parse().unwrap()),
"example.com_foo_bar"
);
}
#[test]
fn test_uri_to_filename_with_credentials() {
assert_eq!(
uri_to_filename(&"http://user:pass@example.com/foo/bar".parse().unwrap()),
"example.com_foo_bar"
);
}
#[test]
fn test_uri_to_filename_host_less() {
assert_eq!(
uri_to_filename(&"file:///var/lib/foo/dists/sid/InRelease".parse().unwrap()),
"_var_lib_foo_dists_sid_InRelease"
);
}
#[test]
fn test_read_contents_file() {
let data = b"usr/bin/foo admin/foo-tools\nusr/lib/bar.so libs/libbar\n";
let entries: Vec<(String, String)> =
read_contents_file(std::io::Cursor::new(data.to_vec())).collect();
assert_eq!(
entries,
vec![
("/usr/bin/foo".to_string(), "admin/foo-tools".to_string()),
("/usr/lib/bar.so".to_string(), "libs/libbar".to_string()),
]
);
}
#[test]
fn test_read_contents_file_skips_blank_and_headerless_lines() {
let data = b"\nusr/bin/foo admin/foo\n \nleftover\n";
let entries: Vec<(String, String)> =
read_contents_file(std::io::Cursor::new(data.to_vec())).collect();
assert_eq!(
entries,
vec![("/usr/bin/foo".to_string(), "admin/foo".to_string())]
);
}
#[test]
fn test_read_contents_file_tolerates_non_utf8() {
let mut data = Vec::new();
data.extend_from_slice(b"usr/share/\xff/file doc/weird\n");
data.extend_from_slice(b"usr/bin/ok admin/ok\n");
let entries: Vec<(String, String)> =
read_contents_file(std::io::Cursor::new(data)).collect();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].1, "doc/weird");
assert!(entries[0].0.starts_with("/usr/share/"));
assert_eq!(
entries[1],
("/usr/bin/ok".to_string(), "admin/ok".to_string())
);
}
#[test]
fn test_unwrap_multi_member_gz() {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let mut compressed = Vec::new();
for part in [b"first member\n".as_ref(), b"second member\n".as_ref()] {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(part).unwrap();
compressed.extend(encoder.finish().unwrap());
}
let mut f = unwrap(std::io::Cursor::new(compressed), ".gz").unwrap();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
assert_eq!(buf, b"first member\nsecond member\n");
}
fn clearsign_release(release: &str) -> (Vec<u8>, sequoia_openpgp::Cert) {
use sequoia_openpgp::cert::CertBuilder;
use sequoia_openpgp::policy::StandardPolicy;
use sequoia_openpgp::serialize::stream::{Message, Signer};
use std::io::Write;
let (cert, _) = CertBuilder::new().add_signing_subkey().generate().unwrap();
let policy = StandardPolicy::new();
let keypair = cert
.keys()
.with_policy(&policy, None)
.secret()
.for_signing()
.next()
.unwrap()
.key()
.clone()
.into_keypair()
.unwrap();
let mut sink = Vec::new();
{
let message = Message::new(&mut sink);
let mut signer = Signer::new(message, keypair)
.unwrap()
.cleartext()
.build()
.unwrap();
signer.write_all(release.as_bytes()).unwrap();
signer.finalize().unwrap();
}
(sink, cert)
}
#[test]
fn test_contents_urls_from_sources() {
use std::str::FromStr;
let s =
"Types: deb\nURIs: http://deb.debian.org/debian\nSuites: trixie\nComponents: main\n";
let repos = apt_sources::Repositories::from_str(s).unwrap();
let release = concat!(
"Origin: Debian\n",
"MD5Sum:\n",
" 0000000000000000000000000000000a 1 main/Contents-amd64.gz\n",
" 0000000000000000000000000000000b 1 main/Contents-all.gz\n",
);
let (inrelease, cert) = clearsign_release(release);
let load_url = move |url: &url::Url| -> Result<Box<dyn Read>, Error> {
if url.path().ends_with("/InRelease") {
Ok(Box::new(std::io::Cursor::new(inrelease.clone())))
} else {
Err(Error::FileNotFoundError(url.to_string()))
}
};
let resolve_certs =
move |_signature: Option<&apt_sources::signature::Signature>| Ok(vec![cert.clone()]);
let mut urls: Vec<String> =
contents_urls_from_sources(&repos, "amd64", &load_url, &resolve_certs)
.map(|u| u.to_string())
.collect();
urls.sort();
assert_eq!(
urls,
vec![
"http://deb.debian.org/debian/dists/trixie/main/Contents-all".to_string(),
"http://deb.debian.org/debian/dists/trixie/main/Contents-amd64".to_string(),
]
);
}
#[test]
fn test_contents_urls_rejects_untrusted_release() {
use std::str::FromStr;
let s =
"Types: deb\nURIs: http://deb.debian.org/debian\nSuites: trixie\nComponents: main\n";
let repos = apt_sources::Repositories::from_str(s).unwrap();
let release =
"Origin: Debian\nMD5Sum:\n 0000000000000000000000000000000a 1 main/Contents-amd64.gz\n";
let (inrelease, _signing_cert) = clearsign_release(release);
let (_, other_cert) = clearsign_release("unrelated");
let load_url = move |url: &url::Url| -> Result<Box<dyn Read>, Error> {
if url.path().ends_with("/InRelease") {
Ok(Box::new(std::io::Cursor::new(inrelease.clone())))
} else {
Err(Error::FileNotFoundError(url.to_string()))
}
};
let resolve_certs = move |_signature: Option<&apt_sources::signature::Signature>| {
Ok(vec![other_cert.clone()])
};
let urls: Vec<url::Url> =
contents_urls_from_sources(&repos, "amd64", &load_url, &resolve_certs).collect();
assert!(urls.is_empty(), "expected no URLs, got {:?}", urls);
}
#[test]
fn test_unwrap_plain() {
let data = b"hello world";
let f = std::io::Cursor::new(data);
let mut f = unwrap(f, "").unwrap();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
assert_eq!(buf, b"hello world");
}
#[test]
fn test_unwrap_gz() {
use flate2::write::GzEncoder;
use flate2::Compression;
use std::io::Write;
let original = b"hello world from gzip";
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(original).unwrap();
let compressed = encoder.finish().unwrap();
let f = std::io::Cursor::new(compressed);
let mut f = unwrap(f, ".gz").unwrap();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
assert_eq!(buf, original);
}
#[test]
fn test_unwrap_xz() {
use lzma_rs::lzma_compress;
let original = b"hello world from xz";
let mut compressed = Vec::new();
lzma_compress(&mut original.as_ref(), &mut compressed).unwrap();
let f = std::io::Cursor::new(compressed);
let mut f = unwrap(f, ".xz").unwrap();
let mut buf = Vec::new();
f.read_to_end(&mut buf).unwrap();
assert_eq!(buf, original);
}
#[test]
fn test_unwrap_corrupt_xz() {
let corrupt_data = b"this is not valid xz data";
let f = std::io::Cursor::new(corrupt_data);
let result = unwrap(f, ".xz");
assert!(result.is_err());
if let Err(Error::DecompressionError(msg)) = result {
assert!(msg.contains("LZMA"));
} else {
panic!("Expected DecompressionError");
}
}
}