use std::sync::Arc;
const METRICS_LIST_CAP: usize = 100_000;
use http_body_util::{BodyExt, Full, Limited};
use hyper::body::{Bytes, Incoming};
use hyper::service::service_fn;
use hyper::{Request, Response};
use hyper_util::rt::{TokioExecutor, TokioIo};
use hyper_util::server::conn::auto::Builder as ConnBuilder;
use rustls::ServerConfig;
use tokio_rustls::TlsAcceptor;
use crate::audit::{AuditAction, AuditEvent, AuditLog};
use crate::auth::{self, AuthConfig};
use crate::exposed::fast_routes::FastRoutes;
use crate::exposed::tls::leaf_common_name;
pub struct GatewaySettings {
pub routes: FastRoutes,
pub auth_config: Arc<AuthConfig>,
pub tls: Option<Arc<ServerConfig>>,
pub max_body_bytes: usize,
pub audit: Arc<dyn AuditLog>,
pub sboms: Option<crate::sbom::SharedSbomStore>,
pub properties: Option<crate::properties::SharedPropertyStore>,
pub quarantine: Option<(String, String)>,
}
pub fn start_http_gateway(
addr: std::net::SocketAddr,
settings: Arc<GatewaySettings>,
) -> anyhow::Result<()> {
tokio::spawn(async move {
let listener = match tokio::net::TcpListener::bind(addr).await {
Ok(l) => l,
Err(e) => {
eprintln!("HTTP/OCI gateway failed to bind {}: {}", addr, e);
return;
}
};
let scheme = if settings.tls.is_some() { "https" } else { "http (cleartext)" };
println!("HTTP/OCI gateway listening on {} [{}]", addr, scheme);
if settings.tls.is_none() {
log::warn!(
"SECURITY: HTTP/OCI gateway on {} runs WITHOUT TLS — credentials and \
artifacts travel in cleartext. Set ron_tls for any non-loopback use.",
addr
);
}
let acceptor = settings.tls.clone().map(TlsAcceptor::from);
loop {
let (stream, peer) = match listener.accept().await {
Ok(conn) => conn,
Err(e) => {
log::debug!("gateway accept error: {}", e);
continue;
}
};
let peer = peer.to_string();
let settings = settings.clone();
let acceptor = acceptor.clone();
tokio::spawn(async move {
match acceptor {
Some(acceptor) => {
let tls_stream = match acceptor.accept(stream).await {
Ok(s) => s,
Err(e) => {
log::debug!("TLS handshake failed: {}", e);
return;
}
};
let client_cn = tls_stream
.get_ref()
.1
.peer_certificates()
.and_then(leaf_common_name);
serve(TokioIo::new(tls_stream), settings, client_cn, peer).await;
}
None => serve(TokioIo::new(stream), settings, None, peer).await,
}
});
}
});
Ok(())
}
async fn serve<I>(
io: TokioIo<I>,
settings: Arc<GatewaySettings>,
client_cn: Option<String>,
peer: String,
) where
I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + 'static,
{
let svc = service_fn(move |req| {
let settings = settings.clone();
let client_cn = client_cn.clone();
let peer = peer.clone();
async move { handle(req, settings, client_cn, peer).await }
});
if let Err(e) = ConnBuilder::new(TokioExecutor::new())
.serve_connection(io, svc)
.await
{
log::debug!("gateway connection error: {}", e);
}
}
async fn handle(
req: Request<Incoming>,
settings: Arc<GatewaySettings>,
client_cn: Option<String>,
peer: String,
) -> Result<Response<Full<Bytes>>, std::convert::Infallible> {
let started = std::time::Instant::now();
let method = req.method().as_str().to_owned();
let path = req.uri().path().to_owned();
let path_and_query = req
.uri()
.path_and_query()
.map(|pq| pq.as_str().to_owned())
.unwrap_or_else(|| path.clone());
let want_sha256 = req
.headers()
.get("x-checksum-sha256")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
let want_sha512 = req
.headers()
.get("x-checksum-sha512")
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
if method == "GET" {
if path == "/healthz" || path == "/readyz" {
return Ok(text(200, "ok"));
}
if path == "/v2" || path == "/v2/" {
return Ok(Response::builder()
.status(200)
.header("Docker-Distribution-Api-Version", "registry/2.0")
.body(Full::new(Bytes::from_static(b"{}")))
.expect("static response"));
}
if path == "/-/search" {
let query = parse_search_query(req.uri().query().unwrap_or(""));
let repos = settings.routes.all_repos();
let props = settings.properties.clone();
let results = tokio::task::spawn_blocking(move || {
crate::search::search_repos_with_properties(
&repos,
&query,
props.as_deref().map(|p| p as &dyn crate::search::PropertyLookup),
)
})
.await;
let hit_count = results
.as_ref()
.map(|r| (r.artifacts.len() + r.paths.len()) as u64)
.unwrap_or(0);
let (status, body) = match results {
Ok(r) => match serde_json::to_vec(&r) {
Ok(json) => (200u16, json),
Err(e) => (500, format!("serialize error: {e}").into_bytes()),
},
Err(e) => (500, format!("search task failed: {e}").into_bytes()),
};
let ev = AuditEvent::new(
"anonymous",
AuditAction::List,
"",
&path_and_query,
&peer,
status,
hit_count,
);
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
crate::grpc::functional_status(
"holger-http/search",
"search_served",
status == 200,
&format!("{hit_count} hits"),
);
crate::metrics::global().record_request(
crate::metrics::Verb::Search,
status,
body.len() as u64,
);
crate::metrics::global().observe_request_duration(started.elapsed().as_secs_f64());
let ct = if status == 200 { "application/json" } else { "text/plain" };
return Ok(Response::builder()
.status(status)
.header("Content-Type", ct)
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")));
}
if path == "/-/sbom" {
let (status, ct, body, repo, label) =
serve_sbom_door(settings.sboms.as_ref(), req.uri().query().unwrap_or(""));
let ev = AuditEvent::new(
"anonymous",
AuditAction::Download,
&repo,
&label,
&peer,
status,
if status == 200 { body.len() as u64 } else { 0 },
);
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
crate::grpc::functional_status(
"holger-http/sbom",
"sbom_served",
status == 200,
&format!("{status} {label}"),
);
crate::metrics::global().record_request(
crate::metrics::Verb::Sbom,
status,
if status == 200 { body.len() as u64 } else { 0 },
);
crate::metrics::global().observe_request_duration(started.elapsed().as_secs_f64());
return Ok(Response::builder()
.status(status)
.header("Content-Type", ct)
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")));
}
if path == "/-/properties" {
let (status, body, repo, label) =
serve_properties_door(settings.properties.as_ref(), req.uri().query().unwrap_or(""));
let ev = AuditEvent::new(
"anonymous",
AuditAction::List,
&repo,
&label,
&peer,
status,
0,
);
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
crate::grpc::functional_status(
"holger-http/properties",
"properties_served",
status == 200,
&format!("{status} {label}"),
);
let ct = if status == 200 { "application/json" } else { "text/plain" };
return Ok(Response::builder()
.status(status)
.header("Content-Type", ct)
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")));
}
if path == "/metrics" {
let repos = settings.routes.all_repos();
let body = tokio::task::spawn_blocking(move || {
let repo_counts: Vec<(String, usize)> = repos
.iter()
.map(|(name, backend)| {
let n = backend
.list(None, METRICS_LIST_CAP)
.map(|v| v.len())
.unwrap_or(0);
(name.clone(), n)
})
.collect();
crate::metrics::global().render_prometheus(&repo_counts)
})
.await;
let (status, body) = match body {
Ok(text) => (200u16, text.into_bytes()),
Err(e) => (500u16, format!("metrics task failed: {e}").into_bytes()),
};
crate::grpc::functional_status(
"holger-http/metrics",
"metrics_served",
status == 200,
&format!("{} bytes", body.len()),
);
return Ok(Response::builder()
.status(status)
.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")));
}
}
if path == "/-/properties" && matches!(method.as_str(), "PUT" | "POST" | "DELETE") {
let raw_q = req.uri().query().unwrap_or("").to_owned();
let coord = parse_sbom_coord(&raw_q);
let bearer = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let identity =
match auth::validate_request(&settings.auth_config, bearer, client_cn.as_deref()).await {
Ok(id) => id,
Err(_) => return Ok(text(401, "Unauthorized")),
};
if let Err((status, detail)) =
authorize_http_write(&settings.auth_config, identity.as_ref(), &method, &coord.repository)
{
let who = identity.as_ref().map(|i| i.subject.as_str()).unwrap_or("anonymous");
let ev = AuditEvent::new(who, AuditAction::Upload, &coord.repository, &path, &peer, status, 0)
.with_detail(detail.to_string());
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
let msg = if status == 403 { "Forbidden" } else { "Unauthorized" };
return Ok(text(status, msg));
}
let who = identity.map(|i| i.subject).unwrap_or_else(|| "anonymous".to_string());
let (status, body, repo, label) =
serve_properties_write(settings.properties.as_ref(), &method, &raw_q);
let action = if method == "DELETE" { AuditAction::Delete } else { AuditAction::Upload };
let ev = AuditEvent::new(&who, action, &repo, &label, &peer, status, 0);
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
crate::grpc::functional_status(
"holger-http/properties_write",
"property_written",
status == 200,
&format!("{status} {label}"),
);
crate::metrics::global().record_request(
if method == "DELETE" { crate::metrics::Verb::Delete } else { crate::metrics::Verb::Put },
status,
0,
);
crate::metrics::global().observe_request_duration(started.elapsed().as_secs_f64());
let ct = if status == 200 { "application/json" } else { "text/plain" };
return Ok(Response::builder()
.status(status)
.header("Content-Type", ct)
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")));
}
let is_read = method == "GET" || method == "HEAD";
let action = if is_read {
AuditAction::Download
} else if method == "DELETE" {
AuditAction::Delete
} else {
AuditAction::Upload
};
let repo_key = route_key(&path).unwrap_or_default().to_owned();
let metric_verb = crate::metrics::Verb::from_audit_action(action).unwrap_or(crate::metrics::Verb::Get);
let record = |ident: &str, status: u16, bytes: u64, detail: &str| {
crate::metrics::global().record_request(metric_verb, status, bytes);
crate::metrics::global().observe_request_duration(started.elapsed().as_secs_f64());
let mut ev =
AuditEvent::new(ident, action, &repo_key, &path, &peer, status, bytes);
if !detail.is_empty() {
ev = ev.with_detail(detail);
}
if let Err(e) = settings.audit.record(ev) {
log::warn!("audit record failed: {e}");
}
};
let ident = if is_read {
"anonymous".to_string()
} else {
let bearer = req
.headers()
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "));
let identity =
match auth::validate_request(&settings.auth_config, bearer, client_cn.as_deref()).await
{
Ok(id) => id,
Err(_) => {
record("anonymous", 401, 0, "unauthorized");
return Ok(text(401, "Unauthorized"));
}
};
if let Err((status, detail)) =
authorize_http_write(&settings.auth_config, identity.as_ref(), &method, &repo_key)
{
let who = identity
.as_ref()
.map(|i| i.subject.as_str())
.unwrap_or("anonymous");
record(who, status, 0, detail);
let msg = if status == 403 { "Forbidden" } else { "Unauthorized" };
return Ok(text(status, msg));
}
identity
.map(|i| i.subject)
.unwrap_or_else(|| "anonymous".to_string())
};
let backend = match routes_lookup(&settings.routes, &path) {
Some(b) => b,
None => {
record(&ident, 404, 0, "unknown repository");
return Ok(text(404, "Unknown repository"));
}
};
if is_read
&& serve_quarantined(
settings.quarantine.as_ref(),
settings.properties.as_ref(),
backend.as_ref(),
&path,
&repo_key,
)
{
record(&ident, 404, 0, "quarantined — refused at serve boundary");
crate::grpc::functional_status("holger-http/quarantine", "quarantine_blocked", true, &path);
return Ok(text(404, "Not found"));
}
if !is_read && !backend.is_writable() {
record(&ident, 403, 0, "repository is read-only");
return Ok(text(403, "Forbidden"));
}
let body = match Limited::new(req.into_body(), settings.max_body_bytes)
.collect()
.await
{
Ok(collected) => collected.to_bytes(),
Err(_) => {
record(&ident, 413, 0, "payload too large");
return Ok(text(413, "Payload too large"));
}
};
if !is_read {
if let Err((status, detail)) =
verify_upload_checksum(want_sha256.as_deref(), want_sha512.as_deref(), &body)
{
record(&ident, status, body.len() as u64, detail);
return Ok(text(status, detail));
}
}
let dispatch = tokio::task::spawn_blocking(move || {
backend.handle_http2_request(&method, &path_and_query, &body)
})
.await;
match dispatch {
Ok(Ok((status, headers, body))) => {
record(&ident, status, body.len() as u64, "");
let mut builder = Response::builder().status(status);
for (k, v) in headers {
builder = builder.header(k, v);
}
Ok(builder
.body(Full::new(Bytes::from(body)))
.unwrap_or_else(|_| text(500, "Internal error")))
}
Ok(Err(e)) => {
record(&ident, 500, 0, &e.to_string());
log::warn!("backend error for {}: {:#}", path, e);
Ok(text(500, "Internal error"))
}
Err(_) => {
record(&ident, 500, 0, "dispatch task failed");
Ok(text(500, "Internal error"))
}
}
}
fn verify_upload_checksum(
want_sha256: Option<&str>,
want_sha512: Option<&str>,
body: &[u8],
) -> Result<(), (u16, &'static str)> {
use sha2::{Digest, Sha256, Sha512};
if let Some(expected) = want_sha256 {
let expected = expected.trim();
if !expected.is_empty() {
let got = hex::encode(Sha256::digest(body));
if !got.eq_ignore_ascii_case(expected) {
return Err((400, "sha256 checksum mismatch"));
}
}
}
if let Some(expected) = want_sha512 {
let expected = expected.trim();
if !expected.is_empty() {
let got = hex::encode(Sha512::digest(body));
if !got.eq_ignore_ascii_case(expected) {
return Err((400, "sha512 checksum mismatch"));
}
}
}
Ok(())
}
fn form_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = |c: u8| -> Option<u8> {
match c {
b'0'..=b'9' => Some(c - b'0'),
b'a'..=b'f' => Some(c - b'a' + 10),
b'A'..=b'F' => Some(c - b'A' + 10),
_ => None,
}
};
match (hex(bytes[i + 1]), hex(bytes[i + 2])) {
(Some(h), Some(l)) => {
out.push(h << 4 | l);
i += 3;
}
_ => {
out.push(b'%');
i += 1;
}
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
fn parse_search_query(raw: &str) -> crate::search::SearchQuery {
let mut q = crate::search::SearchQuery::default();
for pair in raw.split('&').filter(|s| !s.is_empty()) {
let (key, val) = match pair.split_once('=') {
Some((k, v)) => (k, form_decode(v)),
None => (pair, String::new()),
};
let set = |slot: &mut Option<String>, v: String| {
if !v.is_empty() {
*slot = Some(v);
}
};
match key {
"name" => set(&mut q.name, val),
"namespace" | "group" => set(&mut q.namespace, val),
"version" => set(&mut q.version, val),
"checksum" | "sha256" => set(&mut q.checksum, val),
"path" => set(&mut q.path, val),
"property" | "prop" => {
if !val.is_empty() {
let (k, v) = match val.split_once(':') {
Some((k, v)) => (k.to_string(), v.to_string()),
None => (val, String::new()),
};
if !k.is_empty() {
q.properties.push((k, v));
}
}
}
"repo" | "repository" => {
if !val.is_empty() {
q.repositories.push(val);
}
}
"limit" => {
if let Ok(n) = val.parse::<usize>() {
q.limit = n;
}
}
_ => {}
}
}
q
}
pub(crate) struct SbomCoordQuery {
pub repository: String,
pub id: traits::ArtifactId,
}
fn parse_sbom_coord(raw: &str) -> SbomCoordQuery {
let (mut repository, mut namespace, mut name, mut version) =
(String::new(), String::new(), String::new(), String::new());
for pair in raw.split('&').filter(|s| !s.is_empty()) {
let (key, val) = match pair.split_once('=') {
Some((k, v)) => (k, form_decode(v)),
None => (pair, String::new()),
};
match key {
"repo" | "repository" => repository = val,
"namespace" | "group" => namespace = val,
"name" => name = val,
"version" => version = val,
_ => {}
}
}
SbomCoordQuery {
repository,
id: traits::ArtifactId {
namespace: if namespace.is_empty() { None } else { Some(namespace) },
name,
version,
},
}
}
pub(crate) fn serve_sbom_door(
sboms: Option<&crate::sbom::SharedSbomStore>,
raw_query: &str,
) -> (u16, &'static str, Vec<u8>, String, String) {
let coord = parse_sbom_coord(raw_query);
let label = format!(
"{}/{}@{}",
coord.id.namespace.as_deref().unwrap_or("-"),
coord.id.name,
coord.id.version,
);
let (status, ct, body): (u16, &'static str, Vec<u8>) = if coord.repository.is_empty()
|| coord.id.name.is_empty()
|| coord.id.version.is_empty()
{
(400, "text/plain", b"sbom query needs repo, name and version".to_vec())
} else {
match sboms.map(|s| s.fetch(&coord.repository, &coord.id)) {
Some(Ok(Some(doc))) => (200, "application/json", doc),
Some(Ok(None)) | None => {
(404, "text/plain", b"no SBOM hosted for that coordinate".to_vec())
}
Some(Err(e)) => (500, "text/plain", format!("sbom read error: {e}").into_bytes()),
}
};
(status, ct, body, coord.repository, label)
}
pub(crate) fn serve_properties_door(
props: Option<&crate::properties::SharedPropertyStore>,
raw_query: &str,
) -> (u16, Vec<u8>, String, String) {
let coord = parse_sbom_coord(raw_query);
let label = format!(
"{}/{}@{}",
coord.id.namespace.as_deref().unwrap_or("-"),
coord.id.name,
coord.id.version,
);
if coord.repository.is_empty() || coord.id.name.is_empty() || coord.id.version.is_empty() {
return (
400,
b"properties query needs repo, name and version".to_vec(),
coord.repository,
label,
);
}
let map = props
.map(|p| p.get(&coord.repository, &coord.id))
.unwrap_or_default();
let body = serde_json::to_vec(&map).unwrap_or_else(|_| b"{}".to_vec());
(200, body, coord.repository, label)
}
pub(crate) fn serve_properties_write(
props: Option<&crate::properties::SharedPropertyStore>,
method: &str,
raw_query: &str,
) -> (u16, Vec<u8>, String, String) {
let coord = parse_sbom_coord(raw_query);
let (mut key, mut values) = (String::new(), Vec::<String>::new());
for pair in raw_query.split('&').filter(|s| !s.is_empty()) {
let (k, v) = match pair.split_once('=') {
Some((k, v)) => (k, form_decode(v)),
None => (pair, String::new()),
};
match k {
"key" => key = v,
"value" => {
if !v.is_empty() {
values.push(v);
}
}
_ => {}
}
}
let label = format!(
"{}/{}@{}",
coord.id.namespace.as_deref().unwrap_or("-"),
coord.id.name,
coord.id.version,
);
if coord.repository.is_empty()
|| coord.id.name.is_empty()
|| coord.id.version.is_empty()
|| key.is_empty()
{
return (
400,
b"properties write needs repo, name, version and key".to_vec(),
coord.repository,
label,
);
}
let store = match props {
Some(s) => s,
None => {
return (503, b"property store not configured".to_vec(), coord.repository, label)
}
};
let effective = if method == "DELETE" { Vec::new() } else { values };
match store.set(&coord.repository, &coord.id, &key, effective) {
Ok(map) => {
let body = serde_json::to_vec(&map).unwrap_or_else(|_| b"{}".to_vec());
(200, body, coord.repository, label)
}
Err(e) => (500, format!("property write error: {e}").into_bytes(), coord.repository, label),
}
}
fn route_key(path: &str) -> Option<&str> {
let segs: Vec<&str> = path.trim_start_matches('/').split('/').filter(|s| !s.is_empty()).collect();
match segs.as_slice() {
["v2", name, ..] => Some(*name),
[first, ..] => Some(*first),
[] => None,
}
}
fn authorize_http_write(
auth_config: &AuthConfig,
identity: Option<&auth::AuthIdentity>,
method: &str,
repo: &str,
) -> Result<(), (u16, &'static str)> {
if !auth_config.rbac_enabled() {
return Ok(());
}
match identity {
Some(id) => {
let role = auth_config.role_for_repo(repo, &id.subject);
let permitted = if method == "DELETE" {
role.can_admin()
} else {
role.can_write()
};
crate::grpc::functional_status(
"holger-http/authorize_http_write",
"rbac_write_permitted",
permitted,
method,
);
if permitted {
Ok(())
} else {
Err((403, "insufficient role"))
}
}
None => {
crate::grpc::functional_status(
"holger-http/authorize_http_write",
"rbac_write_permitted",
false,
"rbac configured but no authenticated identity",
);
Err((401, "authentication required"))
}
}
}
fn routes_lookup(
routes: &FastRoutes,
path: &str,
) -> Option<std::sync::Arc<dyn traits::RepositoryBackendTrait>> {
routes.lookup(route_key(path)?).cloned()
}
fn serve_quarantined(
quarantine: Option<&(String, String)>,
properties: Option<&crate::properties::SharedPropertyStore>,
backend: &dyn traits::RepositoryBackendTrait,
path: &str,
repo_key: &str,
) -> bool {
match (quarantine, properties) {
(Some((key, value)), Some(store)) => match backend.coordinate_for_path(path) {
Some(id) => crate::properties::map_matches(&store.get(repo_key, &id), key, value),
None => false,
},
_ => false,
}
}
fn text(status: u16, msg: &str) -> Response<Full<Bytes>> {
Response::builder()
.status(status)
.header("Content-Type", "text/plain")
.body(Full::new(Bytes::from(msg.to_owned())))
.expect("static response")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{AuthConfig, AuthIdentity, Role};
use std::collections::HashMap;
fn ident(sub: &str) -> AuthIdentity {
AuthIdentity { subject: sub.to_string(), method: "mtls".into() }
}
fn cfg_with_roles(pairs: &[(&str, Role)], default_role: Option<Role>) -> AuthConfig {
let mut roles = HashMap::new();
for (s, r) in pairs {
roles.insert((*s).to_string(), *r);
}
AuthConfig { methods: vec![], roles, default_role, ..Default::default() }
}
const R: &str = "any-repo";
#[test]
fn rbac_off_allows_all_writes() {
let cfg = AuthConfig::default();
assert!(authorize_http_write(&cfg, Some(&ident("alice")), "PUT", R).is_ok());
assert!(authorize_http_write(&cfg, None, "PUT", R).is_ok());
assert!(authorize_http_write(&cfg, None, "DELETE", R).is_ok());
}
#[test]
fn rbac_on_reader_cannot_write_or_delete() {
let cfg = cfg_with_roles(&[("bob", Role::Reader)], None);
assert_eq!(
authorize_http_write(&cfg, Some(&ident("bob")), "PUT", R),
Err((403, "insufficient role"))
);
assert_eq!(
authorize_http_write(&cfg, Some(&ident("bob")), "POST", R),
Err((403, "insufficient role"))
);
assert_eq!(
authorize_http_write(&cfg, Some(&ident("bob")), "DELETE", R),
Err((403, "insufficient role"))
);
}
#[test]
fn rbac_on_writer_uploads_admin_deletes() {
let cfg = cfg_with_roles(&[("wendy", Role::Writer), ("alice", Role::Admin)], None);
assert!(authorize_http_write(&cfg, Some(&ident("wendy")), "PUT", R).is_ok());
assert_eq!(
authorize_http_write(&cfg, Some(&ident("wendy")), "DELETE", R),
Err((403, "insufficient role")),
"writer must not delete"
);
assert!(authorize_http_write(&cfg, Some(&ident("alice")), "PUT", R).is_ok());
assert!(authorize_http_write(&cfg, Some(&ident("alice")), "DELETE", R).is_ok());
}
#[test]
fn rbac_on_anonymous_write_fails_closed() {
let cfg = cfg_with_roles(&[("alice", Role::Admin)], None);
assert_eq!(
authorize_http_write(&cfg, None, "PUT", R),
Err((401, "authentication required"))
);
assert_eq!(
authorize_http_write(&cfg, Some(&ident("stranger")), "PUT", R),
Err((403, "insufficient role"))
);
}
#[test]
fn rbac_default_role_writer() {
let cfg = cfg_with_roles(&[], Some(Role::Writer));
assert!(cfg.rbac_enabled());
assert!(authorize_http_write(&cfg, Some(&ident("anyone")), "PUT", R).is_ok());
assert_eq!(
authorize_http_write(&cfg, Some(&ident("anyone")), "DELETE", R),
Err((403, "insufficient role"))
);
}
#[test]
fn rbac_per_repo_acl_scopes_the_write_gate() {
let mut cfg = cfg_with_roles(&[("carol", Role::Reader)], None);
cfg.repo_roles.insert(
"team-a-dev".to_string(),
HashMap::from([("carol".to_string(), Role::Writer)]),
);
assert!(authorize_http_write(&cfg, Some(&ident("carol")), "PUT", "team-a-dev").is_ok());
assert_eq!(
authorize_http_write(&cfg, Some(&ident("carol")), "DELETE", "team-a-dev"),
Err((403, "insufficient role"))
);
assert_eq!(
authorize_http_write(&cfg, Some(&ident("carol")), "PUT", "other-repo"),
Err((403, "insufficient role"))
);
}
use sha2::{Digest as _, Sha512};
fn sha256_hex(b: &[u8]) -> String {
nornir_hash::sha256_hex(b)
}
fn sha512_hex(b: &[u8]) -> String {
hex::encode(Sha512::digest(b))
}
#[test]
fn checksum_absent_is_ok() {
assert!(verify_upload_checksum(None, None, b"anything").is_ok());
}
#[test]
fn checksum_empty_header_is_ok() {
assert!(verify_upload_checksum(Some(""), Some(" "), b"payload").is_ok());
}
#[test]
fn checksum_sha256_match_ok() {
let body = b"the artifact bytes";
assert!(verify_upload_checksum(Some(&sha256_hex(body)), None, body).is_ok());
}
#[test]
fn checksum_sha256_uppercase_ok() {
let body = b"MiXeD case digest";
let up = sha256_hex(body).to_uppercase();
assert!(verify_upload_checksum(Some(&up), None, body).is_ok());
}
#[test]
fn checksum_sha256_mismatch_rejected() {
let body = b"the artifact bytes";
let wrong = sha256_hex(b"different bytes entirely");
assert_eq!(
verify_upload_checksum(Some(&wrong), None, body),
Err((400, "sha256 checksum mismatch"))
);
}
#[test]
fn checksum_sha256_garbage_rejected() {
assert_eq!(
verify_upload_checksum(Some("deadbeef"), None, b"x"),
Err((400, "sha256 checksum mismatch"))
);
}
#[test]
fn checksum_sha512_match_and_mismatch() {
let body = b"sha512 covered payload";
assert!(verify_upload_checksum(None, Some(&sha512_hex(body)), body).is_ok());
let wrong = sha512_hex(b"nope");
assert_eq!(
verify_upload_checksum(None, Some(&wrong), body),
Err((400, "sha512 checksum mismatch"))
);
}
#[test]
fn checksum_both_digests_match_ok() {
let body = b"double-checked artifact";
assert!(verify_upload_checksum(
Some(&sha256_hex(body)),
Some(&sha512_hex(body)),
body
)
.is_ok());
}
#[test]
fn search_query_parses_every_axis() {
let q = parse_search_query(
"name=serde&group=org.example&version=1.0.0&sha256=ABC&path=%2Ftmp%2Fx&repo=a&repo=b&limit=25",
);
assert_eq!(q.name.as_deref(), Some("serde"));
assert_eq!(q.namespace.as_deref(), Some("org.example"));
assert_eq!(q.version.as_deref(), Some("1.0.0"));
assert_eq!(q.checksum.as_deref(), Some("ABC"));
assert_eq!(q.path.as_deref(), Some("/tmp/x"), "percent-decode applied");
assert_eq!(q.repositories, vec!["a".to_string(), "b".to_string()]);
assert_eq!(q.limit, 25);
}
#[test]
fn search_query_empty_and_unknown_keys() {
assert!(parse_search_query("").is_empty());
let q = parse_search_query("name=&bogus=x&version=");
assert!(q.is_empty(), "empty values + unknown keys ⇒ no criteria set");
}
#[test]
fn search_query_plus_is_space() {
let q = parse_search_query("name=hello+world");
assert_eq!(q.name.as_deref(), Some("hello world"));
}
#[test]
fn sbom_coord_parses_axes() {
let c = parse_sbom_coord("repo=rust-dev&group=org.example&name=serde&version=1.0.0&x=y");
assert_eq!(c.repository, "rust-dev");
assert_eq!(c.id.namespace.as_deref(), Some("org.example"));
assert_eq!(c.id.name, "serde");
assert_eq!(c.id.version, "1.0.0");
let c2 = parse_sbom_coord("repo=r&name=n&version=1");
assert!(c2.id.namespace.is_none());
}
#[test]
fn sbom_door_serves_byte_identical_and_404s_missing() {
let tmp = tempfile::tempdir().unwrap();
let store: crate::sbom::SharedSbomStore = Arc::new(crate::sbom::SbomStore::new(tmp.path()));
let coord = traits::ArtifactId {
namespace: None,
name: "serde".into(),
version: "1.0.0".into(),
};
let doc = crate::sbom::build_cyclonedx(&crate::sbom::SbomInput {
subject: "rust-dev".into(),
components: vec![],
vulns: vec![],
});
store.attach("rust-dev", &coord, &doc).unwrap();
let (status, ct, body, repo, _label) =
serve_sbom_door(Some(&store), "repo=rust-dev&name=serde&version=1.0.0");
assert_eq!(status, 200);
assert_eq!(ct, "application/json");
assert_eq!(body, doc, "the door serves the exact attached SBOM bytes");
assert_eq!(repo, "rust-dev");
let (miss, _, _, _, _) =
serve_sbom_door(Some(&store), "repo=rust-dev&name=serde&version=2.0.0");
assert_eq!(miss, 404);
let (bad, _, _, _, _) = serve_sbom_door(Some(&store), "name=serde");
assert_eq!(bad, 400);
let (none, _, _, _, _) =
serve_sbom_door(None, "repo=rust-dev&name=serde&version=1.0.0");
assert_eq!(none, 404);
}
#[test]
fn search_query_parses_property_filters() {
let q = parse_search_query("name=serde&property=env:prod&prop=team:core&property=reviewed");
assert_eq!(q.name.as_deref(), Some("serde"));
assert_eq!(
q.properties,
vec![
("env".to_string(), "prod".to_string()),
("team".to_string(), "core".to_string()),
("reviewed".to_string(), String::new()), ]
);
let q2 = parse_search_query("property=url:https://x.y/z");
assert_eq!(q2.properties, vec![("url".to_string(), "https://x.y/z".to_string())]);
}
#[test]
fn properties_door_serves_map_and_validates_coordinate() {
let tmp = tempfile::tempdir().unwrap();
let store: crate::properties::SharedPropertyStore =
Arc::new(crate::properties::PropertyStore::new(tmp.path()));
let coord = traits::ArtifactId { namespace: None, name: "serde".into(), version: "1.0.0".into() };
store.set("rust-dev", &coord, "env", vec!["prod".into()]).unwrap();
let (status, body, repo, _label) =
serve_properties_door(Some(&store), "repo=rust-dev&name=serde&version=1.0.0");
assert_eq!(status, 200);
assert_eq!(repo, "rust-dev");
let json = String::from_utf8(body).unwrap();
assert!(json.contains("\"env\"") && json.contains("\"prod\""), "map serialized: {json}");
let (s2, b2, _, _) =
serve_properties_door(Some(&store), "repo=rust-dev&name=serde&version=2.0.0");
assert_eq!(s2, 200);
assert_eq!(String::from_utf8(b2).unwrap(), "{}", "unset coord ⇒ empty map, no leak");
let (bad, _, _, _) = serve_properties_door(Some(&store), "name=serde");
assert_eq!(bad, 400);
let (none, nb, _, _) =
serve_properties_door(None, "repo=rust-dev&name=serde&version=1.0.0");
assert_eq!(none, 200);
assert_eq!(String::from_utf8(nb).unwrap(), "{}");
}
#[test]
fn properties_write_door_sets_and_removes() {
let tmp = tempfile::tempdir().unwrap();
let store: crate::properties::SharedPropertyStore =
Arc::new(crate::properties::PropertyStore::new(tmp.path()));
let (s, body, repo, _l) = serve_properties_write(
Some(&store),
"PUT",
"repo=rust-dev&name=serde&version=1.0.0&key=env&value=prod&value=staging",
);
assert_eq!(s, 200);
assert_eq!(repo, "rust-dev");
assert!(String::from_utf8(body).unwrap().contains("staging"), "response echoes the map");
let coord = traits::ArtifactId { namespace: None, name: "serde".into(), version: "1.0.0".into() };
assert_eq!(
store.get("rust-dev", &coord).get("env").unwrap(),
&vec!["prod".to_string(), "staging".to_string()]
);
let (sd, _b, _r, _l) =
serve_properties_write(Some(&store), "DELETE", "repo=rust-dev&name=serde&version=1.0.0&key=env");
assert_eq!(sd, 200);
assert!(store.get("rust-dev", &coord).is_empty(), "DELETE removed the key");
let (bad, _b, _r, _l) =
serve_properties_write(Some(&store), "PUT", "repo=rust-dev&name=serde&version=1.0.0");
assert_eq!(bad, 400);
let (unavail, _b, _r, _l) =
serve_properties_write(None, "PUT", "repo=r&name=n&version=1&key=k&value=v");
assert_eq!(unavail, 503);
}
}