pub(crate) mod ansible;
mod cargo_registry;
mod conan;
pub(crate) mod deb;
pub mod docker;
pub mod docker_auth;
pub(crate) mod gems;
mod go;
mod maven;
mod npm;
pub(crate) mod nuget;
pub(crate) mod pub_dart;
mod pypi;
pub(crate) mod range;
mod raw;
pub(crate) mod rpm;
pub(crate) mod terraform;
#[cfg(test)]
mod ns_isolation_metadata_tests;
pub use ansible::routes as ansible_routes;
pub use cargo_registry::routes as cargo_routes;
pub use conan::routes as conan_routes;
pub use deb::routes as deb_routes;
pub use docker::routes as docker_routes;
pub use docker_auth::DockerAuth;
pub use gems::routes as gems_routes;
pub use go::routes as go_routes;
pub use maven::routes as maven_routes;
pub use npm::routes as npm_routes;
pub(crate) use maven::storage_key as maven_storage_key;
pub use nuget::alias_routes as nuget_alias_routes;
pub use nuget::routes as nuget_routes;
pub use pub_dart::routes as pub_dart_routes;
pub use pypi::routes as pypi_routes;
pub use raw::routes as raw_routes;
pub(crate) use raw::storage_key as raw_storage_key;
pub use rpm::routes as rpm_routes;
pub use terraform::routes as terraform_routes;
use crate::circuit_breaker::CircuitBreakerRegistry;
use crate::config::basic_auth_header;
use crate::metrics::{UPSTREAM_POLICY_BLOCKED_TOTAL, UPSTREAM_REQUEST_DURATION};
use crate::registry_type::RegistryType;
use crate::AppState;
use axum::body::{Body, Bytes};
use axum::http::{header, StatusCode};
use axum::response::{IntoResponse, Response};
use futures::StreamExt;
use std::time::{Duration, Instant};
pub(crate) fn method_not_allowed(allow: &'static str) -> Response {
(StatusCode::METHOD_NOT_ALLOWED, [(header::ALLOW, allow)]).into_response()
}
const SIDECAR_READ_CONCURRENCY: usize = 32;
pub(crate) async fn read_json_sidecars<T: serde::de::DeserializeOwned>(
storage: &crate::storage::Storage,
prefix: &str,
) -> Result<Vec<T>, String> {
use futures::TryStreamExt;
let keys = storage
.list(prefix)
.await
.map_err(|e| format!("list sidecars: {e}"))?;
futures::stream::iter(keys)
.map(|key| async move {
let data = storage
.get(&key)
.await
.map_err(|e| format!("read sidecar {key}: {e}"))?;
serde_json::from_slice::<T>(&data).map_err(|e| format!("parse sidecar {key}: {e}"))
})
.buffer_unordered(SIDECAR_READ_CONCURRENCY)
.try_collect()
.await
}
pub(crate) fn proxied_repo_conflict() -> Response {
(
StatusCode::CONFLICT,
"Repository is a pull-through proxy (read-only)",
)
.into_response()
}
pub(crate) fn replace_url_escape_aware(text: &str, from: &str, to: &str) -> String {
let plain = text.replace(from, to);
let esc_from = from.replace('/', "\\/");
if !plain.contains(&esc_from) {
return plain;
}
let esc_to = to.replace('/', "\\/");
plain.replace(&esc_from, &esc_to)
}
pub(crate) fn nora_base_url(state: &AppState) -> String {
state.config.server.public_base_url()
}
#[derive(Debug)]
#[allow(dead_code)]
pub(crate) enum ProxyError {
NotFound,
Upstream(u16),
Network(String),
CircuitOpen(String),
}
pub(crate) fn circuit_open_response(registry: &str) -> Response {
(
StatusCode::SERVICE_UNAVAILABLE,
[("retry-after", "30")],
format!("upstream {} temporarily unavailable", registry),
)
.into_response()
}
fn policy_block_reason(headers: &reqwest::header::HeaderMap) -> Option<&'static str> {
let reason = headers.get("x-amzn-waf-reason")?;
let is_geo = reason
.to_str()
.map(|v| v.eq_ignore_ascii_case("geo"))
.unwrap_or(false);
Some(if is_geo { "geo" } else { "waf" })
}
#[allow(clippy::too_many_arguments)]
async fn proxy_fetch_core<T, F, Fut>(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: Option<&str>,
extra_headers: Option<(&str, &str)>,
extract: F,
cb: &CircuitBreakerRegistry,
registry: RegistryType,
) -> Result<T, ProxyError>
where
F: Fn(reqwest::Response) -> Fut + Copy,
Fut: std::future::Future<Output = Result<T, reqwest::Error>>,
{
let registry_str = registry.as_str();
let probe = cb.check(registry_str)?;
for attempt in 0..2 {
let mut request = client.get(url).timeout(timeout);
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
if let Some((key, val)) = extra_headers {
request = request.header(key, val);
}
let upstream_start = Instant::now();
match request.send().await {
Ok(response) => {
let elapsed = upstream_start.elapsed().as_secs_f64();
if response.status().is_success() {
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "2xx"])
.observe(elapsed);
let result = extract(response)
.await
.map_err(|e| ProxyError::Network(e.to_string()));
if result.is_ok() {
cb.record_success(registry_str, probe);
} else {
cb.record_failure(registry_str, probe);
}
return result;
}
let status = response.status().as_u16();
if (400..500).contains(&status) {
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "4xx"])
.observe(elapsed);
if let Some(reason) = policy_block_reason(response.headers()) {
UPSTREAM_POLICY_BLOCKED_TOTAL
.with_label_values(&[registry_str, reason])
.inc();
tracing::warn!(
registry = registry_str,
url,
status,
reason,
"upstream returned a policy/geo block, relayed as 404 (not a genuine not-found) — check egress/region"
);
}
cb.record_alive(registry_str, probe);
return Err(ProxyError::NotFound);
}
if attempt == 0 {
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "5xx"])
.observe(elapsed);
tracing::debug!(url, status, "upstream 5xx, retrying in 1s");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "5xx"])
.observe(elapsed);
cb.record_failure(registry_str, probe);
return Err(ProxyError::Upstream(status));
}
Err(e) => {
let elapsed = upstream_start.elapsed().as_secs_f64();
let status_label = if e.is_timeout() { "timeout" } else { "error" };
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, status_label])
.observe(elapsed);
if attempt == 0 {
tracing::debug!(url, error = %e, "upstream error, retrying in 1s");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
cb.record_failure(registry_str, probe);
return Err(ProxyError::Network(e.to_string()));
}
}
}
cb.record_failure(registry_str, probe);
Err(ProxyError::Network("max retries exceeded".into()))
}
pub(crate) async fn proxy_fetch(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: Option<&str>,
cb: &CircuitBreakerRegistry,
registry: RegistryType,
) -> Result<Vec<u8>, ProxyError> {
proxy_fetch_core(
client,
url,
timeout,
auth,
None,
|r| async { r.bytes().await.map(|b| b.to_vec()) },
cb,
registry,
)
.await
}
pub(crate) async fn proxy_fetch_text(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: Option<&str>,
extra_headers: Option<(&str, &str)>,
cb: &CircuitBreakerRegistry,
registry: RegistryType,
) -> Result<String, ProxyError> {
proxy_fetch_core(
client,
url,
timeout,
auth,
extra_headers,
|r| r.text(),
cb,
registry,
)
.await
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn repo_proxy_download(
state: &AppState,
registry: &'static str,
rt: crate::registry_type::RegistryType,
display: String,
key: String,
url: String,
auth: Option<&str>,
timeout_secs: u64,
metadata_ttl: i64,
immutable: bool,
content_type: &'static str,
) -> Response {
use crate::activity_log::{ActionType, ActivityEntry};
use crate::audit::AuditEntry;
let q_override = match registry {
"rpm" => &state.config.curation.rpm,
_ => &state.config.curation.deb,
};
let (q_mode, q_secs) = crate::digest_quarantine::resolve_global(
q_override
.quarantine
.as_ref()
.or(state.config.curation.quarantine.as_ref()),
q_override
.quarantine_ttl
.as_deref()
.or(state.config.curation.quarantine_ttl.as_deref()),
);
let serve = |data: Bytes| {
let mut builder = axum::http::Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type);
if !immutable {
builder = builder.header(header::CACHE_CONTROL, "no-cache");
}
builder.body(Body::from(data)).expect("valid response")
};
let cached = state.storage.get(&key).await.ok();
let cache_fresh = match &cached {
None => false,
Some(_) if immutable => true,
Some(_) => {
let modified = state.storage.stat(&key).await.map(|m| m.modified);
crate::cache_ttl::mutable_ref_fresh(true, metadata_ttl, modified)
}
};
if let Some(ref data) = cached {
if cache_fresh {
state.metrics.record_download(registry);
state.metrics.record_cache_hit(registry);
state.activity.push(ActivityEntry::new(
ActionType::CacheHit,
display,
rt,
"CACHE",
));
state
.audit
.log(AuditEntry::new("cache_hit", "api", "", registry, ""));
if immutable {
if let Some(resp) = crate::digest_quarantine::proxy_gate_dated(
&state.digest_store,
registry,
data,
&q_mode,
q_secs,
"cache",
None,
) {
return resp;
}
}
return serve(data.clone()).into_response();
}
}
match proxy_fetch(
&state.http_client,
&url,
Duration::from_secs(timeout_secs),
auth,
&state.circuit_breaker,
rt,
)
.await
{
Ok(data) => {
let data = Bytes::from(data);
state.metrics.record_download(registry);
state.metrics.record_cache_miss(registry);
state.activity.push(ActivityEntry::new(
ActionType::ProxyFetch,
display,
rt,
"PROXY",
));
state
.audit
.log(AuditEntry::new("proxy_fetch", "api", "", registry, ""));
if immutable {
state.spawn_cache_immutable(registry, key, data.clone());
if let Some(resp) = crate::digest_quarantine::proxy_gate_dated(
&state.digest_store,
registry,
&data,
&q_mode,
q_secs,
&url,
None,
) {
return resp;
}
} else {
state.spawn_cache(registry, key, data.clone());
}
serve(data).into_response()
}
Err(ProxyError::CircuitOpen(reg)) => circuit_open_response(®),
Err(e) => {
if let Some(data) = cached {
tracing::warn!(registry, url = %url, error = ?e, "upstream failed, serving stale cached copy");
if immutable {
if let Some(resp) = crate::digest_quarantine::proxy_gate_dated(
&state.digest_store,
registry,
&data,
&q_mode,
q_secs,
"cache-stale",
None,
) {
return resp;
}
}
let mut response = serve(data).into_response();
response.headers_mut().insert(
header::HeaderName::from_static("x-nora-stale"),
header::HeaderValue::from_static("true"),
);
return response;
}
tracing::debug!(registry, url = %url, error = ?e, "proxy fetch failed with no cached copy");
StatusCode::NOT_FOUND.into_response()
}
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) async fn proxy_forward_post(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: Option<&str>,
fwd_headers: &[(&str, &str)],
body: &[u8],
cb: &CircuitBreakerRegistry,
registry: RegistryType,
) -> Result<(u16, Vec<u8>, Option<String>), ProxyError> {
let registry_str = registry.as_str();
let probe = cb.check(registry_str)?;
for attempt in 0..2 {
let mut request = client.post(url).timeout(timeout).body(body.to_vec());
if let Some(credentials) = auth {
request = request.header("Authorization", basic_auth_header(credentials));
}
for (k, v) in fwd_headers {
request = request.header(*k, *v);
}
let upstream_start = Instant::now();
match request.send().await {
Ok(response) => {
let elapsed = upstream_start.elapsed().as_secs_f64();
let code = response.status().as_u16();
let content_type = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.map(str::to_owned);
if response.status().is_success() {
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "2xx"])
.observe(elapsed);
match response.bytes().await {
Ok(b) => {
cb.record_success(registry_str, probe);
return Ok((code, b.to_vec(), content_type));
}
Err(e) => {
cb.record_failure(registry_str, probe);
return Err(ProxyError::Network(e.to_string()));
}
}
}
if (400..500).contains(&code) {
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "4xx"])
.observe(elapsed);
cb.record_alive(registry_str, probe);
let b = response
.bytes()
.await
.map(|b| b.to_vec())
.unwrap_or_default();
return Ok((code, b, content_type));
}
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, "5xx"])
.observe(elapsed);
if attempt == 0 {
tracing::debug!(url, status = code, "upstream 5xx on POST, retrying in 1s");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
cb.record_failure(registry_str, probe);
return Err(ProxyError::Upstream(code));
}
Err(e) => {
let elapsed = upstream_start.elapsed().as_secs_f64();
let status_label = if e.is_timeout() { "timeout" } else { "error" };
UPSTREAM_REQUEST_DURATION
.with_label_values(&[registry_str, status_label])
.observe(elapsed);
if attempt == 0 {
tracing::debug!(url, error = %e, "upstream error on POST, retrying in 1s");
tokio::time::sleep(Duration::from_secs(1)).await;
continue;
}
cb.record_failure(registry_str, probe);
return Err(ProxyError::Network(e.to_string()));
}
}
}
cb.record_failure(registry_str, probe);
Err(ProxyError::Network("max retries exceeded".into()))
}
pub(crate) enum StreamOutcome {
Ok(u64),
TooLarge,
ClientGone,
Io(std::io::Error),
}
pub(crate) async fn stream_body_to_file(
body: Body,
file: &mut tokio::fs::File,
budget: u64,
) -> StreamOutcome {
use tokio::io::AsyncWriteExt;
let mut stream = body.into_data_stream();
let mut written: u64 = 0;
while let Some(frame) = stream.next().await {
let chunk = match frame {
Ok(c) => c,
Err(_) => return StreamOutcome::ClientGone,
};
written = written.saturating_add(chunk.len() as u64);
if written > budget {
return StreamOutcome::TooLarge;
}
if let Err(e) = file.write_all(&chunk).await {
return StreamOutcome::Io(e);
}
}
if let Err(e) = file.flush().await {
return StreamOutcome::Io(e);
}
if let Err(e) = file.sync_all().await {
return StreamOutcome::Io(e);
}
debug_assert!(
written <= budget,
"stream_body_to_file wrote {written} bytes > budget {budget}"
);
StreamOutcome::Ok(written)
}
pub(crate) fn content_length(headers: &axum::http::HeaderMap) -> Option<u64> {
headers
.get(header::CONTENT_LENGTH)
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<u64>().ok())
}
pub(crate) struct TempFileGuard {
path: Option<std::path::PathBuf>,
}
impl TempFileGuard {
pub(crate) fn new(path: std::path::PathBuf) -> Self {
Self { path: Some(path) }
}
pub(crate) fn disarm(&mut self) {
self.path = None;
}
}
impl Drop for TempFileGuard {
fn drop(&mut self) {
if let Some(ref path) = self.path {
let _ = std::fs::remove_file(path);
}
}
}
pub(crate) async fn sha256_of_file(path: &std::path::Path) -> std::io::Result<String> {
use sha2::Digest;
use tokio::io::AsyncReadExt;
let file = tokio::fs::File::open(path).await?;
let mut reader = tokio::io::BufReader::new(file);
let mut hasher = sha2::Sha256::new();
let mut buf = vec![0u8; 256 * 1024];
loop {
match reader.read(&mut buf).await? {
0 => break,
n => hasher.update(&buf[..n]),
}
}
Ok(hex::encode(hasher.finalize()))
}
#[derive(Debug, Default, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub(crate) struct Validators {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub etag: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_modified: Option<String>,
}
impl Validators {
pub fn is_some(&self) -> bool {
self.etag.is_some() || self.last_modified.is_some()
}
}
pub(crate) enum Revalidation {
NotModified,
Modified {
body: Vec<u8>,
validators: Validators,
},
}
pub(crate) fn validators_key(key: &str) -> String {
format!("{key}.meta")
}
pub(crate) async fn read_validators(storage: &crate::Storage, key: &str) -> Option<Validators> {
let data = storage.get(&validators_key(key)).await.ok()?;
serde_json::from_slice::<Validators>(&data).ok()
}
pub(crate) async fn write_validators(storage: &crate::Storage, key: &str, v: &Validators) {
if !v.is_some() {
return;
}
if let Ok(data) = serde_json::to_vec(v) {
if let Err(e) = storage.put(&validators_key(key), &data).await {
tracing::warn!(key = %key, error = ?e, "failed to write validator sidecar");
}
}
}
fn header_string(resp: &reqwest::Response, name: reqwest::header::HeaderName) -> Option<String> {
resp.headers()
.get(name)
.and_then(|v| v.to_str().ok())
.map(str::to_owned)
}
pub(crate) async fn proxy_fetch_conditional(
client: &reqwest::Client,
url: &str,
timeout: Duration,
auth: Option<&str>,
validators: &Validators,
cb: &CircuitBreakerRegistry,
registry: RegistryType,
) -> Result<Revalidation, ProxyError> {
let registry_str = registry.as_str();
let probe = cb.check(registry_str)?;
let mut request = client.get(url).timeout(timeout);
if let Some(credentials) = auth {
request = request.header(header::AUTHORIZATION, basic_auth_header(credentials));
}
if let Some(ref etag) = validators.etag {
request = request.header(header::IF_NONE_MATCH, etag);
}
if let Some(ref lm) = validators.last_modified {
request = request.header(header::IF_MODIFIED_SINCE, lm);
}
match request.send().await {
Ok(response) => {
let status = response.status();
if status == reqwest::StatusCode::NOT_MODIFIED {
cb.record_success(registry_str, probe);
return Ok(Revalidation::NotModified);
}
if status.is_success() {
let new_validators = Validators {
etag: header_string(&response, header::ETAG),
last_modified: header_string(&response, header::LAST_MODIFIED),
};
let body = response
.bytes()
.await
.map_err(|e| ProxyError::Network(e.to_string()))?;
cb.record_success(registry_str, probe);
return Ok(Revalidation::Modified {
body: body.to_vec(),
validators: new_validators,
});
}
let code = status.as_u16();
if (400..500).contains(&code) {
cb.record_alive(registry_str, probe);
return Err(ProxyError::NotFound);
}
cb.record_failure(registry_str, probe);
Err(ProxyError::Upstream(code))
}
Err(e) => {
cb.record_failure(registry_str, probe);
Err(ProxyError::Network(e.to_string()))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test_proxy_fetch_invalid_url() {
let client = reqwest::Client::new();
let cb = crate::circuit_breaker::CircuitBreakerRegistry::new(
crate::config::CircuitBreakerConfig::default(),
);
let result = proxy_fetch(
&client,
"http://127.0.0.1:1/nonexistent",
Duration::from_secs(2),
None,
&cb,
RegistryType::Docker, )
.await;
assert!(matches!(result, Err(ProxyError::Network(_))));
}
#[test]
fn policy_block_reason_detects_waf_geo() {
use reqwest::header::HeaderMap;
let mut geo = HeaderMap::new();
geo.insert("x-amzn-waf-reason", "geo".parse().unwrap());
assert_eq!(policy_block_reason(&geo), Some("geo"));
let mut other = HeaderMap::new();
other.insert("x-amzn-waf-reason", "rate-based".parse().unwrap());
assert_eq!(policy_block_reason(&other), Some("waf"));
assert_eq!(policy_block_reason(&HeaderMap::new()), None);
}
#[tokio::test]
async fn upstream_waf_geo_block_surfaced_but_plain_4xx_silent() {
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};
let blocked = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(404).insert_header("x-amzn-waf-reason", "geo"))
.mount(&blocked)
.await;
let reg = RegistryType::Terraform;
let before = UPSTREAM_POLICY_BLOCKED_TOTAL
.with_label_values(&[reg.as_str(), "geo"])
.get();
let r = proxy_fetch_text(
&reqwest::Client::new(),
&blocked.uri(),
Duration::from_secs(5),
None,
None,
&noop_cb(),
reg,
)
.await;
assert!(matches!(r, Err(ProxyError::NotFound)));
assert_eq!(
UPSTREAM_POLICY_BLOCKED_TOTAL
.with_label_values(&[reg.as_str(), "geo"])
.get(),
before + 1,
"a WAF geo 4xx must bump the policy-block metric"
);
let plain = MockServer::start().await;
Mock::given(any())
.respond_with(ResponseTemplate::new(404))
.mount(&plain)
.await;
let reg2 = RegistryType::Cargo;
let before2 = UPSTREAM_POLICY_BLOCKED_TOTAL
.with_label_values(&[reg2.as_str(), "geo"])
.get();
let r2 = proxy_fetch_text(
&reqwest::Client::new(),
&plain.uri(),
Duration::from_secs(5),
None,
None,
&noop_cb(),
reg2,
)
.await;
assert!(matches!(r2, Err(ProxyError::NotFound)));
assert_eq!(
UPSTREAM_POLICY_BLOCKED_TOTAL
.with_label_values(&[reg2.as_str(), "geo"])
.get(),
before2,
"a plain 4xx must NOT bump the policy-block metric"
);
}
fn noop_cb() -> CircuitBreakerRegistry {
CircuitBreakerRegistry::new(crate::config::CircuitBreakerConfig::default())
}
#[tokio::test]
async fn conditional_200_captures_validators() {
use wiremock::matchers::any;
use wiremock::{Mock, MockServer, ResponseTemplate};
let upstream = MockServer::start().await;
Mock::given(any())
.respond_with(
ResponseTemplate::new(200)
.insert_header("etag", "\"v1\"")
.set_body_string("BODY-V1"),
)
.mount(&upstream)
.await;
let cb = noop_cb();
let out = proxy_fetch_conditional(
&reqwest::Client::new(),
&upstream.uri(),
Duration::from_secs(5),
None,
&Validators::default(),
&cb,
RegistryType::Npm,
)
.await
.unwrap();
match out {
Revalidation::Modified { body, validators } => {
assert_eq!(body, b"BODY-V1");
assert_eq!(validators.etag.as_deref(), Some("\"v1\""));
}
Revalidation::NotModified => panic!("expected Modified"),
}
}
#[tokio::test]
async fn conditional_304_sends_if_none_match_and_returns_not_modified() {
use wiremock::matchers::{header_exists, method};
use wiremock::{Mock, MockServer, ResponseTemplate};
let upstream = MockServer::start().await;
Mock::given(method("GET"))
.and(header_exists("if-none-match"))
.respond_with(ResponseTemplate::new(304))
.mount(&upstream)
.await;
let validators = Validators {
etag: Some("\"v1\"".to_string()),
last_modified: None,
};
let cb = noop_cb();
let out = proxy_fetch_conditional(
&reqwest::Client::new(),
&upstream.uri(),
Duration::from_secs(5),
None,
&validators,
&cb,
RegistryType::Npm,
)
.await
.unwrap();
assert!(matches!(out, Revalidation::NotModified));
}
#[tokio::test]
async fn validators_sidecar_roundtrips_through_storage() {
let dir = tempfile::TempDir::new().unwrap();
let storage = crate::Storage::new_local(dir.path().to_str().unwrap());
let key = "npm/pkg/metadata.json";
let v = Validators {
etag: Some("\"abc\"".to_string()),
last_modified: Some("Wed, 21 Oct 2026 07:28:00 GMT".to_string()),
};
write_validators(&storage, key, &v).await;
let reloaded = crate::Storage::new_local(dir.path().to_str().unwrap());
let got = read_validators(&reloaded, key)
.await
.expect("sidecar persists");
assert_eq!(got, v);
assert_eq!(validators_key(key), "npm/pkg/metadata.json.meta");
}
#[tokio::test]
async fn empty_validators_write_no_sidecar() {
let dir = tempfile::TempDir::new().unwrap();
let storage = crate::Storage::new_local(dir.path().to_str().unwrap());
write_validators(&storage, "npm/x/metadata.json", &Validators::default()).await;
assert!(read_validators(&storage, "npm/x/metadata.json")
.await
.is_none());
}
}