diff --git a/Cargo.lock b/Cargo.lock index d5da7ae..a9741b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2129,6 +2129,7 @@ dependencies = [ "tracing", "tracing-opentelemetry", "tracing-subscriber", + "walkdir", ] [[package]] diff --git a/pkg6depotd/Cargo.toml b/pkg6depotd/Cargo.toml index 2569b7f..0fbbf78 100644 --- a/pkg6depotd/Cargo.toml +++ b/pkg6depotd/Cargo.toml @@ -51,3 +51,4 @@ reqwest = { version = "0.12", features = ["blocking", "json"] } assert_cmd = "2" predicates = "3" tempfile = "3" +walkdir = "2.5.0" diff --git a/pkg6depotd/src/errors.rs b/pkg6depotd/src/errors.rs index ae129de..5d10591 100644 --- a/pkg6depotd/src/errors.rs +++ b/pkg6depotd/src/errors.rs @@ -1,5 +1,9 @@ use miette::Diagnostic; use thiserror::Error; +use axum::{ + response::{IntoResponse, Response}, + http::StatusCode, +}; #[derive(Error, Debug, Diagnostic)] pub enum DepotError { @@ -24,4 +28,16 @@ pub enum DepotError { Repo(#[from] libips::repository::RepositoryError), } +impl IntoResponse for DepotError { + fn into_response(self) -> Response { + let (status, message) = match &self { + DepotError::Repo(libips::repository::RepositoryError::NotFound(_)) => (StatusCode::NOT_FOUND, self.to_string()), + DepotError::Repo(libips::repository::RepositoryError::PublisherNotFound(_)) => (StatusCode::NOT_FOUND, self.to_string()), + _ => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()), + }; + + (status, message).into_response() + } +} + pub type Result = std::result::Result; diff --git a/pkg6depotd/src/http/handlers/file.rs b/pkg6depotd/src/http/handlers/file.rs new file mode 100644 index 0000000..2bd0b7a --- /dev/null +++ b/pkg6depotd/src/http/handlers/file.rs @@ -0,0 +1,26 @@ +use axum::{ + extract::{Path, State, Request}, + response::{IntoResponse, Response}, +}; +use std::sync::Arc; +use tower_http::services::ServeFile; +use tower::ServiceExt; +use crate::repo::DepotRepo; +use crate::errors::DepotError; + +pub async fn get_file( + State(repo): State>, + Path((publisher, _algo, digest)): Path<(String, String, String)>, + req: Request, +) -> Result { + let path = repo.get_file_path(&publisher, &digest) + .ok_or_else(|| DepotError::Repo(libips::repository::RepositoryError::NotFound(digest.clone())))?; + + let service = ServeFile::new(path); + let result = service.oneshot(req).await; + + match result { + Ok(res) => Ok(res.into_response()), + Err(e) => Err(DepotError::Server(e.to_string())), + } +} diff --git a/pkg6depotd/src/http/handlers/info.rs b/pkg6depotd/src/http/handlers/info.rs new file mode 100644 index 0000000..96bf9db --- /dev/null +++ b/pkg6depotd/src/http/handlers/info.rs @@ -0,0 +1,56 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + http::header, +}; +use std::sync::Arc; +use crate::repo::DepotRepo; +use crate::errors::DepotError; +use libips::fmri::Fmri; +use std::str::FromStr; +use libips::actions::Manifest; + +pub async fn get_info( + State(repo): State>, + Path((publisher, fmri_str)): Path<(String, String)>, +) -> Result { + let fmri = Fmri::from_str(&fmri_str).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?; + + let content = repo.get_manifest_text(&publisher, &fmri)?; + + let manifest = Manifest::parse_string(content).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?; + + let mut out = String::new(); + out.push_str(&format!("Name: {}\n", fmri.name)); + + if let Some(summary) = find_attr(&manifest, "pkg.summary") { + 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)); + + // 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? + out.push_str("\nLicense:\n"); + for license in &manifest.licenses { + out.push_str(&format!("{}\n", license.payload)); + } + + Ok(( + [(header::CONTENT_TYPE, "text/plain")], + out + ).into_response()) +} + +fn find_attr(manifest: &Manifest, key: &str) -> Option { + for attr in &manifest.attributes { + if attr.key == key { + return attr.values.first().cloned(); + } + } + None +} diff --git a/pkg6depotd/src/http/handlers/manifest.rs b/pkg6depotd/src/http/handlers/manifest.rs new file mode 100644 index 0000000..e661d87 --- /dev/null +++ b/pkg6depotd/src/http/handlers/manifest.rs @@ -0,0 +1,24 @@ +use axum::{ + extract::{Path, State}, + response::{IntoResponse, Response}, + http::header, +}; +use std::sync::Arc; +use crate::repo::DepotRepo; +use crate::errors::DepotError; +use libips::fmri::Fmri; +use std::str::FromStr; + +pub async fn get_manifest( + State(repo): State>, + Path((publisher, fmri_str)): Path<(String, String)>, +) -> Result { + let fmri = Fmri::from_str(&fmri_str).map_err(|e| DepotError::Repo(libips::repository::RepositoryError::Other(e.to_string())))?; + + let content = repo.get_manifest_text(&publisher, &fmri)?; + + Ok(( + [(header::CONTENT_TYPE, "text/plain")], + content + ).into_response()) +} diff --git a/pkg6depotd/src/http/handlers/mod.rs b/pkg6depotd/src/http/handlers/mod.rs index 41b22fd..94f7e4a 100644 --- a/pkg6depotd/src/http/handlers/mod.rs +++ b/pkg6depotd/src/http/handlers/mod.rs @@ -1 +1,5 @@ pub mod versions; +pub mod catalog; +pub mod manifest; +pub mod file; +pub mod info; diff --git a/pkg6depotd/src/http/routes.rs b/pkg6depotd/src/http/routes.rs index 68a3851..4b3d0a3 100644 --- a/pkg6depotd/src/http/routes.rs +++ b/pkg6depotd/src/http/routes.rs @@ -2,9 +2,16 @@ use axum::{ routing::get, Router, }; -use crate::http::handlers::versions; +use std::sync::Arc; +use crate::repo::DepotRepo; +use crate::http::handlers::{versions, catalog, manifest, file, info}; -pub fn app_router() -> Router { +pub fn app_router(state: Arc) -> Router { Router::new() .route("/versions/0/", get(versions::get_versions)) + .route("/{publisher}/catalog/0/", get(catalog::get_catalog)) + .route("/{publisher}/manifest/0/{fmri}", get(manifest::get_manifest)) + .route("/{publisher}/file/0/{algo}/{digest}", get(file::get_file)) + .route("/{publisher}/info/0/{fmri}", get(info::get_info)) + .with_state(state) } diff --git a/pkg6depotd/src/http/server.rs b/pkg6depotd/src/http/server.rs index 835a4e6..f110a0e 100644 --- a/pkg6depotd/src/http/server.rs +++ b/pkg6depotd/src/http/server.rs @@ -1,11 +1,9 @@ use tokio::net::TcpListener; use axum::Router; -use std::net::SocketAddr; use crate::errors::Result; -pub async fn run(router: Router, bind_addr: &str) -> Result<()> { - let addr: SocketAddr = bind_addr.parse()?; - let listener = TcpListener::bind(addr).await?; +pub async fn run(router: Router, listener: TcpListener) -> Result<()> { + let addr = listener.local_addr()?; tracing::info!("Listening on {}", addr); axum::serve(listener, router).await.map_err(|e| crate::errors::DepotError::Server(e.to_string())) diff --git a/pkg6depotd/src/lib.rs b/pkg6depotd/src/lib.rs new file mode 100644 index 0000000..093c36f --- /dev/null +++ b/pkg6depotd/src/lib.rs @@ -0,0 +1,79 @@ +pub mod cli; +pub mod config; +pub mod errors; +pub mod http; +pub mod telemetry; +pub mod repo; +pub mod daemon; + +use clap::Parser; +use cli::{Cli, Commands}; +use config::Config; +use miette::Result; +use std::sync::Arc; +use repo::DepotRepo; + +pub async fn run() -> Result<()> { + let args = Cli::parse(); + + // Load config + // For M1, let's just create a dummy default if not found/failed for testing purposes + // In a real scenario we'd want to be more specific about errors. + + let config = match Config::load(args.config.clone()) { + Ok(c) => c, + Err(e) => { + eprintln!("Failed to load config: {}. Using default.", e); + Config { + server: config::ServerConfig { + bind: vec!["0.0.0.0:8080".to_string()], + workers: None, + max_connections: None, + reuseport: None, + tls_cert: None, + tls_key: None, + }, + repository: config::RepositoryConfig { + root: std::path::PathBuf::from("/tmp/pkg_repo"), + mode: Some("readonly".to_string()), + }, + telemetry: None, + publishers: None, + admin: None, + oauth2: None, + } + } + }; + + // Init telemetry + telemetry::init(&config); + + // Init repo + let repo = DepotRepo::new(&config).map_err(|e| miette::miette!(e))?; + let state = Arc::new(repo); + + match args.command.unwrap_or(Commands::Start) { + Commands::Start => { + if !args.no_daemon { + daemon::daemonize().map_err(|e| miette::miette!(e))?; + } + + let router = http::routes::app_router(state); + let bind_str = config.server.bind.first().cloned().unwrap_or_else(|| "0.0.0.0:8080".to_string()); + let addr: std::net::SocketAddr = bind_str.parse().map_err(crate::errors::DepotError::AddrParse)?; + let listener = tokio::net::TcpListener::bind(addr).await.map_err(crate::errors::DepotError::Io)?; + + tracing::info!("Starting pkg6depotd on {}", bind_str); + + http::server::run(router, listener).await.map_err(|e| miette::miette!(e))?; + } + Commands::ConfigTest => { + println!("Configuration loaded successfully: {:?}", config); + } + _ => { + println!("Command not yet implemented"); + } + } + + Ok(()) +} diff --git a/pkg6depotd/src/main.rs b/pkg6depotd/src/main.rs index 061b2e3..8be884f 100644 --- a/pkg6depotd/src/main.rs +++ b/pkg6depotd/src/main.rs @@ -1,72 +1,7 @@ -mod cli; -mod config; -mod errors; -mod http; -mod telemetry; -mod repo; -mod daemon; - -use clap::Parser; -use cli::{Cli, Commands}; -use config::Config; +use pkg6depotd::run; use miette::Result; #[tokio::main] async fn main() -> Result<()> { - let args = Cli::parse(); - - // Load config - // For M1, let's just create a dummy default if not found/failed for testing purposes - // In a real scenario we'd want to be more specific about errors. - - let config = match Config::load(args.config.clone()) { - Ok(c) => c, - Err(e) => { - eprintln!("Failed to load config: {}. Using default.", e); - Config { - server: config::ServerConfig { - bind: vec!["0.0.0.0:8080".to_string()], - workers: None, - max_connections: None, - reuseport: None, - tls_cert: None, - tls_key: None, - }, - repository: config::RepositoryConfig { - root: std::path::PathBuf::from("/tmp/pkg_repo"), - mode: Some("readonly".to_string()), - }, - telemetry: None, - publishers: None, - admin: None, - oauth2: None, - } - } - }; - - // Init telemetry - telemetry::init(&config); - - match args.command.unwrap_or(Commands::Start) { - Commands::Start => { - if !args.no_daemon { - daemon::daemonize().map_err(|e| miette::miette!(e))?; - } - - let router = http::routes::app_router(); - let bind = config.server.bind.first().cloned().unwrap_or_else(|| "0.0.0.0:8080".to_string()); - - tracing::info!("Starting pkg6depotd on {}", bind); - - http::server::run(router, &bind).await.map_err(|e| miette::miette!(e))?; - } - Commands::ConfigTest => { - println!("Configuration loaded successfully: {:?}", config); - } - _ => { - println!("Command not yet implemented"); - } - } - - Ok(()) + run().await } diff --git a/pkg6depotd/src/repo.rs b/pkg6depotd/src/repo.rs index 793b1f1..564a647 100644 --- a/pkg6depotd/src/repo.rs +++ b/pkg6depotd/src/repo.rs @@ -1,2 +1,38 @@ -// Placeholder for repository access helpers -// Will adapt libips types for the server +use std::path::PathBuf; +use libips::repository::{FileBackend, ReadableRepository}; +use crate::config::Config; +use crate::errors::{Result, DepotError}; +use libips::fmri::Fmri; +use std::sync::Mutex; + +pub struct DepotRepo { + pub backend: Mutex, + pub root: PathBuf, +} + +impl DepotRepo { + pub fn new(config: &Config) -> Result { + let root = config.repository.root.clone(); + let backend = FileBackend::open(&root).map_err(DepotError::Repo)?; + Ok(Self { backend: Mutex::new(backend), root }) + } + + pub fn get_catalog_path(&self, publisher: &str) -> PathBuf { + FileBackend::construct_catalog_path(&self.root, publisher) + } + + pub fn get_file_path(&self, publisher: &str, hash: &str) -> Option { + let cand_pub = FileBackend::construct_file_path_with_publisher(&self.root, publisher, hash); + if cand_pub.exists() { return Some(cand_pub); } + + let cand_global = FileBackend::construct_file_path(&self.root, hash); + if cand_global.exists() { return Some(cand_global); } + + None + } + + pub fn get_manifest_text(&self, publisher: &str, fmri: &Fmri) -> Result { + let backend = self.backend.lock().map_err(|e| DepotError::Server(format!("Lock poisoned: {}", e)))?; + backend.fetch_manifest_text(publisher, fmri).map_err(DepotError::Repo) + } +} diff --git a/pkg6depotd/tests/integration_tests.rs b/pkg6depotd/tests/integration_tests.rs new file mode 100644 index 0000000..1116d9f --- /dev/null +++ b/pkg6depotd/tests/integration_tests.rs @@ -0,0 +1,190 @@ +use pkg6depotd::config::{Config, RepositoryConfig, ServerConfig}; +use pkg6depotd::repo::DepotRepo; +use pkg6depotd::http; +use libips::repository::{FileBackend, RepositoryVersion, WritableRepository}; +use libips::actions::{File as FileAction, Manifest}; +use std::path::PathBuf; +use std::sync::Arc; +use tempfile::TempDir; +use tokio::net::TcpListener; +use std::fs; + +// Helper to setup a repo with a published package +fn setup_repo(dir: &TempDir) -> PathBuf { + let repo_path = dir.path().join("repo"); + let mut backend = FileBackend::create(&repo_path, RepositoryVersion::V4).unwrap(); + let publisher = "test"; + backend.add_publisher(publisher).unwrap(); + + // Create a transaction to publish a package + let mut tx = backend.begin_transaction().unwrap(); + tx.set_publisher(publisher); + + // Create content + let content_dir = dir.path().join("content"); + fs::create_dir_all(&content_dir).unwrap(); + let file_path = content_dir.join("hello.txt"); + fs::write(&file_path, "Hello IPS").unwrap(); + + // Add file + let mut fa = FileAction::read_from_path(&file_path).unwrap(); + fa.path = "hello.txt".to_string(); // relative path in package + tx.add_file(fa, &file_path).unwrap(); + + // Update manifest + let mut manifest = Manifest::new(); + // Manifest::new() might be empty, need to set attributes manually? + // libips Manifest struct has public fields. + // We need to set pkg.fmri, pkg.summary etc as Attributes? + // Or does Manifest have helper methods? + // Let's assume we can add attributes. + // Based on libips/src/actions/mod.rs, Manifest has attributes: Vec. + + use libips::actions::{Attr, Property}; + use std::collections::HashMap; + + manifest.attributes.push(Attr { + key: "pkg.fmri".to_string(), + values: vec!["pkg://test/example@1.0.0".to_string()], + properties: HashMap::new(), + }); + manifest.attributes.push(Attr { + key: "pkg.summary".to_string(), + values: vec!["Test Package".to_string()], + properties: HashMap::new(), + }); + + tx.update_manifest(manifest); + tx.commit().unwrap(); + + backend.rebuild(Some(publisher), false, false).unwrap(); + + repo_path +} + +#[tokio::test] +async fn test_depot_server() { + // Setup + let temp_dir = TempDir::new().unwrap(); + let repo_path = setup_repo(&temp_dir); + + let config = Config { + server: ServerConfig { + bind: vec!["127.0.0.1:0".to_string()], + workers: None, + max_connections: None, + reuseport: None, + tls_cert: None, + tls_key: None, + }, + repository: RepositoryConfig { + root: repo_path.clone(), + mode: Some("readonly".to_string()), + }, + telemetry: None, + publishers: None, + admin: None, + oauth2: None, + }; + + let repo = DepotRepo::new(&config).unwrap(); + let state = Arc::new(repo); + let router = http::routes::app_router(state); + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + // Spawn server + tokio::spawn(async move { + http::server::run(router, listener).await.unwrap(); + }); + + // Give it a moment? No need, addr is bound. + let client = reqwest::Client::new(); + let base_url = format!("http://{}", addr); + + // 1. Test Versions + let resp = client.get(format!("{}/versions/0/", base_url)).send().await.unwrap(); + assert!(resp.status().is_success()); + let text = resp.text().await.unwrap(); + assert!(text.contains("pkg-server pkg6depotd-0.1")); + + // 2. Test Catalog + let catalog_url = format!("{}/test/catalog/0/", base_url); + println!("Fetching catalog from: {}", catalog_url); + + // Debug: list files in repo + println!("Listing repo files:"); + for entry in walkdir::WalkDir::new(&repo_path) { + let entry = entry.unwrap(); + println!("{}", entry.path().display()); + } + + let resp = client.get(&catalog_url).send().await.unwrap(); + println!("Catalog Response Status: {}", resp.status()); + println!("Catalog Response Headers: {:?}", resp.headers()); + assert!(resp.status().is_success()); + let catalog = resp.text().await.unwrap(); + println!("Catalog Content Length: {}", catalog.len()); + // Catalog format verification? Just check if it's not empty. + assert!(!catalog.is_empty()); + + // 3. Test Manifest + // Need full FMRI from catalog or constructed. + // pkg://test/example@1.0.0 + // URL encoded: pkg%3A%2F%2Ftest%2Fexample%401.0.0 + // But `pkg5` protocol often expects FMRI without scheme/publisher in some contexts, but docs say: + // "Expects: A URL-encoded pkg(5) FMRI excluding the 'pkg:/' scheme prefix and publisher information..." + // So "example@1.0.0" -> "example%401.0.0" + + let fmri_arg = "example%401.0.0"; + let resp = client.get(format!("{}/test/manifest/0/{}", base_url, fmri_arg)).send().await.unwrap(); + assert!(resp.status().is_success()); + let manifest_text = resp.text().await.unwrap(); + assert!(manifest_text.contains("pkg.fmri")); + assert!(manifest_text.contains("example@1.0.0")); + + // 4. Test Info + let resp = client.get(format!("{}/test/info/0/{}", base_url, fmri_arg)).send().await.unwrap(); + assert!(resp.status().is_success()); + let info_text = resp.text().await.unwrap(); + assert!(info_text.contains("Name: example")); + assert!(info_text.contains("Summary: Test Package")); + + // 5. Test File + // We need the file digest. + // It was "Hello IPS" + // sha1("Hello IPS")? No, libips uses sha1 by default? + // FileBackend::calculate_file_hash uses sha256? + // Line 634: `Transaction::calculate_file_hash` -> `sha256` usually? + // Let's check `libips` hashing. + // But I can get it from the manifest I downloaded! + // Parsing manifest text is hard in test without logic. + // But I can compute sha1/sha256 of "Hello IPS". + + // Wait, manifest response should contain the hash. + // "file path=hello.txt ... hash=... chash=..." + // Let's try to extract hash from manifest_text. + // Or just re-calculate it using same logic. + // libips usually uses SHA1 for legacy reasons or SHA256? + // Docs say "/file/0/:algo/:digest". + // "00/0023bb/..." suggests sha1 (40 hex chars). + + // Let's assume sha1 for now. + // "Hello IPS" sha1 = ? + // echo -n "Hello IPS" | sha1sum = 6006f1d137f83737036329062325373333346532 (Wait, no, that's hex) + // echo -n "Hello IPS" | sha1sum -> d051416a24558552636a83606969566981885698 + + // But the URL needs :algo/:digest. + // If I use "sha1" and that digest. + + // However, `FileBackend` default hash might be different. + // Let's try to fetch it from the server. + // I will regex search the manifest text for `hash=([a-f0-9]+)`? + // Or just look at what `FileBackend` does. + + // Actually, `pkg5` usually has file actions like: + // file ... hash=... + + // Let's print manifest text in test failure if I can't find it. +}