use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{Read, Seek, SeekFrom, Write}; use std::net::{TcpListener, TcpStream}; use std::path::PathBuf; use std::process::Command; use std::sync::{Arc, Mutex}; use rpki::blob_store::ExternalRepoBytesDb; use rpki::query::report_stream::{ObjectFilter, ObjectScope}; use rpki::query::vrp::VrpLookup; use rpki::query_db::{ ChainEdgeRecord, ExportJobRecord, ObjectInstanceRecord, ObjectUriIndexRecord, QueryDb, QueryDbError, ValidationExplainRecord, }; use serde::Serialize; use serde_json::{Value, json}; const EXPORT_PAGE_LIMIT: usize = 1000; const EXPORT_OBJECT_SET_MAX: usize = 10_000; type ExportJobStore = Arc>>; #[derive(Clone, Debug, PartialEq, Eq)] struct Args { query_db: PathBuf, repo_bytes_db: Option, export_root: PathBuf, listen: String, watch_run_root: Option, watch_interval_secs: u64, projection_entry_limit: usize, watch_min_run_seq: Option, watch_backfill: bool, indexer_bin: Option, retain_indexed_runs: usize, } fn usage() -> &'static str { "Usage: rpki_query_service --query-db [--repo-bytes-db ] [--export-root ] [--listen ] [--watch-run-root ] [--watch-interval-secs ] [--watch-min-run-seq ] [--watch-backfill] [--projection-entry-limit ] [--indexer-bin ] [--retain-indexed-runs ]" } fn parse_args(argv: &[String]) -> Result { let mut query_db = None; let mut repo_bytes_db = None; let mut export_root = None; let mut listen = "127.0.0.1:9560".to_string(); let mut watch_run_root = None; let mut watch_interval_secs = 10u64; let mut projection_entry_limit = 50usize; let mut watch_min_run_seq = None; let mut watch_backfill = false; let mut indexer_bin = None; let mut retain_indexed_runs = 10usize; let mut index = 1usize; while index < argv.len() { match argv[index].as_str() { "--query-db" => { index += 1; query_db = Some(PathBuf::from(value_at(argv, index, "--query-db")?)); } "--repo-bytes-db" => { index += 1; repo_bytes_db = Some(PathBuf::from(value_at(argv, index, "--repo-bytes-db")?)); } "--export-root" => { index += 1; export_root = Some(PathBuf::from(value_at(argv, index, "--export-root")?)); } "--listen" => { index += 1; listen = value_at(argv, index, "--listen")?.to_string(); } "--watch-run-root" => { index += 1; watch_run_root = Some(PathBuf::from(value_at(argv, index, "--watch-run-root")?)); } "--watch-interval-secs" => { index += 1; let raw = value_at(argv, index, "--watch-interval-secs")?; watch_interval_secs = raw .parse::() .map_err(|_| format!("invalid --watch-interval-secs: {raw}"))?; } "--projection-entry-limit" => { index += 1; let raw = value_at(argv, index, "--projection-entry-limit")?; projection_entry_limit = raw .parse::() .map_err(|_| format!("invalid --projection-entry-limit: {raw}"))?; } "--watch-min-run-seq" => { index += 1; let raw = value_at(argv, index, "--watch-min-run-seq")?; watch_min_run_seq = Some( raw.parse::() .map_err(|_| format!("invalid --watch-min-run-seq: {raw}"))?, ); } "--indexer-bin" => { index += 1; indexer_bin = Some(PathBuf::from(value_at(argv, index, "--indexer-bin")?)); } "--watch-backfill" => { watch_backfill = true; } "--retain-indexed-runs" => { index += 1; let raw = value_at(argv, index, "--retain-indexed-runs")?; retain_indexed_runs = raw .parse::() .map_err(|_| format!("invalid --retain-indexed-runs: {raw}"))?; } "-h" | "--help" => return Err(usage().to_string()), other => return Err(format!("unknown argument: {other}\n{}", usage())), } index += 1; } let query_db = query_db.ok_or_else(|| format!("--query-db is required\n{}", usage()))?; let default_export_root = query_db.with_extension("exports"); Ok(Args { query_db, repo_bytes_db, export_root: export_root.unwrap_or(default_export_root), listen, watch_run_root, watch_interval_secs, projection_entry_limit, watch_min_run_seq, watch_backfill, indexer_bin, retain_indexed_runs, }) } fn value_at<'a>(argv: &'a [String], index: usize, flag: &str) -> Result<&'a str, String> { argv.get(index) .map(String::as_str) .ok_or_else(|| format!("{flag} requires a value")) } fn main() { if let Err(err) = real_main() { eprintln!("{err}"); std::process::exit(1); } } fn real_main() -> Result<(), String> { let args = parse_args(&std::env::args().collect::>())?; let db = Arc::new(open_query_db_for_service(&args).map_err(|e| e.to_string())?); let export_jobs = Arc::new(Mutex::new(BTreeMap::new())); let repo_bytes = match args.repo_bytes_db.as_ref() { Some(path) => Some(Arc::new( ExternalRepoBytesDb::open_read_only(path).map_err(|e| e.to_string())?, )), None => None, }; if let Some(run_root) = args.watch_run_root.clone() { let effective_watch_min_run_seq = resolve_effective_watch_min_run_seq( &run_root, args.watch_min_run_seq, args.watch_backfill, )?; spawn_indexer_watcher( Arc::clone(&db), args.query_db.clone(), resolve_indexer_bin(args.indexer_bin.clone())?, run_root, args.projection_entry_limit, args.watch_interval_secs, effective_watch_min_run_seq, args.retain_indexed_runs, ); } let listener = TcpListener::bind(&args.listen) .map_err(|e| format!("bind failed: {}: {e}", args.listen))?; eprintln!("rpki_query_service listening on {}", args.listen); for stream in listener.incoming() { match stream { Ok(stream) => { let db = Arc::clone(&db); let repo_bytes = repo_bytes.clone(); let export_jobs = Arc::clone(&export_jobs); let export_root = args.export_root.clone(); std::thread::spawn(move || { let _ = handle_client(stream, db, repo_bytes, export_jobs, export_root); }); } Err(err) => eprintln!("accept failed: {err}"), } } Ok(()) } fn open_query_db_for_service(args: &Args) -> Result { if args.watch_run_root.is_some() { drop(QueryDb::open(&args.query_db)?); let secondary_path = args.query_db.with_extension("secondary"); if let Some(parent) = secondary_path.parent() { fs::create_dir_all(parent)?; } QueryDb::open_secondary(&args.query_db, &secondary_path) } else { QueryDb::open(&args.query_db) } } fn resolve_indexer_bin(configured: Option) -> Result { if let Some(path) = configured { return Ok(path); } let current_exe = std::env::current_exe().map_err(|e| e.to_string())?; let Some(parent) = current_exe.parent() else { return Err("cannot resolve current executable directory".to_string()); }; Ok(parent.join("rpki_query_indexer")) } fn resolve_effective_watch_min_run_seq( run_root: &std::path::Path, configured_min_run_seq: Option, watch_backfill: bool, ) -> Result, String> { if configured_min_run_seq.is_some() || watch_backfill { return Ok(configured_min_run_seq); } latest_completed_run_seq(run_root).map(|seq| Some(seq.saturating_add(1))) } fn spawn_indexer_watcher( db: Arc, query_db_path: PathBuf, indexer_bin: PathBuf, run_root: PathBuf, projection_entry_limit: usize, interval_secs: u64, min_run_seq: Option, retain_indexed_runs: usize, ) { std::thread::spawn(move || { loop { match discover_next_unindexed_run(&db, &run_root, min_run_seq) { Ok(Some(run_dir)) => { match run_indexer_child( &indexer_bin, &query_db_path, &run_dir, projection_entry_limit, retain_indexed_runs, ) { Ok(message) => { let _ = db.try_catch_up_with_primary(); eprintln!("query watcher indexed {}: {message}", run_dir.display()); } Err(err) => eprintln!( "query watcher index failed for {}: {err}", run_dir.display() ), } } Ok(None) => { let _ = db.try_catch_up_with_primary(); } Err(err) => eprintln!("query watcher index failed: {err}"), } std::thread::sleep(std::time::Duration::from_secs(interval_secs.max(1))); } }); } fn latest_completed_run_seq(run_root: &std::path::Path) -> Result { let runs_root = if run_root.join("runs").is_dir() { run_root.join("runs") } else { run_root.to_path_buf() }; let mut latest = 0u64; if !runs_root.exists() { return Ok(latest); } for entry in fs::read_dir(&runs_root).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if !path.is_dir() || !path.join("report.json").exists() { continue; } let Some(seq) = run_seq_from_path(&path) else { continue; }; if !run_status_is_success_or_unknown(&path)? { continue; } latest = latest.max(seq); } Ok(latest) } fn discover_next_unindexed_run( db: &QueryDb, run_root: &std::path::Path, min_run_seq: Option, ) -> Result, String> { db.try_catch_up_with_primary().map_err(|e| e.to_string())?; let runs_root = if run_root.join("runs").is_dir() { run_root.join("runs") } else { run_root.to_path_buf() }; let mut candidates = Vec::new(); let latest_ready_seq = match db.latest_ready_run().map_err(|e| e.to_string())? { Some(run_id) => db .get_run(&run_id) .map_err(|e| e.to_string())? .and_then(|run| run.run_seq), None => None, }; let min_candidate_seq = min_run_seq.unwrap_or(0).max( latest_ready_seq .map(|seq| seq.saturating_add(1)) .unwrap_or(0), ); for entry in fs::read_dir(&runs_root).map_err(|e| e.to_string())? { let entry = entry.map_err(|e| e.to_string())?; let path = entry.path(); if !path.is_dir() || !path.join("report.json").exists() { continue; } let Some(seq) = run_seq_from_path(&path) else { continue; }; if seq < min_candidate_seq { continue; } let run_id = path .file_name() .and_then(|name| name.to_str()) .unwrap_or_default() .to_string(); if db.get_run(&run_id).map_err(|e| e.to_string())?.is_some() { continue; } if !run_status_is_success_or_unknown(&path)? { continue; } candidates.push((seq, path)); } candidates.sort_by_key(|(seq, _)| *seq); Ok(candidates.into_iter().map(|(_, path)| path).next()) } fn run_indexer_child( indexer_bin: &std::path::Path, query_db_path: &std::path::Path, run_dir: &std::path::Path, projection_entry_limit: usize, retain_indexed_runs: usize, ) -> Result { let output = Command::new(indexer_bin) .arg("--query-db") .arg(query_db_path) .arg("--run-dir") .arg(run_dir) .arg("--projection-entry-limit") .arg(projection_entry_limit.to_string()) .arg("--retain-indexed-runs") .arg(retain_indexed_runs.to_string()) .arg("--dump-summary") .output() .map_err(|e| format!("spawn {} failed: {e}", indexer_bin.display()))?; if !output.status.success() { return Err(format!( "exit={} stdout={} stderr={}", output.status, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) )); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn run_seq_from_path(path: &std::path::Path) -> Option { path.file_name() .and_then(|name| name.to_str()) .and_then(|name| name.strip_prefix("run_")) .and_then(|value| value.parse::().ok()) } fn run_status_is_success_or_unknown(run_dir: &std::path::Path) -> Result { let summary_path = run_dir.join("run-summary.json"); if !summary_path.exists() { return Ok(true); } let summary: Value = serde_json::from_slice( &fs::read(&summary_path) .map_err(|e| format!("read run summary failed: {}: {e}", summary_path.display()))?, ) .map_err(|e| format!("parse run summary failed: {}: {e}", summary_path.display()))?; Ok(summary .get("status") .and_then(Value::as_str) .map_or(true, |status| status == "success")) } fn handle_client( mut stream: TcpStream, db: Arc, repo_bytes: Option>, export_jobs: ExportJobStore, export_root: PathBuf, ) -> Result<(), String> { let Some(request) = read_http_request(&mut stream)? else { return Ok(()); }; if let Err(err) = db.try_catch_up_with_primary() { eprintln!("query db catch-up failed: {err}"); } let response = if request.method != "GET" { if request.method == "POST" { match route_post_request( Arc::clone(&db), repo_bytes.clone(), Arc::clone(&export_jobs), export_root, &request.target, &request.body, ) { Ok(value) => json_response(200, &value), Err(ApiError { status, message }) => { json_response(status, &json!({"error": message})) } } } else { json_response(405, &json!({"error":"method_not_allowed"})) } } else if let Some(raw_result) = route_raw_request(&db, repo_bytes.as_deref(), &export_jobs, &request.target) { match raw_result { Ok(response) => response, Err(ApiError { status, message }) => json_response(status, &json!({"error": message})), } } else { match route_request(&db, repo_bytes.as_deref(), &export_jobs, &request.target) { Ok(value) => json_response(200, &value), Err(ApiError { status, message }) => json_response(status, &json!({"error": message})), } }; stream.write_all(&response).map_err(|e| e.to_string())?; Ok(()) } struct HttpRequest { method: String, target: String, body: Vec, } fn read_http_request(stream: &mut TcpStream) -> Result, String> { let mut buffer = Vec::new(); let mut chunk = [0u8; 8192]; let read = stream.read(&mut chunk).map_err(|e| e.to_string())?; if read == 0 { return Ok(None); } buffer.extend_from_slice(&chunk[..read]); let header_end = loop { if let Some(pos) = find_header_end(&buffer) { break pos; } let read = stream.read(&mut chunk).map_err(|e| e.to_string())?; if read == 0 { return Err("incomplete HTTP headers".to_string()); } buffer.extend_from_slice(&chunk[..read]); }; let headers = String::from_utf8_lossy(&buffer[..header_end]); let Some(first_line) = headers.lines().next() else { return Ok(None); }; let mut parts = first_line.split_whitespace(); let method = parts.next().unwrap_or("").to_string(); let target = parts.next().unwrap_or("").to_string(); let content_length = headers .lines() .filter_map(|line| line.split_once(':')) .find(|(name, _)| name.eq_ignore_ascii_case("content-length")) .and_then(|(_, value)| value.trim().parse::().ok()) .unwrap_or(0); let body_start = header_end + 4; while buffer.len().saturating_sub(body_start) < content_length { let read = stream.read(&mut chunk).map_err(|e| e.to_string())?; if read == 0 { break; } buffer.extend_from_slice(&chunk[..read]); } let body_end = body_start + content_length.min(buffer.len().saturating_sub(body_start)); Ok(Some(HttpRequest { method, target, body: buffer[body_start..body_end].to_vec(), })) } fn find_header_end(bytes: &[u8]) -> Option { bytes.windows(4).position(|window| window == b"\r\n\r\n") } #[derive(Debug)] struct ApiError { status: u16, message: String, } impl ApiError { fn new(status: u16, message: impl Into) -> Self { Self { status, message: message.into(), } } } impl From for ApiError { fn from(value: QueryDbError) -> Self { Self::new(500, value.to_string()) } } fn route_request( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, export_jobs: &ExportJobStore, target: &str, ) -> Result { let (path, query) = split_target(target); let query = parse_query(query); let path = path.trim_end_matches('/'); let segments = path .trim_start_matches("/api/v1") .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect::>(); if segments.is_empty() { return Ok(json!({"data":{"service":"rpki_query_service","version":1}})); } match segments.as_slice() { ["healthz"] => { let latest_ready_run = db.latest_ready_run()?; data_response( &json!({ "status": "ok", "service": "rpki_query_service", "version": 1, "latestReadyRun": latest_ready_run, }), None, ) } ["runs"] => page_response(db.list_runs(limit(&query), cursor(&query))?, None), ["latest_run"] => { let run_id = db .latest_ready_run()? .ok_or_else(|| ApiError::new(404, "latest run not found"))?; let run = db .get_run(&run_id)? .ok_or_else(|| ApiError::new(404, "latest run record not found"))?; data_response(&run, Some(run_id)) } ["runs", "latest"] => { let run_id = db .latest_ready_run()? .ok_or_else(|| ApiError::new(404, "latest run not found"))?; let run = db .get_run(&run_id)? .ok_or_else(|| ApiError::new(404, "latest run record not found"))?; data_response(&run, Some(run_id)) } ["runs", raw_run_id] => { let run_id = resolve_run(db, raw_run_id)?; let run = db .get_run(&run_id)? .ok_or_else(|| ApiError::new(404, "run not found"))?; data_response(&run, Some(run_id)) } ["runs", raw_run_id, "artifacts"] => { let run_id = resolve_run(db, raw_run_id)?; let run = db .get_run(&run_id)? .ok_or_else(|| ApiError::new(404, "run not found"))?; data_response(&run.artifact_paths, Some(run_id)) } ["runs", raw_run_id, "summary"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "overview", "counts")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "vrps", "lookup"] => { let run_id = resolve_run(db, raw_run_id)?; let lookup = vrp_lookup_from_query(&query)?; let asn = query .get("asn") .map(|value| { value.parse::().map_err(|_| { ApiError::new(400, format!("invalid ASN query parameter: {value}")) }) }) .transpose()?; page_response( db.lookup_vrps(&run_id, &lookup, asn, limit(&query), cursor(&query))?, Some(run_id), ) } ["runs", raw_run_id, "repos"] => { let run_id = resolve_run(db, raw_run_id)?; page_response( db.list_repos(&run_id, limit(&query), cursor(&query))?, Some(run_id), ) } ["runs", raw_run_id, "repos", repo_id] => { let run_id = resolve_run(db, raw_run_id)?; let repo = db .get_repo(&run_id, repo_id)? .ok_or_else(|| ApiError::new(404, "repo not found"))?; data_response(&repo, Some(run_id)) } ["runs", raw_run_id, "repos", repo_id, "stats"] => { let run_id = resolve_run(db, raw_run_id)?; let repo = db .get_repo(&run_id, repo_id)? .ok_or_else(|| ApiError::new(404, "repo not found"))?; data_response( &json!({ "publicationPoints": repo.publication_points, "objects": repo.objects, "rejectedObjects": repo.rejected_objects, "syncDurationMsTotal": repo.sync_duration_ms_total, "phases": repo.phases, "terminalStates": repo.terminal_states, }), Some(run_id), ) } ["runs", raw_run_id, "repos", repo_id, "publication-points"] => { let run_id = resolve_run(db, raw_run_id)?; page_response( db.list_publication_points_for_repo( &run_id, repo_id, limit(&query), cursor(&query), )?, Some(run_id), ) } ["runs", raw_run_id, "repos", repo_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; let filter = object_filter_from_query(&query)?; page_response( db.list_objects_filtered( &run_id, ObjectScope::Repo(repo_id.to_string()), &filter, limit(&query), cursor(&query), )?, Some(run_id), ) } ["runs", raw_run_id, "publication-points"] => { let run_id = resolve_run(db, raw_run_id)?; page_response( db.list_publication_points(&run_id, limit(&query), cursor(&query))?, Some(run_id), ) } ["runs", raw_run_id, "publication-points", pp_id] => { let run_id = resolve_run(db, raw_run_id)?; let pp = db .get_publication_point(&run_id, pp_id)? .ok_or_else(|| ApiError::new(404, "publication point not found"))?; data_response(&pp, Some(run_id)) } ["runs", raw_run_id, "publication-points", pp_id, "stats"] => { let run_id = resolve_run(db, raw_run_id)?; let pp = db .get_publication_point(&run_id, pp_id)? .ok_or_else(|| ApiError::new(404, "publication point not found"))?; data_response( &json!({ "objects": pp.objects, "rejectedObjects": pp.rejected_objects, "warnings": pp.warnings, "repoSyncSource": pp.repo_sync_source, "repoSyncPhase": pp.repo_sync_phase, "repoSyncDurationMs": pp.repo_sync_duration_ms, "repoTerminalState": pp.repo_terminal_state, }), Some(run_id), ) } ["runs", raw_run_id, "publication-points", pp_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; let filter = object_filter_from_query(&query)?; page_response( db.list_objects_filtered( &run_id, ObjectScope::PublicationPoint(pp_id.to_string()), &filter, limit(&query), cursor(&query), )?, Some(run_id), ) } ["runs", raw_run_id, "objects"] => { let run_id = resolve_run(db, raw_run_id)?; let filter = object_filter_from_query(&query)?; page_response( db.list_objects_filtered( &run_id, ObjectScope::All, &filter, limit(&query), cursor(&query), )?, Some(run_id), ) } ["runs", raw_run_id, "issues"] => { let run_id = resolve_run(db, raw_run_id)?; let mut filter = object_filter_from_query(&query)?; filter.issues_only = true; filter.repo_id = query.get("repoId").cloned(); filter.pp_id = query.get("ppId").cloned(); page_response( db.list_objects_filtered( &run_id, ObjectScope::All, &filter, limit(&query), cursor(&query), )?, Some(run_id), ) } ["runs", raw_run_id, "search"] => { let run_id = resolve_run(db, raw_run_id)?; let q = query .get("q") .map(String::as_str) .unwrap_or("") .trim() .to_string(); if q.is_empty() { return Err(ApiError::new(400, "q query parameter is required")); } let limit = search_limit(&query); data_response(&search_run(db, &run_id, &q, limit)?, Some(run_id)) } ["runs", raw_run_id, "objects", "by-uri"] => { let run_id = resolve_run(db, raw_run_id)?; let uri = query .get("uri") .ok_or_else(|| ApiError::new(400, "uri query parameter is required"))?; let object = db .get_object_by_uri(&run_id, uri)? .ok_or_else(|| ApiError::new(404, "object uri not found"))?; data_response(&object, Some(run_id)) } ["runs", raw_run_id, "objects", object_instance_id] => { let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; data_response(&object, Some(run_id)) } ["runs", raw_run_id, "objects", object_instance_id, "parsed"] => { let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; let projection = projection_for_explain(db, repo_bytes, &object)?; data_response(&projection, Some(run_id)) } [ "runs", raw_run_id, "objects", object_instance_id, "validation", ] => { let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; data_response(&validation_summary(&object), Some(run_id)) } [ "runs", raw_run_id, "objects", object_instance_id, "validation", "file", ] => { let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; data_response(&file_validation_summary(&object), Some(run_id)) } [ "runs", raw_run_id, "objects", object_instance_id, "validation", "chain", ] => { let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; data_response(&chain_validation_summary(&object), Some(run_id)) } ["runs", raw_run_id, "objects", object_instance_id, "chain"] => { let run_id = resolve_run(db, raw_run_id)?; let chain_edges = chain_edges_for_object(db, repo_bytes, &run_id, object_instance_id)?; data_response(&chain_edges, Some(run_id)) } [ "runs", raw_run_id, "objects", object_instance_id, "parsed", "manifest-files", ] => { let run_id = resolve_run(db, raw_run_id)?; projection_array_page( db, repo_bytes, &run_id, object_instance_id, &["object", "manifest", "fileList", "entries"], "manifest-files", limit(&query), offset_cursor(&query), ) } [ "runs", raw_run_id, "objects", object_instance_id, "parsed", "revoked-certs", ] => { let run_id = resolve_run(db, raw_run_id)?; projection_array_page( db, repo_bytes, &run_id, object_instance_id, &["object", "revokedCertificates", "entries"], "revoked-certs", limit(&query), offset_cursor(&query), ) } ["runs", raw_run_id, "exports"] => { let run_id = resolve_run(db, raw_run_id)?; let limit = exports_limit(&query); page_response( rpki::query_db::QueryPage { data: list_export_jobs(export_jobs, &run_id, limit)?, next_cursor: None, limit, }, Some(run_id), ) } ["runs", raw_run_id, "exports", job_id] => { let run_id = resolve_run(db, raw_run_id)?; let job = get_export_job(db, export_jobs, &run_id, job_id)? .ok_or_else(|| ApiError::new(404, "export job not found"))?; data_response(&job, Some(run_id)) } ["objects", sha256] => { let projection = global_projection_for_hash(db, repo_bytes, sha256)? .ok_or_else(|| ApiError::new(404, "object projection not found"))?; data_response(&projection, None) } ["runs", raw_run_id, "stats", "overview"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "overview", "counts")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", "repos"] => { let run_id = resolve_run(db, raw_run_id)?; page_response( db.list_repos(&run_id, limit(&query), cursor(&query))?, Some(run_id), ) } ["runs", raw_run_id, "stats", "publication-points"] => { let run_id = resolve_run(db, raw_run_id)?; page_response( db.list_publication_points(&run_id, limit(&query), cursor(&query))?, Some(run_id), ) } ["runs", raw_run_id, "stats", "object-types"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "objects", "by_type")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", "validation"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "validation", "by_result")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", "reasons"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "validation", "reasons")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", "downloads"] => { let run_id = resolve_run(db, raw_run_id)?; let stat = db.get_stat(&run_id, "downloads", "summary")?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", "validation-events"] => { let run_id = resolve_run(db, raw_run_id)?; let name = query.get("name").map(String::as_str).unwrap_or("by_type"); let stat = db.get_stat(&run_id, "validation_events", name)?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } ["runs", raw_run_id, "stats", scope] => { let run_id = resolve_run(db, raw_run_id)?; let name = query.get("name").map(String::as_str).unwrap_or("counts"); let stat = db.get_stat(&run_id, scope, name)?; data_response( &stat.map(|s| s.value).unwrap_or_else(|| json!({})), Some(run_id), ) } _ => Err(ApiError::new(404, "not found")), } } fn route_post_request( db: Arc, repo_bytes: Option>, export_jobs: ExportJobStore, export_root: PathBuf, target: &str, body: &[u8], ) -> Result { let (path, _) = split_target(target); let path = path.trim_end_matches('/'); let segments = path .trim_start_matches("/api/v1") .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect::>(); match segments.as_slice() { ["runs", raw_run_id, "exports"] => { let repo_bytes = repo_bytes .ok_or_else(|| ApiError::new(400, "repo-bytes db is required for export jobs"))?; let run_id = resolve_run(&db, raw_run_id)?; let request: Value = if body.is_empty() { json!({}) } else { serde_json::from_slice(body) .map_err(|err| ApiError::new(400, format!("invalid JSON body: {err}")))? }; let scope = request .get("scope") .and_then(Value::as_str) .unwrap_or("repo") .to_string(); let repo_id = request .get("repo_id") .or_else(|| request.get("repoId")) .and_then(Value::as_str) .map(str::to_string); let pp_id = request .get("pp_id") .or_else(|| request.get("ppId")) .and_then(Value::as_str) .map(str::to_string); let object_instance_ids = request .get("object_instance_ids") .or_else(|| request.get("objectInstanceIds")) .and_then(Value::as_array) .map(|items| { items .iter() .filter_map(Value::as_str) .map(str::to_string) .collect::>() }) .unwrap_or_default(); validate_export_request(&scope, repo_id.as_deref(), pp_id.as_deref())?; let job_id = uuid::Uuid::new_v4().to_string(); let output_path = export_root.join(&run_id).join(format!("{job_id}.tar")); let job = ExportJobRecord { schema_version: 1, job_id: job_id.clone(), run_id: run_id.clone(), scope, repo_id, pp_id, status: "running".to_string(), created_at: now_rfc3339(), finished_at: None, output_path: Some(output_path.display().to_string()), object_count: 0, bytes_written: 0, error: None, }; put_export_job(db.as_ref(), &export_jobs, &job)?; let worker_db = Arc::clone(&db); let worker_export_jobs = Arc::clone(&export_jobs); let failed_job_template = job.clone(); let response_run_id = run_id.clone(); let response_job = job.clone(); std::thread::spawn(move || { let final_job = run_export_job( &worker_db, repo_bytes.as_ref(), job, output_path, object_instance_ids, ) .unwrap_or_else(|err| ExportJobRecord { schema_version: 1, job_id: failed_job_template.job_id.clone(), run_id: failed_job_template.run_id.clone(), scope: failed_job_template.scope.clone(), repo_id: failed_job_template.repo_id.clone(), pp_id: failed_job_template.pp_id.clone(), status: "failed".to_string(), created_at: failed_job_template.created_at.clone(), finished_at: Some(now_rfc3339()), output_path: failed_job_template.output_path.clone(), object_count: 0, bytes_written: 0, error: Some(err.message), }); let _ = put_export_job(worker_db.as_ref(), &worker_export_jobs, &final_job); }); data_response(&response_job, Some(response_run_id)) } [ "runs", raw_run_id, "objects", object_instance_id, "validation", "explain", ] => { let run_id = resolve_run(&db, raw_run_id)?; let force_refresh = if body.is_empty() { false } else { let request: Value = serde_json::from_slice(body) .map_err(|err| ApiError::new(400, format!("invalid JSON body: {err}")))?; request .get("refresh") .or_else(|| request.get("forceRefresh")) .and_then(Value::as_bool) .unwrap_or(false) }; let explain = validation_explain( db.as_ref(), repo_bytes.as_deref(), &run_id, object_instance_id, force_refresh, )?; data_response(&explain, Some(run_id)) } _ => Err(ApiError::new(404, "not found")), } } fn validate_export_request( scope: &str, repo_id: Option<&str>, pp_id: Option<&str>, ) -> Result<(), ApiError> { match scope { "repo" if repo_id.is_some() => Ok(()), "publication_point" | "publicationPoint" if pp_id.is_some() => Ok(()), "object_set" | "objectSet" => Ok(()), "repo" => Err(ApiError::new(400, "repo export requires repo_id")), "publication_point" | "publicationPoint" => Err(ApiError::new( 400, "publication point export requires pp_id", )), other => Err(ApiError::new( 400, format!("unsupported export scope: {other}"), )), } } fn route_raw_request( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, export_jobs: &ExportJobStore, target: &str, ) -> Option, ApiError>> { let (path, _) = split_target(target); let path = path.trim_end_matches('/'); let segments = path .trim_start_matches("/api/v1") .trim_start_matches('/') .split('/') .filter(|s| !s.is_empty()) .collect::>(); match segments.as_slice() { ["runs", raw_run_id, "objects", object_instance_id, "raw"] => Some(raw_object_response( db, repo_bytes, raw_run_id, object_instance_id, )), ["runs", raw_run_id, "exports", job_id, "download"] => Some(export_download_response( db, export_jobs, raw_run_id, job_id, )), _ => None, } } fn raw_object_response( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, raw_run_id: &str, object_instance_id: &str, ) -> Result, ApiError> { let repo_bytes = repo_bytes .ok_or_else(|| ApiError::new(400, "repo-bytes db is not configured for raw download"))?; let run_id = resolve_run(db, raw_run_id)?; let object = db .get_object_by_instance_id(&run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; if !is_sha256_hex(&object.sha256) { return Err(ApiError::new(404, "object raw hash is unavailable")); } let bytes = repo_bytes .get_blob_bytes(&object.sha256) .map_err(|err| ApiError::new(500, err.to_string()))? .ok_or_else(|| ApiError::new(404, "object raw bytes not found"))?; Ok(binary_response(200, "application/octet-stream", bytes)) } fn export_download_response( db: &QueryDb, export_jobs: &ExportJobStore, raw_run_id: &str, job_id: &str, ) -> Result, ApiError> { let run_id = resolve_run(db, raw_run_id)?; let job = get_export_job(db, export_jobs, &run_id, job_id)? .ok_or_else(|| ApiError::new(404, "export job not found"))?; if job.status != "complete" { return Err(ApiError::new( 409, format!("export job status is {}", job.status), )); } let path = job .output_path .as_ref() .ok_or_else(|| ApiError::new(404, "export output path not found"))?; let bytes = fs::read(path).map_err(|err| ApiError::new(404, err.to_string()))?; Ok(binary_response(200, "application/x-tar", bytes)) } fn export_job_store_key(run_id: &str, job_id: &str) -> String { format!("{run_id}/{job_id}") } fn put_export_job( db: &QueryDb, export_jobs: &ExportJobStore, job: &ExportJobRecord, ) -> Result<(), ApiError> { if !db.is_secondary() { db.put_export_job(job)?; } let mut jobs = export_jobs .lock() .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?; jobs.insert(export_job_store_key(&job.run_id, &job.job_id), job.clone()); Ok(()) } fn get_export_job( db: &QueryDb, export_jobs: &ExportJobStore, run_id: &str, job_id: &str, ) -> Result, ApiError> { if let Some(job) = export_jobs .lock() .map_err(|_| ApiError::new(500, "export job store lock poisoned"))? .get(&export_job_store_key(run_id, job_id)) .cloned() { return Ok(Some(job)); } Ok(db.get_export_job(run_id, job_id)?) } fn list_export_jobs( export_jobs: &ExportJobStore, run_id: &str, limit: usize, ) -> Result, ApiError> { let jobs = export_jobs .lock() .map_err(|_| ApiError::new(500, "export job store lock poisoned"))?; let mut out = jobs .values() .filter(|job| job.run_id == run_id) .cloned() .collect::>(); out.sort_by(|left, right| right.created_at.cmp(&left.created_at)); out.truncate(limit); Ok(out) } fn run_export_job( db: &QueryDb, repo_bytes: &ExternalRepoBytesDb, mut job: ExportJobRecord, output_path: PathBuf, object_instance_ids: Vec, ) -> Result { if let Some(parent) = output_path.parent() { fs::create_dir_all(parent).map_err(|err| ApiError::new(500, err.to_string()))?; } let manifest_path = output_path.with_extension("manifest.json.tmp"); let mut file = File::create(&output_path).map_err(|err| ApiError::new(500, err.to_string()))?; let mut manifest = File::create(&manifest_path).map_err(|err| ApiError::new(500, err.to_string()))?; write_manifest_prefix(&mut manifest, &job)?; let mut object_count = 0u64; match job.scope.as_str() { "repo" => { let repo_id = job.repo_id.as_deref().unwrap_or_default(); let mut cursor = None; loop { let page = db.list_objects_for_repo( &job.run_id, repo_id, EXPORT_PAGE_LIMIT, cursor.as_deref(), )?; for object in page.data { write_export_object( repo_bytes, &mut file, &mut manifest, &mut object_count, &object, )?; } let Some(next_cursor) = page.next_cursor else { break; }; cursor = Some(next_cursor); } } "publication_point" | "publicationPoint" => { let pp_id = job.pp_id.as_deref().unwrap_or_default(); let mut cursor = None; loop { let page = db.list_objects_for_pp( &job.run_id, pp_id, EXPORT_PAGE_LIMIT, cursor.as_deref(), )?; for object in page.data { write_export_object( repo_bytes, &mut file, &mut manifest, &mut object_count, &object, )?; } let Some(next_cursor) = page.next_cursor else { break; }; cursor = Some(next_cursor); } } "object_set" | "objectSet" => { if object_instance_ids.len() > EXPORT_OBJECT_SET_MAX { return Err(ApiError::new( 400, format!("objectSet export accepts at most {EXPORT_OBJECT_SET_MAX} objects"), )); } for object_instance_id in object_instance_ids { let object = db .get_object_by_instance_id(&job.run_id, &object_instance_id)? .ok_or_else(|| { ApiError::new(404, format!("object not found: {object_instance_id}")) })?; write_export_object( repo_bytes, &mut file, &mut manifest, &mut object_count, &object, )?; } } other => { return Err(ApiError::new( 400, format!("unsupported export scope: {other}"), )); } } write_manifest_suffix(&mut manifest, object_count)?; manifest .flush() .map_err(|err| ApiError::new(500, err.to_string()))?; drop(manifest); write_tar_file_entry(&mut file, "manifest.json", &manifest_path)?; let _ = fs::remove_file(&manifest_path); file.write_all(&[0u8; 1024]) .map_err(|err| ApiError::new(500, err.to_string()))?; file.flush() .map_err(|err| ApiError::new(500, err.to_string()))?; let bytes_written = file .seek(SeekFrom::End(0)) .map_err(|err| ApiError::new(500, err.to_string()))?; job.status = "complete".to_string(); job.finished_at = Some(now_rfc3339()); job.object_count = object_count; job.bytes_written = bytes_written; Ok(job) } fn write_manifest_prefix(file: &mut File, job: &ExportJobRecord) -> Result<(), ApiError> { write!(file, "{{\"schemaVersion\":1,\"jobId\":") .map_err(|err| ApiError::new(500, err.to_string()))?; serde_json::to_writer(&mut *file, &job.job_id) .map_err(|err| ApiError::new(500, err.to_string()))?; write!(file, ",\"runId\":").map_err(|err| ApiError::new(500, err.to_string()))?; serde_json::to_writer(&mut *file, &job.run_id) .map_err(|err| ApiError::new(500, err.to_string()))?; write!(file, ",\"scope\":").map_err(|err| ApiError::new(500, err.to_string()))?; serde_json::to_writer(&mut *file, &job.scope) .map_err(|err| ApiError::new(500, err.to_string()))?; write!(file, ",\"repoId\":").map_err(|err| ApiError::new(500, err.to_string()))?; serde_json::to_writer(&mut *file, &job.repo_id) .map_err(|err| ApiError::new(500, err.to_string()))?; write!(file, ",\"ppId\":").map_err(|err| ApiError::new(500, err.to_string()))?; serde_json::to_writer(&mut *file, &job.pp_id) .map_err(|err| ApiError::new(500, err.to_string()))?; write!(file, ",\"objects\":[").map_err(|err| ApiError::new(500, err.to_string()))?; Ok(()) } fn write_manifest_suffix(file: &mut File, object_count: u64) -> Result<(), ApiError> { write!(file, "],\"objectCount\":{object_count}}}") .map_err(|err| ApiError::new(500, err.to_string())) } fn write_export_object( repo_bytes: &ExternalRepoBytesDb, tar_file: &mut File, manifest_file: &mut File, object_count: &mut u64, object: &ObjectInstanceRecord, ) -> Result<(), ApiError> { if !is_sha256_hex(&object.sha256) { return Err(ApiError::new( 500, format!("object has invalid sha256: {}", object.uri), )); } let bytes = repo_bytes .get_blob_bytes(&object.sha256) .map_err(|err| ApiError::new(500, err.to_string()))? .ok_or_else(|| ApiError::new(500, format!("bytes missing for {}", object.uri)))?; write_tar_entry(tar_file, &export_object_path(object), &bytes)?; if *object_count > 0 { write!(manifest_file, ",").map_err(|err| ApiError::new(500, err.to_string()))?; } serde_json::to_writer( manifest_file, &json!({ "uri": object.uri, "sha256": object.sha256, "objectType": object.object_type, "result": object.result, "sourceSection": object.source_section, }), ) .map_err(|err| ApiError::new(500, err.to_string()))?; *object_count += 1; Ok(()) } fn write_tar_entry(file: &mut File, path: &str, bytes: &[u8]) -> Result<(), ApiError> { let name = normalize_tar_path(path); let mut header = [0u8; 512]; let name_bytes = name.as_bytes(); let name_len = name_bytes.len().min(100); header[..name_len].copy_from_slice(&name_bytes[..name_len]); write_octal(&mut header[100..108], 0o644); write_octal(&mut header[108..116], 0); write_octal(&mut header[116..124], 0); write_octal(&mut header[124..136], bytes.len() as u64); write_octal(&mut header[136..148], 0); for byte in &mut header[148..156] { *byte = b' '; } header[156] = b'0'; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); let checksum: u64 = header.iter().map(|byte| *byte as u64).sum(); write_checksum(&mut header[148..156], checksum); file.write_all(&header) .map_err(|err| ApiError::new(500, err.to_string()))?; file.write_all(bytes) .map_err(|err| ApiError::new(500, err.to_string()))?; let padding = (512 - (bytes.len() % 512)) % 512; if padding > 0 { file.write_all(&vec![0u8; padding]) .map_err(|err| ApiError::new(500, err.to_string()))?; } Ok(()) } fn write_tar_file_entry( file: &mut File, path: &str, source_path: &PathBuf, ) -> Result<(), ApiError> { let name = normalize_tar_path(path); let size = fs::metadata(source_path) .map_err(|err| ApiError::new(500, err.to_string()))? .len(); let mut header = [0u8; 512]; let name_bytes = name.as_bytes(); let name_len = name_bytes.len().min(100); header[..name_len].copy_from_slice(&name_bytes[..name_len]); write_octal(&mut header[100..108], 0o644); write_octal(&mut header[108..116], 0); write_octal(&mut header[116..124], 0); write_octal(&mut header[124..136], size); write_octal(&mut header[136..148], 0); for byte in &mut header[148..156] { *byte = b' '; } header[156] = b'0'; header[257..263].copy_from_slice(b"ustar\0"); header[263..265].copy_from_slice(b"00"); let checksum: u64 = header.iter().map(|byte| *byte as u64).sum(); write_checksum(&mut header[148..156], checksum); file.write_all(&header) .map_err(|err| ApiError::new(500, err.to_string()))?; let mut source = File::open(source_path).map_err(|err| ApiError::new(500, err.to_string()))?; std::io::copy(&mut source, file).map_err(|err| ApiError::new(500, err.to_string()))?; let padding = (512 - (size as usize % 512)) % 512; if padding > 0 { file.write_all(&vec![0u8; padding]) .map_err(|err| ApiError::new(500, err.to_string()))?; } Ok(()) } fn write_octal(slot: &mut [u8], value: u64) { let width = slot.len(); let text = format!("{value:0width$o}\0", width = width - 1); slot.copy_from_slice(&text.as_bytes()[..width]); } fn write_checksum(slot: &mut [u8], value: u64) { let text = format!("{value:06o}\0 ",); slot.copy_from_slice(&text.as_bytes()[..slot.len()]); } fn normalize_tar_path(path: &str) -> String { let mut out = path .replace("://", "/") .chars() .map(|ch| match ch { 'A'..='Z' | 'a'..='z' | '0'..='9' | '.' | '_' | '-' | '/' => ch, _ => '_', }) .collect::(); while out.contains("//") { out = out.replace("//", "/"); } out.trim_start_matches('/').chars().take(100).collect() } fn export_object_path(object: &ObjectInstanceRecord) -> String { let ext = object .uri .rsplit_once('.') .map(|(_, ext)| ext) .filter(|ext| ext.len() <= 5) .unwrap_or("bin"); format!( "objects/{}_{}.{}", object.object_instance_id, object.sha256.chars().take(16).collect::(), ext ) } fn projection_array_page( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, run_id: &str, object_instance_id: &str, path: &[&str], list_kind: &str, limit: usize, offset: usize, ) -> Result { let object = db .get_object_by_instance_id(run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; let (total, data) = match projection_list_from_repo_bytes(repo_bytes, &object, list_kind, offset, limit)? { Some(page) => page, None => { let projection = projection_for_explain(db, repo_bytes, &object)? .ok_or_else(|| ApiError::new(404, "object projection not found"))?; let entries = json_path(&projection.projection, path) .and_then(Value::as_array) .ok_or_else(|| ApiError::new(404, "projection list not found"))?; let end = (offset + limit).min(entries.len()); ( entries.len(), entries[offset.min(entries.len())..end].to_vec(), ) } }; let end = (offset + data.len()).min(total); let next_cursor = if end < total { Some(end.to_string()) } else { None }; Ok(json!({ "data": data, "page": {"nextCursor": next_cursor, "limit": limit}, "meta": meta(Some(run_id.to_string())), })) } const EXPLAIN_VERSION: u32 = 1; fn validation_explain( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, run_id: &str, object_instance_id: &str, force_refresh: bool, ) -> Result { if !force_refresh && let Some(cached) = db.get_validation_explain(run_id, object_instance_id, EXPLAIN_VERSION)? { return Ok(cached); } let object = db .get_object_by_instance_id(run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; let projection = projection_for_explain(db, repo_bytes, &object)?; let parsevalidate = parsevalidate_summary(&object, projection.as_ref()); let chain_edges = build_chain_edges(db, run_id, &object, projection.as_ref())?; let chainvalidate = json!({ "status": final_status(&object), "mode": "audit-context", "issues": issues_for_object(&object), "edgesCount": chain_edges.len(), "note": "M7 explain uses run audit state and parse projection only; full chain revalidation is deferred." }); let explain = ValidationExplainRecord { schema_version: 1, explain_version: EXPLAIN_VERSION, run_id: run_id.to_string(), object_instance_id: object.object_instance_id.clone(), uri: object.uri.clone(), sha256: object.sha256.clone(), object_type: object.object_type.clone(), final_status: final_status(&object).to_string(), audit_result: object.result.clone(), detail_summary: object.detail_summary.clone(), authoritative: false, explain_mode: "audit_projection".to_string(), generated_at: now_rfc3339(), parsevalidate, chainvalidate, chain_edges, }; db.put_validation_explain(&explain)?; Ok(explain) } fn chain_edges_for_object( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, run_id: &str, object_instance_id: &str, ) -> Result, ApiError> { let object = db .get_object_by_instance_id(run_id, object_instance_id)? .ok_or_else(|| ApiError::new(404, "object not found"))?; let projection = projection_for_explain(db, repo_bytes, &object)?; build_chain_edges(db, run_id, &object, projection.as_ref()) } fn projection_for_explain( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, object: &ObjectInstanceRecord, ) -> Result, ApiError> { if !is_sha256_hex(&object.sha256) { return Ok(None); } if let Some(projection) = db.get_object_projection(&object.sha256)? { return Ok(Some(projection)); } let Some(repo_bytes) = repo_bytes else { return Ok(None); }; let Some(bytes) = repo_bytes .get_blob_bytes(&object.sha256) .map_err(|err| ApiError::new(500, err.to_string()))? else { return Ok(None); }; let projection = rpki::object_projection::build_object_projection( object_type_for_projection(&object.object_type), std::path::Path::new(&object.uri), &bytes, 100, ); db.put_object_projection(&projection)?; Ok(Some(projection)) } fn search_run(db: &QueryDb, run_id: &str, q: &str, limit: usize) -> Result { let objects = search_run_objects(db, run_id, q, limit)?; let lower = q.to_lowercase(); let repos = db.search_repos(run_id, &lower, limit)?; let publication_points = db.search_publication_points(run_id, &lower, limit)?; Ok(json!({ "objects": objects, "repos": repos, "publicationPoints": publication_points, })) } fn search_run_objects( db: &QueryDb, run_id: &str, q: &str, limit: usize, ) -> Result, ApiError> { if q.starts_with("rsync://") || q.starts_with("http://") || q.starts_with("https://") { let Some(index) = db.get_object_by_uri(run_id, q)? else { return Ok(Vec::new()); }; let object = match db.get_object_by_instance_id(run_id, &index.object_instance_id)? { Some(full) => full, None => object_instance_from_uri_index(&index), }; return Ok(vec![object]); } let lower = q.to_lowercase(); if is_sha256_hex(q) { if let Some(projection) = db.get_object_projection(&lower)? { if let Some(full) = db.get_cached_object_by_sha256(run_id, &projection.sha256)? { return Ok(vec![full]); } return Ok(vec![object_instance_from_projection(run_id, &projection)]); } if let Some(object) = db.get_object_by_sha256(run_id, &lower)? { return Ok(vec![object]); } return Ok(Vec::new()); } let is_hex = lower.len() >= 8 && lower.bytes().all(|byte| byte.is_ascii_hexdigit()); if is_hex { let mut objects = Vec::new(); for projection in db.search_object_projections_by_hash_prefix(&lower, limit)? { let object = match db.get_cached_object_by_sha256(run_id, &projection.sha256)? { Some(full) => full, None => object_instance_from_projection(run_id, &projection), }; objects.push(object); } return Ok(objects); } Ok(Vec::new()) } fn object_instance_from_uri_index(index: &ObjectUriIndexRecord) -> ObjectInstanceRecord { ObjectInstanceRecord { schema_version: 1, run_id: index.run_id.clone(), object_instance_id: index.object_instance_id.clone(), uri: index.uri.clone(), uri_hash: String::new(), sha256: index.sha256.clone(), object_type: String::new(), result: String::new(), detail_summary: None, repo_id: index.repo_id.clone(), pp_id: index.pp_id.clone(), source_section: "uri_index".to_string(), rejected: false, reject_reason: None, } } fn object_instance_from_projection( run_id: &str, projection: &rpki::object_projection::ObjectProjectionRecord, ) -> ObjectInstanceRecord { ObjectInstanceRecord { schema_version: 1, run_id: run_id.to_string(), object_instance_id: String::new(), uri: String::new(), uri_hash: String::new(), sha256: projection.sha256.clone(), object_type: projection.object_type.clone(), result: projection.parse_status.clone(), detail_summary: projection.error_summary.clone(), repo_id: String::new(), pp_id: String::new(), source_section: "hash_index".to_string(), rejected: false, reject_reason: None, } } fn global_projection_for_hash( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, sha256: &str, ) -> Result, ApiError> { if let Some(projection) = db.get_object_projection(sha256)? { return Ok(Some(projection)); } let Some(run_id) = db.latest_ready_run()? else { return Ok(None); }; let Some(object) = db.get_object_by_sha256(&run_id, sha256)? else { return Ok(None); }; projection_for_explain(db, repo_bytes, &object) } fn validation_summary(object: &ObjectInstanceRecord) -> Value { json!({ "objectInstanceId": object.object_instance_id, "uri": object.uri, "sha256": object.sha256, "objectType": object.object_type, "finalStatus": final_status(object), "auditResult": object.result, "detailSummary": object.detail_summary, "rejected": object.rejected, "rejectReason": object.reject_reason, "sourceSection": object.source_section, "fileValidation": file_validation_summary(object), "chainValidation": chain_validation_summary(object), "explainAvailable": true, "authoritative": false }) } fn file_validation_summary(object: &ObjectInstanceRecord) -> Value { json!({ "status": final_status(object), "stage": "audit_result", "issues": issues_for_object(object), "detailSummary": object.detail_summary, }) } fn chain_validation_summary(object: &ObjectInstanceRecord) -> Value { json!({ "status": final_status(object), "stage": "audit_context", "issues": issues_for_object(object), "edgesPage": {"nextCursor": null, "limit": 100}, "note": "Use POST validation/explain for cached audit projection details." }) } fn parsevalidate_summary( object: &ObjectInstanceRecord, projection: Option<&rpki::object_projection::ObjectProjectionRecord>, ) -> Value { let projection_status = projection .map(|record| record.parse_status.as_str()) .unwrap_or("unavailable"); let projection_error = projection.and_then(|record| record.error_summary.as_deref()); let status = match projection_status { "ok" if !object.rejected && object.result == "ok" => "valid", "ok" if object.rejected || object.result == "error" => "warning", "error" => "invalid", _ if object.result == "error" || object.rejected => "invalid", _ => "unknown", }; json!({ "status": status, "projectionStatus": projection_status, "issues": issues_for_object(object), "projectionError": projection_error, "projection": projection.map(|record| record.projection.clone()), }) } fn build_chain_edges( db: &QueryDb, run_id: &str, object: &ObjectInstanceRecord, projection: Option<&rpki::object_projection::ObjectProjectionRecord>, ) -> Result, ApiError> { let mut edges = Vec::new(); if let Some(pp) = db.get_publication_point(run_id, &object.pp_id)? && let Some(manifest_uri) = pp.manifest_rsync_uri && manifest_uri != object.uri { let manifest = db.get_object_by_uri(run_id, &manifest_uri)?; edges.push(ChainEdgeRecord { relation: "publication_point_manifest".to_string(), from_uri: object.uri.clone(), to_uri: manifest_uri, to_object_instance_id: manifest .as_ref() .map(|item| item.object_instance_id.clone()), to_sha256: manifest.as_ref().map(|item| item.sha256.clone()), status: if manifest.is_some() { "linked".to_string() } else { "missing".to_string() }, evidence: json!({"ppId": object.pp_id}), }); } if let Some(uri) = first_signed_object_uri(projection) { let target = db.get_object_by_uri(run_id, &uri)?; edges.push(ChainEdgeRecord { relation: "embedded_ee_sia_signed_object".to_string(), from_uri: object.uri.clone(), to_uri: uri, to_object_instance_id: target.as_ref().map(|item| item.object_instance_id.clone()), to_sha256: target.as_ref().map(|item| item.sha256.clone()), status: if target.is_some() { "linked".to_string() } else { "missing".to_string() }, evidence: json!({"source": "projection.signedObject.certificates[0].resourceCertificate.extensions.subjectInfoAccess"}), }); } Ok(edges) } fn first_signed_object_uri( projection: Option<&rpki::object_projection::ObjectProjectionRecord>, ) -> Option { projection .and_then(|record| { record .projection .get("object")? .get("signedObject")? .get("signedData")? .get("certificates")? .as_array()? .first()? .get("resourceCertificate")? .get("extensions")? .get("subjectInfoAccess")? .get("signedObjectUris")? .as_array()? .first()? .as_str() }) .map(str::to_string) } fn final_status(object: &ObjectInstanceRecord) -> &'static str { if object.rejected || object.result == "error" { "invalid" } else if object.result == "skipped" { "skipped" } else if object.result == "ok" { "valid" } else { "unknown" } } fn issues_for_object(object: &ObjectInstanceRecord) -> Vec { let reason = object .reject_reason .as_ref() .or(object.detail_summary.as_ref()) .filter(|reason| !reason.is_empty()); reason .map(|reason| { vec![json!({ "stage": "audit_result", "severity": if object.rejected || object.result == "error" { "error" } else { "warning" }, "reasonCode": stable_reason_code(reason), "summary": reason, })] }) .unwrap_or_default() } fn stable_reason_code(reason: &str) -> String { let lower = reason.to_ascii_lowercase(); let code = lower .chars() .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '_' }) .collect::(); let code = code .split('_') .filter(|part| !part.is_empty()) .collect::>() .join("_"); if code.is_empty() { "unknown".to_string() } else { code.chars().take(96).collect() } } fn object_type_for_projection(label: &str) -> rpki::object_projection::ObjectType { match label { "cer" | "certificate" | "router_certificate" => rpki::object_projection::ObjectType::Cer, "mft" | "manifest" => rpki::object_projection::ObjectType::Mft, "crl" => rpki::object_projection::ObjectType::Crl, "roa" => rpki::object_projection::ObjectType::Roa, "asa" | "aspa" => rpki::object_projection::ObjectType::Aspa, _ => rpki::object_projection::ObjectType::Auto, } } fn projection_list_from_repo_bytes( repo_bytes: Option<&ExternalRepoBytesDb>, object: &ObjectInstanceRecord, list_kind: &str, offset: usize, limit: usize, ) -> Result)>, ApiError> { let Some(repo_bytes) = repo_bytes else { return Ok(None); }; if !is_sha256_hex(&object.sha256) { return Ok(None); } let Some(bytes) = repo_bytes .get_blob_bytes(&object.sha256) .map_err(|err| ApiError::new(500, err.to_string()))? else { return Ok(None); }; let page = match list_kind { "manifest-files" => { rpki::object_projection::manifest_file_entries_page(&bytes, offset, limit) } "revoked-certs" => rpki::object_projection::crl_revoked_entries_page(&bytes, offset, limit), _ => return Ok(None), } .map_err(|err| ApiError::new(500, err))?; Ok(Some(page)) } fn json_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a Value> { let mut current = value; for key in path { current = current.get(*key)?; } Some(current) } fn resolve_run(db: &QueryDb, raw: &str) -> Result { db.resolve_run_id(raw)? .ok_or_else(|| ApiError::new(404, "run not found")) } fn split_target(target: &str) -> (&str, &str) { target.split_once('?').unwrap_or((target, "")) } fn parse_query(raw: &str) -> BTreeMap { raw.split('&') .filter(|part| !part.is_empty()) .filter_map(|part| { let (key, value) = part.split_once('=').unwrap_or((part, "")); Some((percent_decode(key)?, percent_decode(value)?)) }) .collect() } fn percent_decode(value: &str) -> Option { let bytes = value.as_bytes(); let mut out = Vec::with_capacity(bytes.len()); let mut i = 0usize; while i < bytes.len() { match bytes[i] { b'+' => out.push(b' '), b'%' if i + 2 < bytes.len() => { let hi = hex_value(bytes[i + 1])?; let lo = hex_value(bytes[i + 2])?; out.push((hi << 4) | lo); i += 2; } byte => out.push(byte), } i += 1; } String::from_utf8(out).ok() } fn hex_value(byte: u8) -> Option { match byte { b'0'..=b'9' => Some(byte - b'0'), b'a'..=b'f' => Some(byte - b'a' + 10), b'A'..=b'F' => Some(byte - b'A' + 10), _ => None, } } fn limit(query: &BTreeMap) -> usize { query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(100) .clamp(1, 1000) } fn search_limit(query: &BTreeMap) -> usize { query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(10) .clamp(1, 50) } fn exports_limit(query: &BTreeMap) -> usize { query .get("limit") .and_then(|value| value.parse::().ok()) .unwrap_or(50) .clamp(1, 200) } fn object_filter_from_query(query: &BTreeMap) -> Result { let mut filter = ObjectFilter::default(); if let Some(value) = query.get("type") { if value.is_empty() { return Err(ApiError::new(400, "type filter must not be empty")); } // Accept projection-vocabulary aliases so both `mft` and `manifest` // (etc.) match the URI-derived report stream types. let lower = value.to_ascii_lowercase(); let normalized = match lower.as_str() { "mft" => "manifest", "cer" => "certificate", "asa" => "aspa", _ => lower.as_str(), }; filter.object_type = Some(normalized.to_string()); } if let Some(value) = query.get("result") { if !matches!( value.to_ascii_lowercase().as_str(), "ok" | "error" | "skipped" ) { return Err(ApiError::new( 400, format!("invalid result filter: {value}"), )); } filter.result = Some(value.clone()); } if let Some(value) = query.get("rejected") { filter.rejected = Some(match value.as_str() { "true" => true, "false" => false, _ => { return Err(ApiError::new( 400, format!("invalid rejected filter: {value}"), )); } }); } if let Some(value) = query.get("reason") { filter.reason = Some(value.to_lowercase()); } if let Some(value) = query.get("q") { filter.query = Some(value.to_lowercase()); } Ok(filter) } fn vrp_lookup_from_query(query: &BTreeMap) -> Result { let ip = query.get("ip"); let prefix = query.get("prefix"); match (ip, prefix) { (Some(_), Some(_)) => Err(ApiError::new( 400, "ip and prefix query parameters are mutually exclusive", )), (None, None) => Err(ApiError::new( 400, "one of ip or prefix query parameters is required", )), (Some(value), None) => { VrpLookup::parse_ip(value).map_err(|error| ApiError::new(400, error)) } (None, Some(value)) => { VrpLookup::parse_prefix(value).map_err(|error| ApiError::new(400, error)) } } } fn cursor(query: &BTreeMap) -> Option<&str> { query.get("cursor").map(String::as_str) } fn offset_cursor(query: &BTreeMap) -> usize { query .get("cursor") .and_then(|value| value.parse::().ok()) .unwrap_or(0) } fn now_rfc3339() -> String { time::OffsetDateTime::now_utc() .format(&time::format_description::well_known::Rfc3339) .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) } fn data_response(data: &T, run_id: Option) -> Result { Ok(json!({"data": data, "page": null, "meta": meta(run_id)})) } fn page_response( page: rpki::query_db::QueryPage, run_id: Option, ) -> Result { Ok(json!({ "data": page.data, "page": {"nextCursor": page.next_cursor, "limit": page.limit}, "meta": meta(run_id), })) } fn meta(run_id: Option) -> Value { json!({"runId": run_id, "schemaVersion": 1}) } fn json_response(status: u16, value: &Value) -> Vec { let body = serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()); let reason = match status { 200 => "OK", 400 => "Bad Request", 404 => "Not Found", 405 => "Method Not Allowed", 409 => "Conflict", _ => "Internal Server Error", }; format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", body.len(), String::from_utf8_lossy(&body) ) .into_bytes() } fn binary_response(status: u16, content_type: &str, body: Vec) -> Vec { let reason = match status { 200 => "OK", 404 => "Not Found", _ => "Internal Server Error", }; let mut response = format!( "HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() ) .into_bytes(); response.extend(body); response } fn is_sha256_hex(value: &str) -> bool { value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) } #[cfg(test)] mod tests { use super::*; use rpki::query_db::{ArtifactIndexerConfig, index_artifacts}; use sha2::{Digest, Sha256}; use std::fs; fn test_export_jobs() -> ExportJobStore { Arc::new(Mutex::new(BTreeMap::new())) } fn test_route_request( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, target: &str, ) -> Result { route_request(db, repo_bytes, &test_export_jobs(), target) } fn test_route_raw_request( db: &QueryDb, repo_bytes: Option<&ExternalRepoBytesDb>, target: &str, ) -> Option, ApiError>> { route_raw_request(db, repo_bytes, &test_export_jobs(), target) } #[test] fn parse_query_decodes_limit_and_cursor() { let query = parse_query("limit=10&cursor=objinst%2Frun_1%2Fabc"); assert_eq!(limit(&query), 10); assert_eq!(cursor(&query), Some("objinst/run_1/abc")); } #[test] fn parse_args_accepts_watcher_and_rejects_invalid_input() { let argv = [ "rpki_query_service", "--query-db", "query.db", "--repo-bytes-db", "repo-bytes.db", "--export-root", "exports", "--listen", "127.0.0.1:19560", "--watch-run-root", "runs", "--watch-interval-secs", "2", "--watch-min-run-seq", "42", "--watch-backfill", "--projection-entry-limit", "9", "--indexer-bin", "bin/rpki_query_indexer", "--retain-indexed-runs", "3", ] .iter() .map(|value| value.to_string()) .collect::>(); let args = parse_args(&argv).expect("args"); assert_eq!(args.query_db, PathBuf::from("query.db")); assert_eq!(args.repo_bytes_db, Some(PathBuf::from("repo-bytes.db"))); assert_eq!(args.export_root, PathBuf::from("exports")); assert_eq!(args.listen, "127.0.0.1:19560"); assert_eq!(args.watch_run_root, Some(PathBuf::from("runs"))); assert_eq!(args.watch_interval_secs, 2); assert_eq!(args.watch_min_run_seq, Some(42)); assert!(args.watch_backfill); assert_eq!(args.projection_entry_limit, 9); assert_eq!( args.indexer_bin, Some(PathBuf::from("bin/rpki_query_indexer")) ); assert_eq!(args.retain_indexed_runs, 3); let default_args = parse_args(&[ "rpki_query_service".to_string(), "--query-db".to_string(), "query.db".to_string(), ]) .expect("default args"); assert_eq!(default_args.export_root, PathBuf::from("query.exports")); assert_eq!(default_args.listen, "127.0.0.1:9560"); assert_eq!(default_args.watch_interval_secs, 10); assert_eq!(default_args.watch_min_run_seq, None); assert!(!default_args.watch_backfill); assert_eq!(default_args.projection_entry_limit, 50); assert_eq!(default_args.indexer_bin, None); assert_eq!(default_args.retain_indexed_runs, 10); assert!( parse_args(&["rpki_query_service".to_string()]) .unwrap_err() .contains("--query-db") ); assert!( parse_args(&[ "rpki_query_service".to_string(), "--query-db".to_string(), "query.db".to_string(), "--watch-interval-secs".to_string(), "bad".to_string(), ]) .unwrap_err() .contains("invalid --watch-interval-secs") ); assert!( parse_args(&[ "rpki_query_service".to_string(), "--query-db".to_string(), "query.db".to_string(), "--projection-entry-limit".to_string(), "bad".to_string(), ]) .unwrap_err() .contains("invalid --projection-entry-limit") ); assert!( parse_args(&[ "rpki_query_service".to_string(), "--query-db".to_string(), "query.db".to_string(), "--retain-indexed-runs".to_string(), "bad".to_string(), ]) .unwrap_err() .contains("invalid --retain-indexed-runs") ); assert!( parse_args(&[ "rpki_query_service".to_string(), "--query-db".to_string(), "query.db".to_string(), "--unknown".to_string(), ]) .unwrap_err() .contains("unknown argument") ); } #[test] fn resolve_effective_watch_min_run_seq_defaults_to_next_run_without_backfill() { let temp = tempfile::tempdir().expect("tempdir"); let runs_root = temp.path().join("runs"); fs::create_dir_all(runs_root.join("run_0003")).expect("run_0003"); fs::write(runs_root.join("run_0003/report.json"), "{}").expect("report"); fs::write( runs_root.join("run_0003/run-summary.json"), r#"{"status":"success"}"#, ) .expect("summary"); fs::create_dir_all(runs_root.join("run_0004")).expect("run_0004"); fs::write(runs_root.join("run_0004/report.json"), "{}").expect("report"); fs::write( runs_root.join("run_0004/run-summary.json"), r#"{"status":"failed"}"#, ) .expect("summary"); assert_eq!( resolve_effective_watch_min_run_seq(temp.path(), None, false).expect("min seq"), Some(4) ); assert_eq!( resolve_effective_watch_min_run_seq(temp.path(), Some(2), false).expect("min seq"), Some(2) ); assert_eq!( resolve_effective_watch_min_run_seq(temp.path(), None, true).expect("min seq"), None ); } #[test] fn route_request_exposes_run_navigation_stats_and_object_details() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let object_bytes = fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") .expect("fixture roa"); let object_sha = hex::encode(Sha256::digest(&object_bytes)); write_sample_run(&run_dir, &object_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(object_sha.clone(), object_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let repo = db .list_repos("run_0001", 1, None) .expect("repos") .data .into_iter() .next() .expect("repo"); let pp = db .list_publication_points("run_0001", 1, None) .expect("pps") .data .into_iter() .next() .expect("pp"); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1").expect("root")["data"]["service"] .as_str(), Some("rpki_query_service") ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs").expect("runs")["data"] .as_array() .expect("runs") .len(), 1 ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest") .expect("latest")["data"]["runId"] .as_str(), Some("run_0001") ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/latest_run") .expect("latest_run")["data"]["runId"] .as_str(), Some("run_0001") ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/run_0001") .expect("run")["data"]["counts"]["objects"] .as_u64(), Some(1) ); assert!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/artifacts") .expect("artifacts")["data"]["report.json"] .as_str() .is_some() ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/summary") .expect("summary")["data"]["objects"] .as_u64(), Some(1) ); let vrp_ip = test_route_request( &db, Some(&repo_bytes), "/api/v1/runs/latest/vrps/lookup?ip=2001:da8::1", ) .expect("VRP IP lookup"); assert_eq!(vrp_ip["data"][0]["asn"].as_u64(), Some(4538)); assert_eq!(vrp_ip["meta"]["runId"].as_str(), Some("run_0001")); let vrp_prefix = test_route_request( &db, Some(&repo_bytes), "/api/v1/runs/latest/vrps/lookup?prefix=2001%3Ada8%3A%3A%2F32&asn=4538", ) .expect("VRP prefix lookup"); assert_eq!(vrp_prefix["data"].as_array().unwrap().len(), 1); let invalid = test_route_request( &db, Some(&repo_bytes), "/api/v1/runs/latest/vrps/lookup?ip=192.0.2.1&prefix=192.0.2.0%2F24", ) .expect_err("conflicting VRP lookup parameters"); assert_eq!(invalid.status, 400); for target in [ "/api/v1/runs/latest/repos", &format!("/api/v1/runs/latest/repos/{}", repo.repo_id), &format!("/api/v1/runs/latest/repos/{}/stats", repo.repo_id), &format!( "/api/v1/runs/latest/repos/{}/publication-points", repo.repo_id ), &format!("/api/v1/runs/latest/repos/{}/objects", repo.repo_id), "/api/v1/runs/latest/publication-points", &format!("/api/v1/runs/latest/publication-points/{}", pp.pp_id), &format!("/api/v1/runs/latest/publication-points/{}/stats", pp.pp_id), &format!( "/api/v1/runs/latest/publication-points/{}/objects", pp.pp_id ), "/api/v1/runs/latest/objects", &format!( "/api/v1/runs/latest/objects/by-uri?uri={}", percent_encode_for_test(&object.uri) ), &format!("/api/v1/runs/latest/objects/{}", object.object_instance_id), &format!( "/api/v1/runs/latest/objects/{}/parsed", object.object_instance_id ), &format!( "/api/v1/runs/latest/objects/{}/validation/file", object.object_instance_id ), &format!( "/api/v1/runs/latest/objects/{}/validation/chain", object.object_instance_id ), "/api/v1/runs/latest/stats/overview", "/api/v1/runs/latest/stats/repos", "/api/v1/runs/latest/stats/publication-points", "/api/v1/runs/latest/stats/object-types", "/api/v1/runs/latest/stats/validation", "/api/v1/runs/latest/stats/reasons", "/api/v1/runs/latest/stats/downloads", "/api/v1/runs/latest/stats/validation-events?name=manifest", "/api/v1/runs/latest/stats/objects?name=by_source", &format!("/api/v1/objects/{object_sha}"), ] { let value = test_route_request(&db, Some(&repo_bytes), target).unwrap_or_else(|err| { panic!("route failed for {target}: {} {}", err.status, err.message) }); assert!(value.get("meta").is_some(), "{target}"); } assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/objects/by-uri") .unwrap_err() .status, 400 ); assert_eq!( test_route_request(&db, Some(&repo_bytes), "/api/v1/runs/latest/repos/nope") .unwrap_err() .status, 404 ); } #[test] fn raw_route_downloads_object_bytes_from_repo_bytes_db() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let object_bytes = fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") .expect("fixture roa"); let object_sha = hex::encode(Sha256::digest(&object_bytes)); write_sample_run(&run_dir, &object_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(object_sha.clone(), object_bytes.clone())]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); let response = test_route_raw_request( &db, Some(&repo_bytes), &format!( "/api/v1/runs/latest/objects/{}/raw", object.object_instance_id ), ) .expect("raw route") .expect("raw response"); let separator = response .windows(4) .position(|window| window == b"\r\n\r\n") .expect("header separator"); assert_eq!(&response[separator + 4..], object_bytes.as_slice()); } #[test] fn export_job_writes_tar_with_manifest_and_object() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let object_bytes = fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") .expect("fixture roa"); let object_sha = hex::encode(Sha256::digest(&object_bytes)); write_sample_run(&run_dir, &object_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(object_sha, object_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let repo = db .list_repos("run_0001", 1, None) .expect("repos") .data .into_iter() .next() .expect("repo"); let job = ExportJobRecord { schema_version: 1, job_id: "job1".to_string(), run_id: "run_0001".to_string(), scope: "repo".to_string(), repo_id: Some(repo.repo_id), pp_id: None, status: "running".to_string(), created_at: now_rfc3339(), finished_at: None, output_path: Some(temp.path().join("export.tar").display().to_string()), object_count: 0, bytes_written: 0, error: None, }; let output_path = temp.path().join("export.tar"); let final_job = run_export_job(&db, &repo_bytes, job, output_path.clone(), Vec::new()).expect("export"); assert_eq!(final_job.status, "complete"); assert_eq!(final_job.object_count, 1); let tar = fs::read(output_path).expect("tar"); assert!( tar.windows("manifest.json".len()) .any(|window| window == b"manifest.json") ); assert!( tar.windows("AS4538.roa".len()) .any(|window| window == b"AS4538.roa") || tar.windows(".roa".len()).any(|window| window == b".roa") ); } #[test] fn post_export_route_creates_and_completes_repo_job() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let object_bytes = fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") .expect("fixture roa"); let object_sha = hex::encode(Sha256::digest(&object_bytes)); write_sample_run(&run_dir, &object_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(object_sha, object_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = Arc::new(QueryDb::open(&query_db_path).expect("query db")); let repo_bytes = Arc::new(ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes")); let repo = db .list_repos("run_0001", 1, None) .expect("repos") .data .into_iter() .next() .expect("repo"); let body = serde_json::to_vec(&json!({"scope":"repo","repo_id": repo.repo_id})).expect("body"); let response = route_post_request( Arc::clone(&db), Some(repo_bytes), test_export_jobs(), temp.path().join("exports"), "/api/v1/runs/latest/exports", &body, ) .expect("post export"); let job_id = response["data"]["jobId"] .as_str() .expect("job id") .to_string(); let mut job = None; for _ in 0..50 { job = db.get_export_job("run_0001", &job_id).expect("job"); if job.as_ref().is_some_and(|job| job.status == "complete") { break; } std::thread::sleep(std::time::Duration::from_millis(20)); } let job = job.expect("completed job"); assert_eq!(job.status, "complete"); assert_eq!(job.object_count, 1); assert!(job.bytes_written > 0); } #[test] fn projection_large_lists_page_from_repo_bytes() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let mft_bytes = fs::read( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ) .expect("fixture mft"); let mft_sha = hex::encode(Sha256::digest(&mft_bytes)); write_manifest_sample_run(&run_dir, &mft_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(mft_sha, mft_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 2, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); let page = projection_array_page( &db, Some(&repo_bytes), "run_0001", &object.object_instance_id, &["object", "manifest", "fileList", "entries"], "manifest-files", 2, 2, ) .expect("page"); assert_eq!(page["data"].as_array().expect("data").len(), 2); assert!(page["page"]["nextCursor"].as_str().is_some()); } #[test] fn projection_large_lists_can_use_stored_projection_without_repo_bytes() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let mft_bytes = fs::read( "tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/05FC9C5B88506F7C0D3F862C8895BED67E9F8EBA.mft", ) .expect("fixture mft"); let mft_sha = hex::encode(Sha256::digest(&mft_bytes)); write_manifest_sample_run(&run_dir, &mft_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(mft_sha, mft_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 4, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); let projection = projection_for_explain(&db, Some(&repo_bytes), &object) .expect("prime lazy projection cache") .expect("projection"); assert_eq!(projection.sha256, object.sha256); drop(repo_bytes); let page = projection_array_page( &db, None, "run_0001", &object.object_instance_id, &["object", "manifest", "fileList", "entries"], "manifest-files", 2, 0, ) .expect("page"); assert_eq!(page["data"].as_array().expect("data").len(), 2); } #[test] fn post_and_raw_routes_return_expected_error_statuses() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); write_sample_run(&run_dir, &"1".repeat(64)); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: None, projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = Arc::new(QueryDb::open(&query_db_path).expect("query db")); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); assert_eq!( route_post_request( Arc::clone(&db), None, test_export_jobs(), temp.path().join("exports"), "/api/v1/runs/latest/exports", b"{}", ) .unwrap_err() .status, 400 ); assert_eq!( route_post_request( Arc::clone(&db), None, test_export_jobs(), temp.path().join("exports"), &format!( "/api/v1/runs/latest/objects/{}/validation/explain", object.object_instance_id ), b"{bad json", ) .unwrap_err() .status, 400 ); assert_eq!( validate_export_request("repo", None, None) .unwrap_err() .status, 400 ); assert_eq!( validate_export_request("publication_point", None, None) .unwrap_err() .status, 400 ); assert_eq!( validate_export_request("bad", None, None) .unwrap_err() .status, 400 ); validate_export_request("repo", Some("repo-id"), None).expect("repo export"); validate_export_request("publicationPoint", None, Some("pp-id")).expect("pp export"); validate_export_request("objectSet", None, None).expect("object set export"); assert_eq!( test_route_raw_request( &db, None, &format!( "/api/v1/runs/latest/objects/{}/raw", object.object_instance_id ), ) .expect("raw route") .unwrap_err() .status, 400 ); let running_job = ExportJobRecord { schema_version: 1, job_id: "job1".to_string(), run_id: "run_0001".to_string(), scope: "object_set".to_string(), repo_id: None, pp_id: None, status: "running".to_string(), created_at: now_rfc3339(), finished_at: None, output_path: None, object_count: 0, bytes_written: 0, error: None, }; db.put_export_job(&running_job).expect("job"); assert_eq!( test_route_raw_request(&db, None, "/api/v1/runs/latest/exports/job1/download") .expect("download route") .unwrap_err() .status, 409 ); assert!(test_route_raw_request(&db, None, "/api/v1/not-raw").is_none()); } #[test] fn validation_explain_uses_projection_and_cache() { let temp = tempfile::tempdir().expect("tempdir"); let run_dir = temp.path().join("runs/run_0001"); fs::create_dir_all(&run_dir).expect("run dir"); let object_bytes = fs::read("tests/fixtures/repository/rpki.cernet.net/repo/cernet/0/AS4538.roa") .expect("fixture roa"); let object_sha = hex::encode(Sha256::digest(&object_bytes)); write_sample_run(&run_dir, &object_sha); let repo_bytes_path = temp.path().join("repo-bytes.db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); repo_bytes .put_blob_bytes_batch(&[(object_sha, object_bytes)]) .expect("put bytes"); drop(repo_bytes); let query_db_path = temp.path().join("query-db"); index_artifacts(&ArtifactIndexerConfig { query_db_path: query_db_path.clone(), run_root: Some(temp.path().to_path_buf()), run_dir: None, repo_bytes_db_path: Some(repo_bytes_path.clone()), projection_entry_limit: 5, min_run_seq: None, retain_indexed_runs: None, }) .expect("index artifacts"); let db = QueryDb::open(&query_db_path).expect("query db"); let repo_bytes = ExternalRepoBytesDb::open(&repo_bytes_path).expect("repo bytes"); let object = db .list_objects("run_0001", 1, None) .expect("objects") .data .into_iter() .next() .expect("object"); let explain = validation_explain( &db, Some(&repo_bytes), "run_0001", &object.object_instance_id, false, ) .expect("explain"); assert_eq!(explain.final_status, "valid"); assert_eq!( explain.parsevalidate["projectionStatus"].as_str(), Some("ok") ); assert!(!explain.authoritative); assert!( db.get_validation_explain("run_0001", &object.object_instance_id, EXPLAIN_VERSION) .expect("cached") .is_some() ); let validation = test_route_request( &db, Some(&repo_bytes), &format!( "/api/v1/runs/latest/objects/{}/validation", object.object_instance_id ), ) .expect("validation route"); assert_eq!(validation["data"]["finalStatus"].as_str(), Some("valid")); let chain = test_route_request( &db, Some(&repo_bytes), &format!( "/api/v1/runs/latest/objects/{}/chain", object.object_instance_id ), ) .expect("chain route"); assert!(chain["data"].as_array().is_some()); } fn write_sample_run(run_dir: &std::path::Path, object_sha: &str) { let report = json!({ "format_version": 2, "meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"}, "tree": {"warnings": []}, "publication_points": [ { "node_id": 10, "rsync_base_uri": "rsync://repo.example/rpki/", "manifest_rsync_uri": "rsync://repo.example/rpki/m.mft", "publication_point_rsync_uri": "rsync://repo.example/rpki/", "rrdp_notification_uri": "https://repo.example/rrdp/notification.xml", "source": "rrdp", "repo_sync_source": "rrdp", "repo_sync_phase": "rrdp_delta", "repo_sync_duration_ms": 123, "repo_terminal_state": "fresh", "warnings": [], "objects": [ {"rsync_uri":"rsync://repo.example/rpki/a.roa","sha256_hex": object_sha,"kind":"roa","result":"ok"} ] } ], "vrps": [{"asn": 4538, "prefix": "2001:da8::/32", "max_length": 32}], "aspas": [], "downloads": [], "download_stats": {} }); fs::write( run_dir.join("report.json"), serde_json::to_vec(&report).unwrap(), ) .expect("report"); let summary = json!({ "status": "success", "runId": "run_0001", "runSeq": 1, "startedAtRfc3339Utc": "2026-06-15T00:00:00Z", "finishedAtRfc3339Utc": "2026-06-15T00:01:00Z", "wallMs": 60000, "reportCounts": {"vrps": 1, "aspas": 0, "publicationPoints": 1, "warnings": 0} }); fs::write( run_dir.join("run-summary.json"), serde_json::to_vec(&summary).unwrap(), ) .expect("summary"); fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage"); } fn write_manifest_sample_run(run_dir: &std::path::Path, object_sha: &str) { let report = json!({ "format_version": 2, "meta": {"validation_time_rfc3339_utc": "2026-06-15T00:00:00Z"}, "tree": {"warnings": []}, "publication_points": [ { "node_id": 11, "rsync_base_uri": "rsync://repo.example/rpki/", "manifest_rsync_uri": "rsync://repo.example/rpki/m.mft", "publication_point_rsync_uri": "rsync://repo.example/rpki/", "rrdp_notification_uri": "https://repo.example/rrdp/notification.xml", "source": "rrdp", "repo_sync_source": "rrdp", "repo_sync_phase": "rrdp_delta", "repo_sync_duration_ms": 123, "repo_terminal_state": "fresh", "warnings": [], "objects": [ {"rsync_uri":"rsync://repo.example/rpki/m.mft","sha256_hex": object_sha,"kind":"manifest","result":"ok"} ] } ], "vrps": [], "aspas": [], "downloads": [], "download_stats": {} }); fs::write( run_dir.join("report.json"), serde_json::to_vec(&report).unwrap(), ) .expect("report"); let summary = json!({ "status": "success", "runId": "run_0001", "runSeq": 1, "startedAtRfc3339Utc": "2026-06-15T00:00:00Z", "finishedAtRfc3339Utc": "2026-06-15T00:01:00Z", "wallMs": 60000, "reportCounts": {"vrps": 0, "aspas": 0, "publicationPoints": 1, "warnings": 0} }); fs::write( run_dir.join("run-summary.json"), serde_json::to_vec(&summary).unwrap(), ) .expect("summary"); fs::write(run_dir.join("stage-timing.json"), b"{}").expect("stage"); } fn percent_encode_for_test(value: &str) -> String { value .bytes() .flat_map(|byte| match byte { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { vec![byte as char] } _ => format!("%{byte:02X}").chars().collect(), }) .collect() } }