Add version parsing, FMRI formatting, and license content resolution

- Enhanced `get_info` handler to parse version components into `Build Release`, `Branch`, and `Packaging Date`.
- Added proper FMRI formatting in `pkg://<publisher>/<name>@<version>` format.
- Implemented license content resolution with file-based lookup, gzip decompression, and content truncation.
- Introduced compressed and uncompressed package size calculations for manifest actions.
- Updated dependencies to include `chrono` and `flate2` for date parsing and gzip decompression.
This commit is contained in:
Till Wegmueller 2025-12-09 16:42:21 +01:00
parent cff3d5d960
commit 3457b4acba
No known key found for this signature in database
4 changed files with 193 additions and 7 deletions

2
Cargo.lock generated
View file

@ -2104,8 +2104,10 @@ dependencies = [
"axum", "axum",
"axum-server", "axum-server",
"bytes", "bytes",
"chrono",
"clap", "clap",
"dirs", "dirs",
"flate2",
"http-body-util", "http-body-util",
"hyper", "hyper",
"knuffel", "knuffel",

View file

@ -31,6 +31,8 @@ serde_json = "1.0"
dirs = "6" dirs = "6"
nix = { version = "0.30", features = ["signal", "process", "user", "fs"] } nix = { version = "0.30", features = ["signal", "process", "user", "fs"] }
sha1 = "0.10" sha1 = "0.10"
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
flate2 = "1"
# Telemetry # Telemetry
tracing = "0.1" tracing = "0.1"

View file

@ -9,6 +9,10 @@ use crate::errors::DepotError;
use libips::fmri::Fmri; use libips::fmri::Fmri;
use std::str::FromStr; use std::str::FromStr;
use libips::actions::Manifest; use libips::actions::Manifest;
use chrono::{NaiveDateTime, Utc, TimeZone, Datelike, Timelike};
use libips::actions::Property;
use std::fs;
use std::io::Read as _;
pub async fn get_info( pub async fn get_info(
State(repo): State<Arc<DepotRepo>>, State(repo): State<Arc<DepotRepo>>,
@ -30,17 +34,85 @@ pub async fn get_info(
out.push_str(&format!("Summary: {}\n", summary)); out.push_str(&format!("Summary: {}\n", summary));
} }
out.push_str(&format!("Publisher: {}\n", publisher)); out.push_str(&format!("Publisher: {}\n", publisher));
out.push_str(&format!("Version: {}\n", fmri.version())); // Parse version components for Version, Build Release, Branch, and Packaging Date
out.push_str(&format!("FMRI: pkg://{}/{}\n", publisher, fmri)); let version_full = fmri.version();
let mut version_core = version_full.clone();
let mut build_release: Option<String> = None;
let mut branch: Option<String> = None;
let mut ts_str: Option<String> = None;
if let Some((core, rest)) = version_full.split_once(',') {
version_core = core.to_string();
if let Some((rel_branch, ts)) = rest.split_once(':') {
ts_str = Some(ts.to_string());
if let Some((rel, br)) = rel_branch.split_once('-') {
if !rel.is_empty() { build_release = Some(rel.to_string()); }
if !br.is_empty() { branch = Some(br.to_string()); }
} else {
// No branch
if !rel_branch.is_empty() { build_release = Some(rel_branch.to_string()); }
}
} else {
// No timestamp
if let Some((rel, br)) = rest.split_once('-') {
if !rel.is_empty() { build_release = Some(rel.to_string()); }
if !br.is_empty() { branch = Some(br.to_string()); }
} else if !rest.is_empty() {
build_release = Some(rest.to_string());
}
}
}
out.push_str(&format!("Version: {}\n", version_core));
if let Some(rel) = build_release { out.push_str(&format!("Build Release: {}\n", rel)); }
if let Some(br) = branch { out.push_str(&format!("Branch: {}\n", br)); }
if let Some(ts) = ts_str.and_then(|s| format_packaging_date(&s)) {
out.push_str(&format!("Packaging Date: {}\n", ts));
}
// Compute sizes from manifest file actions (pkg.size and pkg.csize)
let (total_size, total_csize) = compute_sizes(&manifest);
out.push_str(&format!("Size: {}\n", human_bytes(total_size)));
out.push_str(&format!("Compressed Size: {}\n", human_bytes(total_csize)));
// Construct a correct FMRI string:
// Always use the publisher from the URL segment, and format as
// pkg://<publisher>/<name>@<version>
let name = fmri.stem();
let version = fmri.version();
if version.is_empty() {
out.push_str(&format!("FMRI: pkg://{}/{}\n", publisher, name));
} else {
out.push_str(&format!("FMRI: pkg://{}/{}@{}\n", publisher, name, version));
}
// License // License
// License might be an action (License action) or attribute. // Print actual license text content from repository instead of hash.
// Usually it's license actions.
// For M2 minimal parity, we can skip detailed license text or just say empty if not found.
// depot.txt sample shows "License:" empty line if none?
out.push_str("\nLicense:\n"); out.push_str("\nLicense:\n");
let mut first = true;
for license in &manifest.licenses { for license in &manifest.licenses {
out.push_str(&format!("{}\n", license.payload)); if !first { out.push('\n'); }
first = false;
// Optional license name header for readability
if let Some(name_prop) = license.properties.get("license") {
if !name_prop.value.is_empty() {
out.push_str(&format!("Name: {}\n", name_prop.value));
}
}
// Resolve file by digest payload
let digest = license.payload.trim();
if !digest.is_empty() {
match resolve_license_text(&repo, &publisher, digest) {
Some(text) => {
out.push_str(&text);
if !text.ends_with('\n') { out.push('\n'); }
}
None => {
// Fallback: show the digest if content could not be resolved
out.push_str(&format!("<license content unavailable for digest {}>\n", digest));
}
}
}
} }
Ok(( Ok((
@ -49,6 +121,43 @@ pub async fn get_info(
).into_response()) ).into_response())
} }
// Try to read and decode the license text for a given digest from the repository.
// - Prefer publisher-scoped file path; fallback to global location.
// - If content appears to be gzip-compressed (magic 1f 8b), decompress.
// - Decode as UTF-8 (lossy) and enforce a maximum output size to avoid huge responses.
fn resolve_license_text(repo: &DepotRepo, publisher: &str, digest: &str) -> Option<String> {
let path = repo.get_file_path(publisher, digest)?;
let bytes = fs::read(&path).ok()?;
// Detect gzip magic
let mut data: Vec<u8> = bytes;
if data.len() >= 2 && data[0] == 0x1f && data[1] == 0x8b {
// Attempt to gunzip
let mut decoder = flate2::read::GzDecoder::new(&data[..]);
let mut decompressed = Vec::new();
match decoder.read_to_end(&mut decompressed) {
Ok(_) => data = decompressed,
Err(_) => {
// Leave as-is if decompression fails
}
}
}
// Limit output size to 256 KiB
const MAX_LICENSE_BYTES: usize = 256 * 1024;
let truncated = data.len() > MAX_LICENSE_BYTES;
if truncated {
data.truncate(MAX_LICENSE_BYTES);
}
let mut text = String::from_utf8_lossy(&data).to_string();
if truncated {
if !text.ends_with('\n') { text.push('\n'); }
text.push_str("...[truncated]\n");
}
Some(text)
}
fn find_attr(manifest: &Manifest, key: &str) -> Option<String> { fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
for attr in &manifest.attributes { for attr in &manifest.attributes {
if attr.key == key { if attr.key == key {
@ -57,3 +166,74 @@ fn find_attr(manifest: &Manifest, key: &str) -> Option<String> {
} }
None None
} }
fn month_name(month: u32) -> &'static str {
match month {
1 => "January",
2 => "February",
3 => "March",
4 => "April",
5 => "May",
6 => "June",
7 => "July",
8 => "August",
9 => "September",
10 => "October",
11 => "November",
12 => "December",
_ => "",
}
}
fn format_packaging_date(ts: &str) -> Option<String> {
// Expect formats like YYYYMMDDThhmmssZ or with fractional seconds before Z
let clean_ts = if let Some((base, _frac)) = ts.split_once('.') { format!("{}Z", base) } else { ts.to_string() };
let ndt = NaiveDateTime::parse_from_str(&clean_ts, "%Y%m%dT%H%M%SZ").ok()?;
let dt_utc = Utc.from_utc_datetime(&ndt);
let month = month_name(dt_utc.month() as u32);
let day = dt_utc.day();
let year = dt_utc.year();
let hour24 = dt_utc.hour();
let (ampm, hour12) = if hour24 == 0 { ("AM", 12) } else if hour24 < 12 { ("AM", hour24) } else if hour24 == 12 { ("PM", 12) } else { ("PM", hour24 - 12) };
let minute = dt_utc.minute();
let second = dt_utc.second();
Some(format!("{} {:02}, {} at {:02}:{:02}:{:02} {}", month, day, year, hour12, minute, second, ampm))
}
// Sum pkg.size (uncompressed) and pkg.csize (compressed) over all file actions
fn compute_sizes(manifest: &Manifest) -> (u128, u128) {
let mut size: u128 = 0;
let mut csize: u128 = 0;
for file in &manifest.files {
for Property { key, value } in &file.properties {
if key == "pkg.size" {
if let Ok(v) = value.parse::<u128>() { size = size.saturating_add(v); }
} else if key == "pkg.csize" {
if let Ok(v) = value.parse::<u128>() { csize = csize.saturating_add(v); }
}
}
}
(size, csize)
}
fn human_bytes(bytes: u128) -> String {
// Use binary (IEC-like) units for familiarity; format with two decimals for KB and above
const KIB: u128 = 1024;
const MIB: u128 = 1024 * 1024;
const GIB: u128 = 1024 * 1024 * 1024;
const TIB: u128 = 1024 * 1024 * 1024 * 1024;
if bytes < KIB {
return format!("{} B", bytes);
} else if bytes < MIB {
return format!("{:.2} KB", (bytes as f64) / (KIB as f64));
} else if bytes < GIB {
return format!("{:.2} MB", (bytes as f64) / (MIB as f64));
} else if bytes < TIB {
return format!("{:.2} GB", (bytes as f64) / (GIB as f64));
} else {
return format!("{:.2} TB", (bytes as f64) / (TIB as f64));
}
}

View file

@ -142,6 +142,8 @@ async fn test_depot_server() {
let info_text = resp.text().await.unwrap(); let info_text = resp.text().await.unwrap();
assert!(info_text.contains("Name: example")); assert!(info_text.contains("Name: example"));
assert!(info_text.contains("Summary: Test Package")); assert!(info_text.contains("Summary: Test Package"));
// Ensure FMRI format is correct: pkg://<publisher>/<name>@<version>
assert!(info_text.contains("FMRI: pkg://test/example@1.0.0"), "Info FMRI was: {}", info_text);
// 5. Test Publisher v1 // 5. Test Publisher v1
let pub_url = format!("{}/test/publisher/1", base_url); let pub_url = format!("{}/test/publisher/1", base_url);