diff --git a/Cargo.lock b/Cargo.lock index d6c12ad..14eece6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2104,8 +2104,10 @@ dependencies = [ "axum", "axum-server", "bytes", + "chrono", "clap", "dirs", + "flate2", "http-body-util", "hyper", "knuffel", diff --git a/pkg6depotd/Cargo.toml b/pkg6depotd/Cargo.toml index 0e9e9a4..ef7a059 100644 --- a/pkg6depotd/Cargo.toml +++ b/pkg6depotd/Cargo.toml @@ -31,6 +31,8 @@ serde_json = "1.0" dirs = "6" nix = { version = "0.30", features = ["signal", "process", "user", "fs"] } sha1 = "0.10" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +flate2 = "1" # Telemetry tracing = "0.1" diff --git a/pkg6depotd/src/http/handlers/info.rs b/pkg6depotd/src/http/handlers/info.rs index 2c1cc1c..2fad4e7 100644 --- a/pkg6depotd/src/http/handlers/info.rs +++ b/pkg6depotd/src/http/handlers/info.rs @@ -9,6 +9,10 @@ use crate::errors::DepotError; use libips::fmri::Fmri; use std::str::FromStr; 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( State(repo): State>, @@ -30,17 +34,85 @@ pub async fn get_info( out.push_str(&format!("Summary: {}\n", summary)); } out.push_str(&format!("Publisher: {}\n", publisher)); - out.push_str(&format!("Version: {}\n", fmri.version())); - out.push_str(&format!("FMRI: pkg://{}/{}\n", publisher, fmri)); + // Parse version components for Version, Build Release, Branch, and Packaging Date + let version_full = fmri.version(); + let mut version_core = version_full.clone(); + let mut build_release: Option = None; + let mut branch: Option = None; + let mut ts_str: Option = 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:///@ + 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 might be an action (License action) or attribute. - // 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? + // Print actual license text content from repository instead of hash. out.push_str("\nLicense:\n"); + let mut first = true; 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!("\n", digest)); + } + } + } } Ok(( @@ -49,6 +121,43 @@ pub async fn get_info( ).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 { + let path = repo.get_file_path(publisher, digest)?; + let bytes = fs::read(&path).ok()?; + + // Detect gzip magic + let mut data: Vec = 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 { for attr in &manifest.attributes { if attr.key == key { @@ -57,3 +166,74 @@ fn find_attr(manifest: &Manifest, key: &str) -> Option { } 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 { + // 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::() { size = size.saturating_add(v); } + } else if key == "pkg.csize" { + if let Ok(v) = value.parse::() { 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)); + } +} diff --git a/pkg6depotd/tests/integration_tests.rs b/pkg6depotd/tests/integration_tests.rs index c20e5d7..eab02e2 100644 --- a/pkg6depotd/tests/integration_tests.rs +++ b/pkg6depotd/tests/integration_tests.rs @@ -142,6 +142,8 @@ async fn test_depot_server() { let info_text = resp.text().await.unwrap(); assert!(info_text.contains("Name: example")); assert!(info_text.contains("Summary: Test Package")); + // Ensure FMRI format is correct: pkg:///@ + assert!(info_text.contains("FMRI: pkg://test/example@1.0.0"), "Info FMRI was: {}", info_text); // 5. Test Publisher v1 let pub_url = format!("{}/test/publisher/1", base_url);