rpki/src/bin/rpki_query_service.rs

3216 lines
114 KiB
Rust

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<Mutex<BTreeMap<String, ExportJobRecord>>>;
#[derive(Clone, Debug, PartialEq, Eq)]
struct Args {
query_db: PathBuf,
repo_bytes_db: Option<PathBuf>,
export_root: PathBuf,
listen: String,
watch_run_root: Option<PathBuf>,
watch_interval_secs: u64,
projection_entry_limit: usize,
watch_min_run_seq: Option<u64>,
watch_backfill: bool,
indexer_bin: Option<PathBuf>,
retain_indexed_runs: usize,
}
fn usage() -> &'static str {
"Usage: rpki_query_service --query-db <path> [--repo-bytes-db <path>] [--export-root <path>] [--listen <addr:port>] [--watch-run-root <path>] [--watch-interval-secs <n>] [--watch-min-run-seq <n>] [--watch-backfill] [--projection-entry-limit <n>] [--indexer-bin <path>] [--retain-indexed-runs <n>]"
}
fn parse_args(argv: &[String]) -> Result<Args, String> {
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::<u64>()
.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::<usize>()
.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::<u64>()
.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::<usize>()
.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::<Vec<_>>())?;
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<QueryDb, QueryDbError> {
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<PathBuf>) -> Result<PathBuf, String> {
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<u64>,
watch_backfill: bool,
) -> Result<Option<u64>, 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<QueryDb>,
query_db_path: PathBuf,
indexer_bin: PathBuf,
run_root: PathBuf,
projection_entry_limit: usize,
interval_secs: u64,
min_run_seq: Option<u64>,
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<u64, String> {
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<u64>,
) -> Result<Option<PathBuf>, 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<String, String> {
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<u64> {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix("run_"))
.and_then(|value| value.parse::<u64>().ok())
}
fn run_status_is_success_or_unknown(run_dir: &std::path::Path) -> Result<bool, String> {
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<QueryDb>,
repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
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<u8>,
}
fn read_http_request(stream: &mut TcpStream) -> Result<Option<HttpRequest>, 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::<usize>().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<usize> {
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<String>) -> Self {
Self {
status,
message: message.into(),
}
}
}
impl From<QueryDbError> 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<Value, ApiError> {
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::<Vec<_>>();
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::<u32>().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<QueryDb>,
repo_bytes: Option<Arc<ExternalRepoBytesDb>>,
export_jobs: ExportJobStore,
export_root: PathBuf,
target: &str,
body: &[u8],
) -> Result<Value, 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::<Vec<_>>();
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::<Vec<_>>()
})
.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<Result<Vec<u8>, 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::<Vec<_>>();
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<Vec<u8>, 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<Vec<u8>, 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<Option<ExportJobRecord>, 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<Vec<ExportJobRecord>, 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::<Vec<_>>();
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<String>,
) -> Result<ExportJobRecord, ApiError> {
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::<String>();
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::<String>(),
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<Value, ApiError> {
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<ValidationExplainRecord, ApiError> {
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<Vec<ChainEdgeRecord>, 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<Option<rpki::object_projection::ObjectProjectionRecord>, 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<Value, ApiError> {
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<Vec<ObjectInstanceRecord>, 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<Option<rpki::object_projection::ObjectProjectionRecord>, 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<Vec<ChainEdgeRecord>, 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<String> {
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<Value> {
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::<String>();
let code = code
.split('_')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.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<Option<(usize, Vec<Value>)>, 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<String, ApiError> {
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<String, String> {
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<String> {
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<u8> {
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<String, String>) -> usize {
query
.get("limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(100)
.clamp(1, 1000)
}
fn search_limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(10)
.clamp(1, 50)
}
fn exports_limit(query: &BTreeMap<String, String>) -> usize {
query
.get("limit")
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(50)
.clamp(1, 200)
}
fn object_filter_from_query(query: &BTreeMap<String, String>) -> Result<ObjectFilter, ApiError> {
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<String, String>) -> Result<VrpLookup, ApiError> {
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<String, String>) -> Option<&str> {
query.get("cursor").map(String::as_str)
}
fn offset_cursor(query: &BTreeMap<String, String>) -> usize {
query
.get("cursor")
.and_then(|value| value.parse::<usize>().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<T: Serialize>(data: &T, run_id: Option<String>) -> Result<Value, ApiError> {
Ok(json!({"data": data, "page": null, "meta": meta(run_id)}))
}
fn page_response<T: Serialize>(
page: rpki::query_db::QueryPage<T>,
run_id: Option<String>,
) -> Result<Value, ApiError> {
Ok(json!({
"data": page.data,
"page": {"nextCursor": page.next_cursor, "limit": page.limit},
"meta": meta(run_id),
}))
}
fn meta(run_id: Option<String>) -> Value {
json!({"runId": run_id, "schemaVersion": 1})
}
fn json_response(status: u16, value: &Value) -> Vec<u8> {
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<u8>) -> Vec<u8> {
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<Value, ApiError> {
route_request(db, repo_bytes, &test_export_jobs(), target)
}
fn test_route_raw_request(
db: &QueryDb,
repo_bytes: Option<&ExternalRepoBytesDb>,
target: &str,
) -> Option<Result<Vec<u8>, 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::<Vec<_>>();
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()
}
}