Initial commit
- pkh created - basic 'get' command to obtain a source package
This commit is contained in:
282
src/package_info.rs
Normal file
282
src/package_info.rs
Normal file
@@ -0,0 +1,282 @@
|
||||
use flate2::read::GzDecoder;
|
||||
use std::io::Read;
|
||||
use std::collections::HashMap;
|
||||
use serde::Deserialize;
|
||||
use std::error::Error;
|
||||
use std::path::Path;
|
||||
|
||||
const BASE_URL_UBUNTU: &str = "http://archive.ubuntu.com/ubuntu";
|
||||
const BASE_URL_DEBIAN: &str = "http://deb.debian.org/debian";
|
||||
|
||||
async fn check_launchpad_repo(package: &str) -> Result<Option<String>, Box<dyn Error>> {
|
||||
let url = format!("https://git.launchpad.net/ubuntu/+source/{}", package);
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?;
|
||||
let response = client.head(&url).send().await?;
|
||||
|
||||
if response.status().is_success() {
|
||||
Ok(Some(url))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_series_from_url(url: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let content = reqwest::get(url).await?.text().await?;
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.from_reader(content.as_bytes());
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let series_idx = headers.iter().position(|h| h == "series").ok_or("Column 'series' not found")?;
|
||||
|
||||
let mut series = Vec::new();
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
if let Some(s) = record.get(series_idx) {
|
||||
series.push(s.to_string());
|
||||
}
|
||||
}
|
||||
Ok(series)
|
||||
}
|
||||
|
||||
fn get_series_from_file(path: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
let mut rdr = csv::ReaderBuilder::new()
|
||||
.flexible(true)
|
||||
.from_path(path)?;
|
||||
|
||||
let headers = rdr.headers()?.clone();
|
||||
let series_idx = headers.iter().position(|h| h == "series").ok_or("Column 'series' not found")?;
|
||||
|
||||
let mut series = Vec::new();
|
||||
for result in rdr.records() {
|
||||
let record = result?;
|
||||
if let Some(s) = record.get(series_idx) {
|
||||
series.push(s.to_string());
|
||||
}
|
||||
}
|
||||
Ok(series)
|
||||
}
|
||||
|
||||
pub async fn get_dist_series(dist: &str) -> Result<Vec<String>, Box<dyn Error>> {
|
||||
if Path::new(format!("/usr/share/distro-info/{dist}.csv").as_str()).exists() {
|
||||
get_series_from_file(format!("/usr/share/distro-info/{dist}.csv").as_str())
|
||||
} else {
|
||||
get_series_from_url(format!("https://salsa.debian.org/debian/distro-info-data/-/raw/main/{dist}.csv").as_str()).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_dist_from_series(series: &str) -> Result<String, Box<dyn Error>> {
|
||||
let debian_series = get_dist_series("debian").await?;
|
||||
if debian_series.contains(&series.to_string()) {
|
||||
return Ok("debian".to_string());
|
||||
}
|
||||
let ubuntu_series = get_dist_series("ubuntu").await?;
|
||||
if ubuntu_series.contains(&series.to_string()) {
|
||||
return Ok("ubuntu".to_string());
|
||||
}
|
||||
Err(format!("Unknown series: {}", series).into())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileEntry {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PackageStanza {
|
||||
pub package: String,
|
||||
pub version: String,
|
||||
pub directory: String,
|
||||
pub vcs_git: Option<String>,
|
||||
pub vcs_browser: Option<String>,
|
||||
pub files: Vec<FileEntry>,
|
||||
}
|
||||
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PackageInfo {
|
||||
pub dist: String,
|
||||
pub stanza: PackageStanza,
|
||||
pub preferred_vcs: Option<String>,
|
||||
pub archive_url: String
|
||||
}
|
||||
|
||||
fn get_sources_url(base_url: &str, series: &str, pocket: &str, component: &str) -> String {
|
||||
let pocket_full = if pocket.is_empty() { String::new() } else { format!("-{}", pocket) };
|
||||
format!("{base_url}/dists/{series}{pocket_full}/{component}/source/Sources.gz")
|
||||
}
|
||||
|
||||
fn get_base_url(dist: &str) -> &str {
|
||||
match dist {
|
||||
"ubuntu" => BASE_URL_UBUNTU,
|
||||
"debian" => BASE_URL_DEBIAN,
|
||||
_ => panic!("Unknown distribution"),
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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) -> Result<Option<PackageStanza>, Box<dyn Error>> {
|
||||
let mut d = GzDecoder::new(data);
|
||||
let mut s = String::new();
|
||||
d.read_to_string(&mut s)?;
|
||||
|
||||
for stanza in s.split("\n\n") {
|
||||
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(¤t_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());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(pkg) = fields.get("Package") {
|
||||
if pkg == target_package {
|
||||
let mut files = Vec::new();
|
||||
if let Some(checksums) = fields.get("Checksums-Sha256") {
|
||||
for line in checksums.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
files.push(FileEntry {
|
||||
sha256: parts[0].to_string(),
|
||||
size: parts[1].parse().unwrap_or(0),
|
||||
name: parts[2].to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(Some(PackageStanza {
|
||||
package: pkg.clone(),
|
||||
version: fields.get("Version").cloned().unwrap_or_default(),
|
||||
directory: fields.get("Directory").cloned().unwrap_or_default(),
|
||||
vcs_git: fields.get("Vcs-Git").cloned(),
|
||||
vcs_browser: fields.get("Vcs-Browser").cloned(),
|
||||
files,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn get(package_name: &str, series: &str, pocket: &str) -> Result<Option<PackageInfo>, Box<dyn Error>> {
|
||||
let dist = get_dist_from_series(series).await?;
|
||||
|
||||
// Handle Ubuntu case: Vcs-Git does not usually point to Launchpad but Salsa
|
||||
// We need to check manually if there is a launchpad repository for the package
|
||||
let mut preferred_vcs = None;
|
||||
if dist == "ubuntu" {
|
||||
if let Some(lp_url) = check_launchpad_repo(package_name).await? {
|
||||
println!("Found Launchpad URL: {}", lp_url);
|
||||
preferred_vcs = Some(lp_url);
|
||||
}
|
||||
}
|
||||
|
||||
let base_url = get_base_url(&dist);
|
||||
|
||||
let component = "main"; // TODO: Make configurable or detect
|
||||
let url = get_sources_url(base_url, series, pocket, component);
|
||||
|
||||
println!("Fetching sources from: {}", url);
|
||||
|
||||
let response = reqwest::get(&url).await?;
|
||||
let compressed_data = response.bytes().await?;
|
||||
|
||||
println!("Downloaded Sources.gz for {}/{}", dist, series);
|
||||
|
||||
if let Some(stanza) = parse_sources(&compressed_data, package_name)? {
|
||||
if let Some(vcs) = &stanza.vcs_git {
|
||||
if preferred_vcs.is_none() {
|
||||
preferred_vcs = Some(vcs.clone());
|
||||
}
|
||||
}
|
||||
|
||||
let archive_url = format!("{base_url}/{0}", stanza.directory);
|
||||
return Ok(Some(PackageInfo {
|
||||
dist: dist,
|
||||
stanza: stanza,
|
||||
preferred_vcs: preferred_vcs,
|
||||
archive_url: archive_url,
|
||||
}));
|
||||
} else {
|
||||
// println!("Package '{}' not found in {}/{}", package, dist, series);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_debian_series() {
|
||||
let series = get_dist_series("debian").await.unwrap();
|
||||
assert!(series.contains(&"sid".to_string()));
|
||||
assert!(series.contains(&"bookworm".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_ubuntu_series() {
|
||||
let series = get_dist_series("ubuntu").await.unwrap();
|
||||
assert!(series.contains(&"noble".to_string()));
|
||||
assert!(series.contains(&"jammy".to_string()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_dist_from_series() {
|
||||
assert_eq!(get_dist_from_series("sid").await.unwrap(), "debian");
|
||||
assert_eq!(get_dist_from_series("noble").await.unwrap(), "ubuntu");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_sources() {
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use std::io::Write;
|
||||
|
||||
let data = "Package: hello\nVersion: 2.10-2\nDirectory: pool/main/h/hello\nVcs-Git: https://salsa.debian.org/debian/hello.git\n\nPackage: other\nVersion: 1.0\n";
|
||||
|
||||
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").unwrap().unwrap();
|
||||
assert_eq!(info.package, "hello");
|
||||
assert_eq!(info.version, "2.10-2");
|
||||
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").unwrap();
|
||||
assert!(none.is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user