3216 lines
114 KiB
Rust
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()
|
|
}
|
|
}
|