use std::sync::Arc;
use axum::body::{Body, Bytes};
use axum::extract::{DefaultBodyLimit, Path, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::Router;
use crate::config::CacheConfig;
use crate::signing::CacheSigner;
use crate::StorageBackend;
use sui_compat::narinfo::NarInfo;
#[derive(Clone)]
pub struct AppState {
pub storage: Arc<dyn StorageBackend>,
pub config: CacheConfig,
pub signer: Option<Arc<CacheSigner>>,
}
#[must_use]
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/nix-cache-info", get(cache_info))
.route("/{hash_narinfo}", get(get_narinfo).put(put_narinfo))
.route("/nar/{*path}", get(get_nar).put(put_nar))
.layer(DefaultBodyLimit::disable())
.with_state(state)
}
pub async fn serve(config: CacheConfig, storage: Arc<dyn StorageBackend>) -> Result<(), crate::CacheError> {
let listen = config.listen.clone();
let signer = match &config.signing_key {
Some(path) => {
let key_str = std::fs::read_to_string(path).map_err(crate::CacheError::Io)?;
let signer = CacheSigner::from_secret_key_string(key_str.trim())?;
tracing::info!(
key_name = signer.key_name(),
public_key = %signer.public_key_string(),
"sui-cache signing ENABLED — every ingested narinfo is signed; \
distribute the public key to consumers as a trusted-public-key",
);
Some(Arc::new(signer))
}
None => {
tracing::warn!(
"sui-cache signing DISABLED (no signing_key configured) — narinfo \
served unsigned; consumers cannot verify integrity. Set a \
cofre/ESO-backed signing key to close the poisoned-write hole.",
);
None
}
};
let state = AppState {
storage,
config,
signer,
};
let app = build_router(state);
tracing::info!("sui-cache listening on {listen}");
let listener = tokio::net::TcpListener::bind(&listen)
.await
.map_err(crate::CacheError::Io)?;
axum::serve(listener, app)
.await
.map_err(crate::CacheError::Io)?;
Ok(())
}
fn sign_narinfo_text(signer: &CacheSigner, content: &str) -> Result<String, crate::CacheError> {
let mut info = NarInfo::parse(content)
.map_err(|e| crate::CacheError::NarInfo(e.to_string()))?;
let key_prefix = format!("{}:", signer.key_name());
if info.signatures.iter().any(|s| s.starts_with(&key_prefix)) {
return Ok(content.to_string());
}
let sig = signer.sign_narinfo(&info);
info.signatures.push(sig);
Ok(info.serialize())
}
async fn cache_info(State(state): State<AppState>) -> impl IntoResponse {
let body = format!(
"StoreDir: {}\nWantMassQuery: {}\nPriority: {}\n",
state.config.store_dir,
if state.config.want_mass_query { 1 } else { 0 },
state.config.priority,
);
(
StatusCode::OK,
[("content-type", "text/x-nix-cache-info")],
body,
)
}
async fn get_narinfo(
State(state): State<AppState>,
Path(hash_narinfo): Path<String>,
) -> impl IntoResponse {
let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
return StatusCode::NOT_FOUND.into_response();
};
match state.storage.get_narinfo(hash).await {
Ok(Some(content)) if !crate::is_servable_narinfo(&content) => {
tracing::error!(
hash = %hash,
len = content.len(),
"get_narinfo: stored narinfo is empty or has no StorePath — SERVING 404 so the \
client treats it as a miss instead of aborting; this entry is poison and should \
be evicted",
);
StatusCode::NOT_FOUND.into_response()
}
Ok(Some(content)) => (
StatusCode::OK,
[("content-type", "text/x-nix-narinfo")],
content,
)
.into_response(),
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!(
hash = %hash,
error = %e,
"get_narinfo: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
client rebuilds instead of aborting; the backend needs attention",
);
StatusCode::NOT_FOUND.into_response()
}
}
}
async fn put_narinfo(
State(state): State<AppState>,
Path(hash_narinfo): Path<String>,
body: Bytes,
) -> impl IntoResponse {
let Some(hash) = hash_narinfo.strip_suffix(".narinfo") else {
return StatusCode::BAD_REQUEST.into_response();
};
let content = match String::from_utf8(body.to_vec()) {
Ok(s) => s,
Err(_) => return StatusCode::BAD_REQUEST.into_response(),
};
if !crate::is_servable_narinfo(&content) {
tracing::warn!(
hash = %hash,
len = content.len(),
"put_narinfo: refusing a narinfo with no StorePath line — an entry that cannot be \
served is worse than an absent one, because nix aborts on it instead of missing",
);
return StatusCode::BAD_REQUEST.into_response();
}
if let Some(url) = crate::advertised_url_line(&content) {
if !crate::is_addressable_nar_path(url) {
tracing::warn!(
hash = %hash, url = %url,
"put_narinfo: refusing a narinfo whose URL is not an addressable relative path",
);
return StatusCode::BAD_REQUEST.into_response();
}
}
let to_store = match &state.signer {
Some(signer) => match sign_narinfo_text(signer, &content) {
Ok(signed) => signed,
Err(e) => {
tracing::error!("put_narinfo signing error: {e}");
return StatusCode::BAD_REQUEST.into_response();
}
},
None => content,
};
match state.storage.put_narinfo(hash, &to_store).await {
Ok(()) => StatusCode::OK.into_response(),
Err(e) => {
tracing::error!(
hash = %hash,
error = %e,
"put_narinfo: EVERY durable tier rejected the write — nothing stored; \
reporting failure rather than falsely acknowledging the upload",
);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
fn nar_content_type(path: &str) -> &'static str {
if path.ends_with(".xz") {
"application/x-xz"
} else if path.ends_with(".zstd") || path.ends_with(".zst") {
"application/zstd"
} else {
"application/x-nix-nar"
}
}
async fn get_nar(
State(state): State<AppState>,
Path(path): Path<String>,
) -> impl IntoResponse {
let nar_path = format!("nar/{path}");
match state.storage.get_nar_stream(&nar_path).await {
Ok(Some(stream)) => {
let mut headers = HeaderMap::new();
headers.insert("content-type", nar_content_type(&path).parse().unwrap());
(StatusCode::OK, headers, Body::from_stream(stream)).into_response()
}
Ok(None) => StatusCode::NOT_FOUND.into_response(),
Err(e) => {
tracing::error!(
nar_path = %nar_path,
error = %e,
"get_nar: storage backend failed — DEGRADING TO CACHE MISS (404) so the \
client rebuilds instead of aborting; the backend needs attention",
);
StatusCode::NOT_FOUND.into_response()
}
}
}
async fn put_nar(
State(state): State<AppState>,
Path(path): Path<String>,
body: Body,
) -> impl IntoResponse {
let nar_path = format!("nar/{path}");
let src = match sui_castore::spool_or_buffer(
body.into_data_stream(),
&std::env::temp_dir(),
sui_castore::DEFAULT_INGEST_MEMORY_CAP,
)
.await
{
Ok(src) => src,
Err(e) => {
tracing::error!(
nar_path = %nar_path,
error = %e,
"put_nar: could not stage the upload (spool write failed, or it exceeded \
the in-memory fallback cap) — nothing stored",
);
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
match state.storage.put_nar_stream(&nar_path, src.as_ref()).await {
Ok(()) => StatusCode::OK.into_response(),
Err(e) => {
tracing::error!(
nar_path = %nar_path,
error = %e,
"put_nar: EVERY durable tier rejected the write — nothing stored; \
reporting failure rather than falsely acknowledging the upload",
);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::BackendConfig;
use crate::LocalStorage;
use axum::body::Body;
use http_body_util::BodyExt;
use tower::ServiceExt;
fn test_app(dir: &std::path::Path) -> Router {
let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir));
let config = CacheConfig {
listen: "127.0.0.1:0".to_string(),
backend: BackendConfig::Local {
path: dir.to_path_buf(),
},
priority: 40,
want_mass_query: true,
store_dir: "/nix/store".to_string(),
signing_key: None,
require_sigs: false,
};
build_router(AppState { storage, config, signer: None })
}
async fn body_string(response: axum::http::Response<Body>) -> String {
let body = response.into_body();
let bytes = body.collect().await.unwrap().to_bytes();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn body_bytes(response: axum::http::Response<Body>) -> Vec<u8> {
let body = response.into_body();
body.collect().await.unwrap().to_bytes().to_vec()
}
#[tokio::test]
async fn cache_info_endpoint() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/nix-cache-info")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("StoreDir: /nix/store"));
assert!(body.contains("WantMassQuery: 1"));
assert!(body.contains("Priority: 40"));
}
#[tokio::test]
async fn get_narinfo_not_found() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/abc.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_then_get_narinfo() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let narinfo = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nFileHash: sha256:aaa\nFileSize: 100\nNarHash: sha256:bbb\nNarSize: 200\nReferences: \n";
let req = axum::http::Request::builder()
.method("PUT")
.uri("/abc.narinfo")
.body(Body::from(narinfo.to_string()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = axum::http::Request::builder()
.uri("/abc.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
assert!(body.contains("StorePath: /nix/store/abc-hello"));
}
#[tokio::test]
async fn get_nar_not_found() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/nar/abc.nar.xz")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn put_then_get_nar() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let nar_data = b"fake nar blob data";
let req = axum::http::Request::builder()
.method("PUT")
.uri("/nar/xyz.nar.xz")
.body(Body::from(nar_data.to_vec()))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = axum::http::Request::builder()
.uri("/nar/xyz.nar.xz")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_bytes(resp).await;
assert_eq!(body, nar_data);
}
#[tokio::test]
async fn get_narinfo_serves_a_poisoned_entry_as_a_miss() {
let dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(dir.path());
storage.put_narinfo("poison", "").await.unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/poison.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"an unusable hit must degrade to a miss; a 200 with an empty body makes the client \
ABORT rather than build, which is strictly worse than not having the entry"
);
}
#[tokio::test]
async fn put_narinfo_refuses_a_body_with_no_store_path() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.method("PUT")
.uri("/empty.narinfo")
.body(Body::from(""))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(
resp.status(),
StatusCode::BAD_REQUEST,
"an empty body cannot become a servable narinfo, so it is the client's error"
);
}
#[tokio::test]
async fn put_then_get_a_well_formed_narinfo_still_works() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let good = "StorePath: /nix/store/ok-pkg\nURL: nar/ok.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
let put = axum::http::Request::builder()
.method("PUT")
.uri("/ok.narinfo")
.body(Body::from(good))
.unwrap();
let resp = app.clone().oneshot(put).await.unwrap();
assert!(resp.status().is_success(), "a valid narinfo must still be accepted");
let get = axum::http::Request::builder()
.uri("/ok.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(get).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "and must still be served");
}
#[tokio::test]
async fn get_narinfo_content_type() {
let dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(dir.path());
storage
.put_narinfo("ct", "StorePath: /nix/store/ct-pkg\nURL: nar/ct.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n")
.await
.unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/ct.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"text/x-nix-narinfo"
);
}
#[tokio::test]
async fn get_nar_xz_content_type() {
let dir = tempfile::tempdir().unwrap();
let storage = LocalStorage::new(dir.path());
storage
.put_nar("nar/test.nar.xz", b"data")
.await
.unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.uri("/nar/test.nar.xz")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert_eq!(
resp.headers().get("content-type").unwrap(),
"application/x-xz"
);
}
#[tokio::test]
async fn cache_info_custom_priority() {
let dir = tempfile::tempdir().unwrap();
let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
let config = CacheConfig {
priority: 10,
want_mass_query: false,
..CacheConfig::default()
};
let app = build_router(AppState {
storage,
config,
signer: None,
});
let req = axum::http::Request::builder()
.uri("/nix-cache-info")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
let body = body_string(resp).await;
assert!(body.contains("Priority: 10"));
assert!(body.contains("WantMassQuery: 0"));
}
#[tokio::test]
async fn put_narinfo_signs_at_ingest_and_get_returns_verifiable_sig() {
use crate::signing::{verify_narinfo_signature, CacheSigner};
let dir = tempfile::tempdir().unwrap();
let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
let signer = Arc::new(CacheSigner::generate("ingest-key".to_string()));
let pk = signer.public_key_string();
let config = CacheConfig {
listen: "127.0.0.1:0".to_string(),
backend: BackendConfig::Local { path: dir.path().to_path_buf() },
priority: 40,
want_mass_query: true,
store_dir: "/nix/store".to_string(),
signing_key: None,
require_sigs: false,
};
let app = build_router(AppState { storage, config, signer: Some(signer.clone()) });
let narinfo = "StorePath: /nix/store/abc-hello\n\
URL: nar/abc.nar.xz\n\
Compression: xz\n\
FileHash: sha256:aaa\n\
FileSize: 100\n\
NarHash: sha256:bbb\n\
NarSize: 200\n\
References: zzz-b aaa-a\n";
let req = axum::http::Request::builder()
.method("PUT")
.uri("/abc.narinfo")
.body(Body::from(narinfo))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let req = axum::http::Request::builder()
.uri("/abc.narinfo")
.body(Body::empty())
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = body_string(resp).await;
let parsed = NarInfo::parse(&body).unwrap();
assert_eq!(parsed.signatures.len(), 1, "GET must return a signed narinfo");
assert!(parsed.signatures[0].starts_with("ingest-key:"));
assert!(
verify_narinfo_signature(&parsed, &parsed.signatures[0], &pk).unwrap(),
"the ingest signature must verify against the signer public key",
);
}
#[tokio::test]
async fn put_narinfo_is_idempotent_under_our_key() {
use crate::signing::CacheSigner;
let dir = tempfile::tempdir().unwrap();
let storage: Arc<dyn StorageBackend> = Arc::new(LocalStorage::new(dir.path()));
let signer = Arc::new(CacheSigner::generate("dedupe-key".to_string()));
let config = CacheConfig {
listen: "127.0.0.1:0".to_string(),
backend: BackendConfig::Local { path: dir.path().to_path_buf() },
priority: 40,
want_mass_query: true,
store_dir: "/nix/store".to_string(),
signing_key: None,
require_sigs: false,
};
let app = build_router(AppState { storage, config, signer: Some(signer) });
let narinfo = "StorePath: /nix/store/def-x\n\
URL: nar/def.nar.xz\n\
Compression: xz\n\
FileHash: sha256:a\n\
FileSize: 1\n\
NarHash: sha256:b\n\
NarSize: 2\n\
References: \n";
for uri in ["/def.narinfo"] {
let req = axum::http::Request::builder()
.method("PUT").uri(uri).body(Body::from(narinfo)).unwrap();
assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
}
let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
let signed = body_string(app.clone().oneshot(req).await.unwrap()).await;
let req = axum::http::Request::builder()
.method("PUT").uri("/def.narinfo").body(Body::from(signed.clone())).unwrap();
assert_eq!(app.clone().oneshot(req).await.unwrap().status(), StatusCode::OK);
let req = axum::http::Request::builder().uri("/def.narinfo").body(Body::empty()).unwrap();
let final_text = body_string(app.oneshot(req).await.unwrap()).await;
let parsed = NarInfo::parse(&final_text).unwrap();
assert_eq!(parsed.signatures.len(), 1, "must not double-sign on re-PUT");
}
#[derive(Default)]
struct BrokenStorage {
nar_refs: crate::MemNarRefIndex,
}
#[async_trait::async_trait]
impl StorageBackend for BrokenStorage {
async fn get_narinfo(&self, _hash: &str) -> Result<Option<String>, crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other(
"postgres: error returned from database: relation \"sui_cache_narinfo\" does not exist",
)))
}
async fn put_narinfo_record(
&self,
_hash: &str,
_content: &str,
) -> Result<(), crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
}
async fn delete_narinfo_record(&self, _hash: &str) -> Result<(), crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
}
async fn delete_nar_record(&self, _nar_path: &str) -> Result<(), crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
}
fn nar_ref_index(&self) -> &dyn crate::NarRefIndex {
&self.nar_refs
}
async fn get_nar(&self, _path: &str) -> Result<Option<Vec<u8>>, crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other(
"postgres: error returned from database: relation \"sui_cache_nar\" does not exist",
)))
}
async fn put_nar(&self, _path: &str, _data: &[u8]) -> Result<(), crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
}
fn nar_residency(&self) -> crate::NarResidency {
crate::NarResidency::WholeValue
}
async fn list_narinfos(&self) -> Result<Vec<String>, crate::CacheError> {
Err(crate::CacheError::Io(std::io::Error::other("postgres: down")))
}
}
fn broken_app() -> Router {
let storage: Arc<dyn StorageBackend> = Arc::new(BrokenStorage::default());
build_router(AppState {
storage,
config: CacheConfig::default(),
signer: None,
})
}
#[tokio::test]
async fn broken_backend_narinfo_read_is_a_miss_not_a_server_error() {
let resp = broken_app()
.oneshot(
axum::http::Request::builder()
.uri("/abc.narinfo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::NOT_FOUND,
"a backend that cannot answer must report a MISS, never a 500",
);
assert_ne!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn broken_backend_nar_read_is_a_miss_not_a_server_error() {
let resp = broken_app()
.oneshot(
axum::http::Request::builder()
.uri("/nar/abc.nar.xz")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn cache_info_still_answers_while_the_backend_is_broken() {
let resp = broken_app()
.oneshot(
axum::http::Request::builder()
.uri("/nix-cache-info")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn a_totally_failed_write_still_reports_failure() {
let narinfo = "StorePath: /nix/store/abc-hello\nURL: nar/abc.nar.xz\nCompression: xz\nFileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
let resp = broken_app()
.oneshot(
axum::http::Request::builder()
.method("PUT")
.uri("/abc.narinfo")
.body(Body::from(narinfo))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[tokio::test]
async fn put_narinfo_with_a_traversal_url_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let evil = "StorePath: /nix/store/abc-hello\nURL: ../../escape.nar\nCompression: xz\n\
FileHash: sha256:a\nFileSize: 1\nNarHash: sha256:b\nNarSize: 2\nReferences: \n";
let req = axum::http::Request::builder()
.method("PUT")
.uri("/abc.narinfo")
.body(Body::from(evil))
.unwrap();
let resp = app.clone().oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let get = axum::http::Request::builder()
.uri("/abc.narinfo")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(get).await.unwrap().status(),
StatusCode::NOT_FOUND,
"a rejected narinfo must not have been stored",
);
}
#[tokio::test]
async fn put_narinfo_bad_utf8() {
let dir = tempfile::tempdir().unwrap();
let app = test_app(dir.path());
let req = axum::http::Request::builder()
.method("PUT")
.uri("/bad.narinfo")
.body(Body::from(vec![0xFF, 0xFE, 0xFD]))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}