use sui_compat::narinfo::{NarInfo, NarInfoError};
use sui_compat::store_path::StorePath;
use crate::http::{HttpClient, HttpError, ReqwestHttpClient};
use crate::traits::{PathInfo, Store, StoreError, StoreResult};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BinaryCacheError {
#[error("http client error: {0}")]
HttpClient(#[from] HttpError),
#[error("unexpected HTTP status {status} for {url}")]
UnexpectedStatus {
status: u16,
url: String,
},
#[error("narinfo parse error: {0}")]
NarInfoParse(#[from] NarInfoError),
}
impl From<BinaryCacheError> for StoreError {
fn from(e: BinaryCacheError) -> Self {
match &e {
BinaryCacheError::HttpClient(_) | BinaryCacheError::UnexpectedStatus { .. } => {
StoreError::Http(e.to_string())
}
BinaryCacheError::NarInfoParse(_) => StoreError::NarInfo(e.to_string()),
}
}
}
pub struct BinaryCacheStore {
client: Box<dyn HttpClient>,
base_url: String,
trusted_keys: Vec<String>,
auth_header: Option<(String, String)>,
}
pub struct BinaryCacheStoreBuilder {
base_url: String,
trusted_keys: Vec<String>,
client: Option<Box<dyn HttpClient>>,
auth_header: Option<(String, String)>,
}
impl BinaryCacheStoreBuilder {
#[must_use]
pub fn trusted_keys(mut self, keys: Vec<String>) -> Self {
self.trusted_keys = keys;
self
}
#[must_use]
pub fn http_client(mut self, client: Box<dyn HttpClient>) -> Self {
self.client = Some(client);
self
}
#[must_use]
pub fn auth_header(mut self, scheme: &str, credentials: &str) -> Self {
self.auth_header = Some((scheme.to_string(), credentials.to_string()));
self
}
#[must_use]
pub fn build(self) -> BinaryCacheStore {
BinaryCacheStore {
client: self.client.unwrap_or_else(|| Box::new(ReqwestHttpClient::new())),
base_url: self.base_url,
trusted_keys: self.trusted_keys,
auth_header: self.auth_header,
}
}
}
impl BinaryCacheStore {
#[must_use]
pub fn builder(base_url: &str) -> BinaryCacheStoreBuilder {
BinaryCacheStoreBuilder {
base_url: base_url.trim_end_matches('/').to_string(),
trusted_keys: Vec::new(),
client: None,
auth_header: None,
}
}
#[must_use]
pub fn new(base_url: &str, trusted_keys: Vec<String>) -> Self {
Self::builder(base_url).trusted_keys(trusted_keys).build()
}
#[must_use]
pub fn with_http_client(
base_url: &str,
trusted_keys: Vec<String>,
client: Box<dyn HttpClient>,
) -> Self {
Self::builder(base_url)
.trusted_keys(trusted_keys)
.http_client(client)
.build()
}
fn request_headers(&self, extra: &[(&str, &str)]) -> Vec<(String, String)> {
let mut headers: Vec<(String, String)> = extra
.iter()
.map(|(k, v)| ((*k).to_string(), (*v).to_string()))
.collect();
if let Some((scheme, creds)) = &self.auth_header {
headers.push(("Authorization".to_string(), format!("{scheme} {creds}")));
}
headers
}
pub async fn fetch_narinfo(&self, hash: &str) -> StoreResult<Option<NarInfo>> {
let url = format!("{}/{hash}.narinfo", self.base_url);
let headers = self.request_headers(&[("Accept", "text/x-nix-narinfo")]);
let header_refs: Vec<(&str, &str)> = headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let response = self
.client
.get(&url, &header_refs)
.await
.map_err(BinaryCacheError::from)?;
if response.status == 404 {
return Ok(None);
}
if !response.is_success() {
return Err(BinaryCacheError::UnexpectedStatus {
status: response.status,
url,
}
.into());
}
let info = NarInfo::parse(&response.body).map_err(BinaryCacheError::from)?;
Ok(Some(info))
}
#[must_use]
pub fn base_url(&self) -> &str {
&self.base_url
}
#[must_use]
pub fn trusted_keys(&self) -> &[String] {
&self.trusted_keys
}
#[must_use]
pub fn auth_header(&self) -> Option<(&str, &str)> {
self.auth_header.as_ref().map(|(s, c)| (s.as_str(), c.as_str()))
}
pub async fn fetch_nar(&self, url_path: &str) -> StoreResult<Vec<u8>> {
let url = format!("{}/{url_path}", self.base_url);
self.client
.get_bytes(&url)
.await
.map_err(BinaryCacheError::from)
.map_err(StoreError::from)
}
#[cfg(test)]
fn narinfo_to_path_info(info: &NarInfo) -> PathInfo {
PathInfo::from(info)
}
fn store_path_hash(path: &StorePath) -> String {
let basename = path.to_basename();
basename[..32.min(basename.len())].to_string()
}
pub fn verify_narinfo_signatures(
narinfo: &NarInfo,
trusted_keys: &[String],
) -> StoreResult<bool> {
use sui_compat::signature::{StorePathSignature, compute_fingerprint};
use sui_compat::hash::base64_decode;
if trusted_keys.is_empty() {
return Ok(false);
}
let mut sorted_refs: Vec<String> = narinfo.references.clone();
sorted_refs.sort();
let fingerprint = compute_fingerprint(
&narinfo.store_path,
&narinfo.nar_hash,
narinfo.nar_size,
&sorted_refs,
);
let mut key_map: std::collections::HashMap<String, Vec<u8>> =
std::collections::HashMap::new();
for key_str in trusted_keys {
if let Some((name, b64_pubkey)) = key_str.split_once(':')
&& let Ok(pubkey_bytes) = base64_decode(b64_pubkey) {
key_map.insert(name.to_string(), pubkey_bytes);
}
}
for sig_str in &narinfo.signatures {
let parsed = match StorePathSignature::parse(sig_str) {
Ok(s) => s,
Err(_) => continue,
};
if let Some(pubkey_bytes) = key_map.get(&parsed.key_name)
&& pubkey_bytes.len() == 32 {
let pubkey: [u8; 32] = pubkey_bytes
.as_slice()
.try_into()
.expect("length checked");
if parsed.verify(&fingerprint, &pubkey).is_ok() {
return Ok(true);
}
}
}
Ok(false)
}
}
#[async_trait::async_trait]
impl Store for BinaryCacheStore {
async fn query_path_info(&self, path: &StorePath) -> StoreResult<Option<PathInfo>> {
let hash = Self::store_path_hash(path);
Ok(self
.fetch_narinfo(&hash)
.await?
.as_ref()
.map(PathInfo::from))
}
async fn is_valid_path(&self, path: &StorePath) -> StoreResult<bool> {
let hash = Self::store_path_hash(path);
Ok(self.fetch_narinfo(&hash).await?.is_some())
}
async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
Err(StoreError::NotSupported(
"binary cache does not support listing all paths".to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::http::{HttpError, HttpResponse};
#[test]
fn store_path_hash_extraction() {
let path = StorePath::from_absolute_path(
"/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
)
.unwrap();
let hash = BinaryCacheStore::store_path_hash(&path);
assert_eq!(hash, "sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6");
}
#[test]
fn narinfo_to_path_info_conversion() {
let narinfo = sui_compat::narinfo::NarInfo {
store_path: "/nix/store/abc-hello".to_string(),
url: "nar/abc.nar.xz".to_string(),
compression: "xz".to_string(),
file_hash: "sha256:aaa".to_string(),
file_size: 1000,
nar_hash: "sha256:bbb".to_string(),
nar_size: 5000,
references: vec![
"3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string(),
],
deriver: Some("abc.drv".to_string()),
signatures: vec!["key:sig".to_string()],
ca: None,
};
let info = BinaryCacheStore::narinfo_to_path_info(&narinfo);
assert_eq!(info.path, "/nix/store/abc-hello");
assert_eq!(info.nar_size, 5000);
assert_eq!(info.references.len(), 1);
assert_eq!(
info.references[0],
"/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8"
);
}
#[test]
fn with_http_client_constructor() {
let client = Box::new(ReqwestHttpClient::new());
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org/",
vec![],
client,
);
assert_eq!(store.base_url, "https://cache.nixos.org");
}
#[test]
fn base_url_accessor() {
let store = BinaryCacheStore::new("https://cache.nixos.org/", vec![]);
assert_eq!(store.base_url(), "https://cache.nixos.org");
}
#[test]
fn trusted_keys_accessor_returns_keys() {
let keys = vec![
"cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY=".to_string(),
];
let store = BinaryCacheStore::new("https://cache.nixos.org", keys.clone());
assert_eq!(store.trusted_keys(), &keys[..]);
}
#[test]
fn trusted_keys_accessor_empty() {
let store = BinaryCacheStore::new("https://cache.nixos.org", vec![]);
assert!(store.trusted_keys().is_empty());
}
struct MockHttpClient {
responses: std::collections::HashMap<String, HttpResponse>,
}
impl MockHttpClient {
fn new() -> Self {
Self {
responses: std::collections::HashMap::new(),
}
}
fn with_response(mut self, url: &str, resp: HttpResponse) -> Self {
self.responses.insert(url.to_string(), resp);
self
}
}
#[async_trait::async_trait]
impl HttpClient for MockHttpClient {
async fn get(
&self,
url: &str,
_h: &[(&str, &str)],
) -> Result<HttpResponse, HttpError> {
self.responses
.get(url)
.cloned()
.ok_or_else(|| HttpError::Request(format!("no mock: {url}")))
}
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>, HttpError> {
Ok(self.get(url, &[]).await?.body.into_bytes())
}
}
const MOCK_NARINFO: &str = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8
Deriver: abc.drv
Sig: cache.nixos.org-1:sig==
";
fn hello_store_path() -> StorePath {
StorePath::from_absolute_path(
"/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1",
)
.unwrap()
}
#[tokio::test]
async fn fetch_narinfo_valid_response() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap();
assert!(narinfo.is_some());
let info = narinfo.unwrap();
assert_eq!(info.nar_size, 5000);
assert_eq!(info.references.len(), 1);
assert!(info
.store_path
.contains("hello-2.12.1"));
}
#[tokio::test]
async fn fetch_narinfo_404_returns_none() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/nonexistenthash000000000000000000.narinfo",
HttpResponse {
status: 404,
body: "not found".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("nonexistenthash000000000000000000")
.await
.unwrap();
assert!(narinfo.is_none());
}
#[tokio::test]
async fn fetch_narinfo_500_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 500,
body: "server error".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.fetch_narinfo("abc00000000000000000000000000000")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn query_path_info_via_store_trait() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let path_info = store
.query_path_info(&hello_store_path())
.await
.unwrap();
assert!(path_info.is_some());
let info = path_info.unwrap();
assert_eq!(info.path, "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1");
assert_eq!(info.nar_hash, "sha256:bbb");
assert_eq!(info.nar_size, 5000);
assert_eq!(info.signatures, vec!["cache.nixos.org-1:sig=="]);
}
#[tokio::test]
async fn is_valid_path_true_when_exists() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
assert!(store.is_valid_path(&hello_store_path()).await.unwrap());
}
#[tokio::test]
async fn is_valid_path_false_when_missing() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 404,
body: String::new(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
assert!(!store.is_valid_path(&hello_store_path()).await.unwrap());
}
#[tokio::test]
async fn query_all_valid_paths_unsupported() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.query_all_valid_paths().await;
assert!(result.is_err());
}
#[test]
fn narinfo_to_path_info_preserves_ca() {
let narinfo = NarInfo {
store_path: "/nix/store/abc-src.tar.gz".to_string(),
url: "nar/abc.nar".to_string(),
compression: "none".to_string(),
file_hash: "sha256:fff".to_string(),
file_size: 500,
nar_hash: "sha256:eee".to_string(),
nar_size: 1000,
references: vec![],
deriver: None,
signatures: vec![],
ca: Some("fixed:out:r:sha256:deadbeef".to_string()),
};
let info = BinaryCacheStore::narinfo_to_path_info(&narinfo);
assert_eq!(
info.content_address,
Some("fixed:out:r:sha256:deadbeef".to_string())
);
assert_eq!(info.registration_time, 0);
}
#[test]
fn store_path_hash_extracts_exactly_32_chars() {
let path = StorePath::from_absolute_path(
"/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-net-hierarchical-0.1.0.1",
)
.unwrap();
let hash = BinaryCacheStore::store_path_hash(&path);
assert_eq!(hash.len(), 32);
assert_eq!(hash, "00bgd045z0d4icpbc2yyz4gx48ak44la");
}
#[test]
fn base_url_trailing_slashes_stripped() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org///",
vec![],
Box::new(client),
);
assert!(!store.base_url.ends_with('/'));
}
#[tokio::test]
async fn fetch_nar_returns_bytes() {
let nar_content = b"fake-nar-content-with-binary-data\x00\xff\xfe";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/nar/abc.nar.xz",
HttpResponse {
status: 200,
body: String::from_utf8_lossy(nar_content).to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let data = store.fetch_nar("nar/abc.nar.xz").await.unwrap();
assert!(!data.is_empty());
}
#[tokio::test]
async fn fetch_nar_http_error() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.fetch_nar("nar/missing.nar.xz").await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_nar_empty_body() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/nar/empty.nar",
HttpResponse {
status: 200,
body: String::new(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let data = store.fetch_nar("nar/empty.nar").await.unwrap();
assert!(data.is_empty());
}
#[tokio::test]
async fn fetch_narinfo_unknown_fields_ignored() {
let narinfo_with_extra = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8
Deriver: abc.drv
Sig: cache.nixos.org-1:sig==
FutureField: should-be-ignored
AnotherUnknown: 42
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_with_extra.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap();
assert!(narinfo.is_some());
assert_eq!(narinfo.unwrap().nar_size, 5000);
}
#[tokio::test]
async fn fetch_narinfo_malformed_body_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 200,
body: "this is not valid narinfo content at all".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.fetch_narinfo("abc00000000000000000000000000000")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_missing_required_field() {
let incomplete_narinfo = "\
StorePath: /nix/store/abc-hello
Compression: xz
NarHash: sha256:bbb
NarSize: 5000
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 200,
body: incomplete_narinfo.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.fetch_narinfo("abc00000000000000000000000000000")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_whitespace_in_body() {
let narinfo_with_whitespace = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_with_whitespace.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap();
assert!(narinfo.is_some());
}
#[tokio::test]
async fn fetch_narinfo_http_client_error() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.fetch_narinfo("nonexistent0000000000000000000000")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_302_redirect_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 302,
body: String::new(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.fetch_narinfo("abc00000000000000000000000000000")
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_no_signatures() {
let narinfo_no_sigs = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_no_sigs.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert!(narinfo.signatures.is_empty());
assert!(narinfo.references.is_empty());
}
#[tokio::test]
async fn fetch_narinfo_multiple_signatures() {
let narinfo_multi_sigs = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
Sig: key1:aaa==
Sig: key2:bbb==
Sig: key3:ccc==
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_multi_sigs.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let narinfo = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(narinfo.signatures.len(), 3);
assert_eq!(narinfo.signatures[0], "key1:aaa==");
assert_eq!(narinfo.signatures[2], "key3:ccc==");
}
#[tokio::test]
async fn dyn_store_query_path_info() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
),
);
let info = store.query_path_info(&hello_store_path()).await.unwrap();
assert!(info.is_some());
assert_eq!(info.unwrap().nar_size, 5000);
}
#[tokio::test]
async fn dyn_store_is_valid_path() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
),
);
assert!(store.is_valid_path(&hello_store_path()).await.unwrap());
}
#[tokio::test]
async fn dyn_store_query_all_valid_paths_unsupported() {
let client = MockHttpClient::new();
let store: std::sync::Arc<dyn Store> = std::sync::Arc::new(
BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
),
);
let result = store.query_all_valid_paths().await;
assert!(result.is_err());
}
#[test]
fn binary_cache_error_http_client_converts_to_store_http() {
let http_err = HttpError::Request("dns failure".to_string());
let bc_err: BinaryCacheError = http_err.into();
let store_err: StoreError = bc_err.into();
match store_err {
StoreError::Http(msg) => assert!(msg.contains("dns failure")),
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn binary_cache_error_unexpected_status_converts_to_store_http() {
let bc_err = BinaryCacheError::UnexpectedStatus {
status: 503,
url: "https://cache.test/abc.narinfo".to_string(),
};
let store_err: StoreError = bc_err.into();
match store_err {
StoreError::Http(msg) => {
assert!(msg.contains("503"));
assert!(msg.contains("cache.test"));
}
other => panic!("expected Http, got {other:?}"),
}
}
#[test]
fn binary_cache_error_narinfo_parse_converts_to_store_narinfo() {
let parse_err = sui_compat::narinfo::NarInfoError::MissingField("StorePath".to_string());
let bc_err: BinaryCacheError = parse_err.into();
let store_err: StoreError = bc_err.into();
match store_err {
StoreError::NarInfo(msg) => {
assert!(msg.contains("StorePath") || msg.contains("missing"));
}
other => panic!("expected NarInfo, got {other:?}"),
}
}
#[test]
fn binary_cache_error_display_unexpected_status() {
let err = BinaryCacheError::UnexpectedStatus {
status: 418,
url: "https://teapot.test/x.narinfo".to_string(),
};
let s = err.to_string();
assert!(s.contains("418"));
assert!(s.contains("teapot.test"));
}
#[test]
fn binary_cache_error_debug_format() {
let err = BinaryCacheError::UnexpectedStatus {
status: 500,
url: "x".to_string(),
};
let debug = format!("{err:?}");
assert!(debug.contains("UnexpectedStatus"));
assert!(debug.contains("500"));
}
#[test]
fn builder_default_is_reqwest_client() {
let store = BinaryCacheStore::builder("https://cache.nixos.org").build();
assert_eq!(store.base_url(), "https://cache.nixos.org");
assert!(store.trusted_keys().is_empty());
}
#[test]
fn builder_with_trusted_keys() {
let keys = vec!["k1:abc==".to_string(), "k2:def==".to_string()];
let store = BinaryCacheStore::builder("https://cache.nixos.org")
.trusted_keys(keys.clone())
.build();
assert_eq!(store.trusted_keys().len(), 2);
assert_eq!(store.trusted_keys()[0], "k1:abc==");
}
#[test]
fn builder_chaining_order_independent() {
let client = Box::new(MockHttpClient::new());
let keys = vec!["k:s".to_string()];
let store = BinaryCacheStore::builder("https://cache.nixos.org")
.http_client(client)
.trusted_keys(keys.clone())
.build();
assert_eq!(store.trusted_keys(), &keys[..]);
assert_eq!(store.base_url(), "https://cache.nixos.org");
}
#[test]
fn builder_strips_trailing_slash() {
let store = BinaryCacheStore::builder("https://cache.nixos.org/").build();
assert_eq!(store.base_url(), "https://cache.nixos.org");
}
#[test]
fn builder_strips_multiple_trailing_slashes() {
let store = BinaryCacheStore::builder("https://cache.nixos.org////").build();
assert!(!store.base_url().ends_with('/'));
}
#[test]
fn store_path_hash_for_drv_path() {
let path = StorePath::from_absolute_path(
"/nix/store/xb4y5iklhya4blk42k1cfkb8k07dpp4n-hello-2.12.1.drv",
)
.unwrap();
let hash = BinaryCacheStore::store_path_hash(&path);
assert_eq!(hash, "xb4y5iklhya4blk42k1cfkb8k07dpp4n");
assert_eq!(hash.len(), 32);
}
#[tokio::test]
async fn fetch_narinfo_zstd_compression() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.zst
Compression: zstd
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(info.compression, "zstd");
}
#[tokio::test]
async fn fetch_narinfo_no_compression() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar
Compression: none
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(info.compression, "none");
}
#[tokio::test]
async fn fetch_narinfo_bzip2_compression() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.bz2
Compression: bzip2
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(info.compression, "bzip2");
}
#[tokio::test]
async fn fetch_narinfo_with_ca_field() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-source.tar.gz
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
CA: fixed:out:r:sha256:cafebabedeadbeef
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(
info.ca,
Some("fixed:out:r:sha256:cafebabedeadbeef".to_string())
);
let path_info = PathInfo::from(&info);
assert_eq!(
path_info.content_address,
Some("fixed:out:r:sha256:cafebabedeadbeef".to_string())
);
}
#[tokio::test]
async fn fetch_narinfo_many_references_on_one_line() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References: dep1 dep2 dep3 dep4 dep5 dep6 dep7 dep8 dep9 dep10
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert_eq!(info.references.len(), 10);
assert_eq!(info.references[0], "dep1");
assert_eq!(info.references[9], "dep10");
}
#[tokio::test]
async fn fetch_narinfo_no_deriver() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert!(info.deriver.is_none());
}
#[tokio::test]
async fn fetch_narinfo_empty_deriver_treated_as_none() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
Deriver:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap()
.unwrap();
assert!(info.deriver.is_none());
}
#[tokio::test]
async fn fetch_narinfo_503_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 503,
body: "service unavailable".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_403_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 403,
body: "forbidden".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_301_redirect_returns_error() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/abc00000000000000000000000000000.narinfo",
HttpResponse {
status: 301,
body: String::new(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.fetch_narinfo("abc00000000000000000000000000000").await;
assert!(result.is_err());
}
#[tokio::test]
async fn fetch_narinfo_201_created_treated_as_success() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 201,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.fetch_narinfo("sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6")
.await
.unwrap();
assert!(info.is_some());
}
#[tokio::test]
async fn fetch_nar_returns_correct_url_path() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/nar/some/nested/path.nar.xz",
HttpResponse {
status: 200,
body: "data".to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let bytes = store.fetch_nar("nar/some/nested/path.nar.xz").await.unwrap();
assert_eq!(bytes, b"data");
}
#[tokio::test]
async fn binary_cache_collect_garbage_unsupported() {
use crate::traits::GcOptions;
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.collect_garbage(&GcOptions::default()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn binary_cache_add_to_store_unsupported() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.add_to_store("hello", b"data", &[]).await;
assert!(result.is_err());
}
#[tokio::test]
async fn binary_cache_register_path_unsupported() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = PathInfo::new("/nix/store/abc-x", "sha256:aaa");
let result = store.register_path(&info).await;
assert!(result.is_err());
}
#[tokio::test]
async fn binary_cache_query_referrers_unsupported() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store.query_referrers(&hello_store_path()).await;
assert!(result.is_err());
}
#[tokio::test]
async fn binary_cache_add_signatures_unsupported() {
let client = MockHttpClient::new();
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let result = store
.add_signatures(&hello_store_path(), &["sig".to_string()])
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn binary_cache_query_references_round_trip() {
let body = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37 00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: body.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store.query_path_info(&hello_store_path()).await.unwrap().unwrap();
assert_eq!(info.references.len(), 2);
assert_eq!(
info.references[0],
"/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37"
);
let refs = store.query_references(&hello_store_path()).await.unwrap();
assert_eq!(refs.len(), 2);
}
#[tokio::test]
async fn box_dyn_binary_cache_store_query_path_info() {
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: MOCK_NARINFO.to_string(),
},
);
let store: Box<dyn Store> = Box::new(BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
));
let info = store.query_path_info(&hello_store_path()).await.unwrap();
assert!(info.is_some());
}
#[tokio::test]
async fn query_path_info_references_are_absolute_store_paths() {
let narinfo_multi_refs = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References: 3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8 00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2 sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
Deriver: abc.drv
Sig: cache.nixos.org-1:sig==
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_multi_refs.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.query_path_info(&hello_store_path())
.await
.unwrap()
.expect("path info should be present");
assert_eq!(info.references.len(), 3);
for r in &info.references {
assert!(
r.starts_with("/nix/store/"),
"reference should be absolute store path, got {r:?}"
);
}
assert_eq!(
info.references[0],
"/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8"
);
assert_eq!(
info.references[1],
"/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2"
);
assert_eq!(
info.references[2],
"/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1"
);
}
#[tokio::test]
async fn query_references_via_store_returns_full_prefixed_paths() {
struct MockStore {
info: PathInfo,
}
#[async_trait::async_trait]
impl Store for MockStore {
async fn query_path_info(
&self,
_path: &StorePath,
) -> StoreResult<Option<PathInfo>> {
Ok(Some(self.info.clone()))
}
async fn is_valid_path(&self, _path: &StorePath) -> StoreResult<bool> {
Ok(true)
}
async fn query_all_valid_paths(&self) -> StoreResult<Vec<StorePath>> {
Ok(vec![])
}
}
let narinfo = NarInfo {
store_path: "/nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1".to_string(),
url: "nar/abc.nar.xz".to_string(),
compression: "xz".to_string(),
file_hash: "sha256:aaa".to_string(),
file_size: 1000,
nar_hash: "sha256:bbb".to_string(),
nar_size: 5000,
references: vec![
"3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string(),
"00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2".to_string(),
],
deriver: None,
signatures: vec![],
ca: None,
};
let mock = MockStore {
info: PathInfo::from(&narinfo),
};
let refs = mock.query_references(&hello_store_path()).await.unwrap();
assert_eq!(
refs.len(),
2,
"default query_references must yield both NarInfo references"
);
let absolute: Vec<String> = refs.iter().map(StorePath::to_absolute_path).collect();
assert!(absolute.contains(
&"/nix/store/3n58xw4373jp0ljirf06d8077j15pc4j-glibc-2.37-8".to_string()
));
assert!(absolute.contains(
&"/nix/store/00bgd045z0d4icpbc2yyz4gx48ak44la-bash-5.2".to_string()
));
}
#[tokio::test]
async fn query_path_info_empty_references_yields_empty_vec() {
let narinfo_no_refs = "\
StorePath: /nix/store/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6-hello-2.12.1
URL: nar/abc.nar.xz
Compression: xz
FileHash: sha256:aaa
FileSize: 1000
NarHash: sha256:bbb
NarSize: 5000
References:
";
let client = MockHttpClient::new().with_response(
"https://cache.nixos.org/sn5lbjwwmkbzj7cx0hfnlwf4sh16cll6.narinfo",
HttpResponse {
status: 200,
body: narinfo_no_refs.to_string(),
},
);
let store = BinaryCacheStore::with_http_client(
"https://cache.nixos.org",
vec![],
Box::new(client),
);
let info = store
.query_path_info(&hello_store_path())
.await
.unwrap()
.expect("path info should be present");
assert!(info.references.is_empty());
}
fn make_signed_narinfo() -> (NarInfo, String) {
use ed25519_dalek::{Signer, SigningKey};
use sui_compat::hash::base64_encode;
use sui_compat::signature::compute_fingerprint;
let signing_key = SigningKey::from_bytes(&[42u8; 32]);
let verifying_key = signing_key.verifying_key();
let narinfo = NarInfo {
store_path: "/nix/store/abc-hello".to_string(),
url: "nar/abc.nar.xz".to_string(),
compression: "xz".to_string(),
file_hash: "sha256:aaa".to_string(),
file_size: 1000,
nar_hash: "sha256:bbb".to_string(),
nar_size: 5000,
references: vec![],
deriver: None,
signatures: vec![],
ca: None,
};
let fingerprint = compute_fingerprint(
&narinfo.store_path,
&narinfo.nar_hash,
narinfo.nar_size,
&narinfo.references,
);
let sig = signing_key.sign(fingerprint.as_bytes());
let sig_str = format!(
"test-key:{}",
base64_encode(&sig.to_bytes())
);
let trusted_key = format!(
"test-key:{}",
base64_encode(verifying_key.as_bytes())
);
let mut signed = narinfo;
signed.signatures = vec![sig_str];
(signed, trusted_key)
}
#[test]
fn verify_narinfo_signatures_valid() {
let (narinfo, trusted_key) = make_signed_narinfo();
let result = BinaryCacheStore::verify_narinfo_signatures(
&narinfo,
&[trusted_key],
)
.unwrap();
assert!(result);
}
#[test]
fn verify_narinfo_signatures_invalid_key() {
use sui_compat::hash::base64_encode;
let (narinfo, _) = make_signed_narinfo();
let wrong_key = format!(
"test-key:{}",
base64_encode(&[99u8; 32])
);
let result = BinaryCacheStore::verify_narinfo_signatures(
&narinfo,
&[wrong_key],
)
.unwrap();
assert!(!result);
}
#[test]
fn verify_narinfo_signatures_empty_trusted_keys_returns_false() {
let (narinfo, _) = make_signed_narinfo();
let result = BinaryCacheStore::verify_narinfo_signatures(
&narinfo,
&[],
)
.unwrap();
assert!(!result);
}
#[test]
fn verify_narinfo_signatures_no_matching_key_name() {
use sui_compat::hash::base64_encode;
let (narinfo, _) = make_signed_narinfo();
let wrong_name_key = format!(
"other-key:{}",
base64_encode(&[42u8; 32])
);
let result = BinaryCacheStore::verify_narinfo_signatures(
&narinfo,
&[wrong_name_key],
)
.unwrap();
assert!(!result);
}
#[test]
fn verify_narinfo_signatures_unsigned_narinfo() {
let narinfo = NarInfo {
store_path: "/nix/store/abc-hello".to_string(),
url: "nar/abc.nar.xz".to_string(),
compression: "xz".to_string(),
file_hash: "sha256:aaa".to_string(),
file_size: 1000,
nar_hash: "sha256:bbb".to_string(),
nar_size: 5000,
references: vec![],
deriver: None,
signatures: vec![],
ca: None,
};
let result = BinaryCacheStore::verify_narinfo_signatures(
&narinfo,
&["key:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string()],
)
.unwrap();
assert!(!result);
}
#[test]
fn verify_narinfo_signatures_with_references() {
use ed25519_dalek::{Signer, SigningKey};
use sui_compat::hash::base64_encode;
use sui_compat::signature::compute_fingerprint;
let signing_key = SigningKey::from_bytes(&[10u8; 32]);
let verifying_key = signing_key.verifying_key();
let refs = vec![
"dep-b".to_string(),
"dep-a".to_string(),
];
let narinfo = NarInfo {
store_path: "/nix/store/xyz-pkg".to_string(),
url: "nar/xyz.nar".to_string(),
compression: "none".to_string(),
file_hash: "sha256:fff".to_string(),
file_size: 2000,
nar_hash: "sha256:eee".to_string(),
nar_size: 3000,
references: refs.clone(),
deriver: None,
signatures: vec![],
ca: None,
};
let mut sorted_refs = refs;
sorted_refs.sort();
let fingerprint = compute_fingerprint(
&narinfo.store_path,
&narinfo.nar_hash,
narinfo.nar_size,
&sorted_refs,
);
let sig = signing_key.sign(fingerprint.as_bytes());
let sig_str = format!("k:{}", base64_encode(&sig.to_bytes()));
let trusted_key = format!("k:{}", base64_encode(verifying_key.as_bytes()));
let mut signed = narinfo;
signed.signatures = vec![sig_str];
let result = BinaryCacheStore::verify_narinfo_signatures(
&signed,
&[trusted_key],
)
.unwrap();
assert!(result);
}
#[test]
fn builder_auth_header_none_by_default() {
let store = BinaryCacheStore::builder("https://cache.example.com").build();
assert!(store.auth_header().is_none());
}
#[test]
fn builder_auth_header_set() {
let store = BinaryCacheStore::builder("https://cache.example.com")
.auth_header("Bearer", "my-token-123")
.build();
let (scheme, creds) = store.auth_header().unwrap();
assert_eq!(scheme, "Bearer");
assert_eq!(creds, "my-token-123");
}
#[test]
fn request_headers_without_auth() {
let store = BinaryCacheStore::builder("https://cache.example.com").build();
let headers = store.request_headers(&[("Accept", "text/plain")]);
assert_eq!(headers.len(), 1);
assert_eq!(headers[0], ("Accept".to_string(), "text/plain".to_string()));
}
#[test]
fn request_headers_with_auth() {
let store = BinaryCacheStore::builder("https://cache.example.com")
.auth_header("Bearer", "token123")
.build();
let headers = store.request_headers(&[("Accept", "text/plain")]);
assert_eq!(headers.len(), 2);
assert_eq!(headers[1], ("Authorization".to_string(), "Bearer token123".to_string()));
}
#[test]
fn new_constructor_has_no_auth() {
let store = BinaryCacheStore::new("https://cache.example.com", vec![]);
assert!(store.auth_header().is_none());
}
}