use serde::{Deserialize, Serialize};
use tatara_lisp::DeriveTataraDomain;
use crate::SpecError;
#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
#[tatara(keyword = "defsubstituter")]
pub struct SubstituterSpec {
pub name: String,
pub transport: SubstituterTransport,
pub endpoint: String,
pub auth: SubstituterAuth,
#[serde(rename = "trustLevel")]
pub trust_level: TrustLevel,
pub phases: Vec<SubstituterPhase>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SubstituterTransport {
Https,
S3,
Local,
Attic,
SshNixStore,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubstituterAuth {
None,
BearerToken,
AwsSigV4,
SshClientCert,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum TrustLevel {
Trusted,
TrustedUsersOnly,
Untrusted,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SubstituterPhase {
pub kind: SubstituterPhaseKind,
#[serde(default)]
pub bind: Option<String>,
#[serde(default)]
pub from: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubstituterPhaseKind {
QueryNarInfo,
FetchNar,
VerifyNarSignature,
DecompressNar,
VerifyNarHash,
ImportNarToStore,
RealizeReferences,
}
pub struct SubstituteArgs {
pub store_path_hash: String,
pub name_hint: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubstituteOutcome {
pub store_path: String,
pub nar_hash: String,
pub references: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct NarInfoRecord {
pub store_path: String,
pub url: String,
pub compression: String,
pub file_hash: String,
pub nar_hash: String,
pub nar_size: u64,
pub references: Vec<String>,
pub signatures: Vec<String>,
}
pub trait SubstituterEnvironment {
fn query_narinfo(&self, endpoint: &str, hash: &str)
-> Result<Option<NarInfoRecord>, String>;
fn fetch_nar(&self, endpoint: &str, url: &str)
-> Result<Vec<u8>, String>;
fn verify_signatures(&self, _record: &NarInfoRecord) -> Result<bool, String> {
Ok(true)
}
fn decompress(&self, compression: &str, bytes: &[u8])
-> Result<Vec<u8>, String>;
fn verify_nar_hash(&self, expected: &str, bytes: &[u8])
-> Result<bool, String>;
fn import_nar(&self, store_path: &str, nar_bytes: &[u8])
-> Result<String, String>;
}
pub fn apply<E: SubstituterEnvironment>(
spec: &SubstituterSpec,
args: &SubstituteArgs,
env: &E,
) -> Result<SubstituteOutcome, SpecError> {
let narinfo = env
.query_narinfo(&spec.endpoint, &args.store_path_hash)
.map_err(|e| SpecError::Interp {
phase: "query-narinfo".into(),
message: format!("{}: {e}", spec.name),
})?
.ok_or_else(|| SpecError::Interp {
phase: "narinfo-not-found".into(),
message: format!(
"substituter `{}` has no path for hash `{}`",
spec.name, args.store_path_hash,
),
})?;
let mut nar_bytes: Option<Vec<u8>> = None;
let mut decompressed: Option<Vec<u8>> = None;
let mut import_path: Option<String> = None;
for phase in &spec.phases {
match phase.kind {
SubstituterPhaseKind::QueryNarInfo => {
}
SubstituterPhaseKind::FetchNar => {
let bytes = env
.fetch_nar(&spec.endpoint, &narinfo.url)
.map_err(|e| SpecError::Interp {
phase: "fetch-nar".into(),
message: format!("{}: {e}", spec.name),
})?;
nar_bytes = Some(bytes);
}
SubstituterPhaseKind::VerifyNarSignature => {
if spec.trust_level == TrustLevel::Untrusted {
continue; }
let ok = env
.verify_signatures(&narinfo)
.map_err(|e| SpecError::Interp {
phase: "verify-signature".into(),
message: format!("{}: {e}", spec.name),
})?;
if !ok {
return Err(SpecError::Interp {
phase: "signature-required".into(),
message: format!(
"substituter `{}` requires a valid signature \
but none verified for `{}`",
spec.name, narinfo.store_path,
),
});
}
}
SubstituterPhaseKind::DecompressNar => {
let Some(bytes) = nar_bytes.as_deref() else {
return Err(SpecError::Interp {
phase: "phase-order".into(),
message: format!(
"{}: DecompressNar phase ran before FetchNar — \
phase ordering broken",
spec.name,
),
});
};
let out = env
.decompress(&narinfo.compression, bytes)
.map_err(|e| SpecError::Interp {
phase: "decompress".into(),
message: format!("{}: {e}", spec.name),
})?;
decompressed = Some(out);
}
SubstituterPhaseKind::VerifyNarHash => {
let Some(bytes) = decompressed.as_deref().or(nar_bytes.as_deref()) else {
return Err(SpecError::Interp {
phase: "phase-order".into(),
message: format!(
"{}: VerifyNarHash ran without FetchNar bytes",
spec.name,
),
});
};
let ok = env
.verify_nar_hash(&narinfo.nar_hash, bytes)
.map_err(|e| SpecError::Interp {
phase: "verify-nar-hash".into(),
message: format!("{}: {e}", spec.name),
})?;
if !ok {
return Err(SpecError::Interp {
phase: "nar-hash-mismatch".into(),
message: format!(
"{}: NAR hash mismatch for `{}` (expected `{}`)",
spec.name, narinfo.store_path, narinfo.nar_hash,
),
});
}
}
SubstituterPhaseKind::ImportNarToStore => {
let Some(bytes) = decompressed.as_deref().or(nar_bytes.as_deref()) else {
return Err(SpecError::Interp {
phase: "phase-order".into(),
message: format!(
"{}: ImportNarToStore ran without bytes",
spec.name,
),
});
};
let path = env
.import_nar(&narinfo.store_path, bytes)
.map_err(|e| SpecError::Interp {
phase: "import-nar".into(),
message: format!("{}: {e}", spec.name),
})?;
import_path = Some(path);
}
SubstituterPhaseKind::RealizeReferences => {
}
}
}
Ok(SubstituteOutcome {
store_path: import_path.unwrap_or(narinfo.store_path),
nar_hash: narinfo.nar_hash,
references: narinfo.references,
})
}
pub const CANONICAL_SUBSTITUTERS_LISP: &str =
include_str!("../specs/substituters.lisp");
pub fn load_canonical() -> Result<Vec<SubstituterSpec>, SpecError> {
crate::loader::load_all::<SubstituterSpec>(CANONICAL_SUBSTITUTERS_LISP)
}
pub fn load_named(name: &str) -> Result<SubstituterSpec, SpecError> {
load_canonical()?
.into_iter()
.find(|s| s.name == name)
.ok_or_else(|| SpecError::Load(format!("no (defsubstituter) with :name {name:?}")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_substituters_parse() {
let specs = load_canonical().expect("canonical substituters must compile");
assert!(!specs.is_empty());
}
#[test]
fn cache_nixos_org_is_https_none() {
let s = load_named("cache.nixos.org").unwrap();
assert_eq!(s.transport, SubstituterTransport::Https);
assert_eq!(s.auth, SubstituterAuth::None);
assert_eq!(s.trust_level, TrustLevel::Trusted);
}
#[test]
fn attic_is_https_or_attic_bearer() {
let s = load_named("attic").unwrap();
assert!(
matches!(s.transport, SubstituterTransport::Https | SubstituterTransport::Attic),
"got transport {:?}",
s.transport,
);
assert_eq!(s.auth, SubstituterAuth::BearerToken);
}
#[test]
fn every_substituter_has_pipeline_essentials() {
let specs = load_canonical().unwrap();
for spec in &specs {
let kinds: Vec<SubstituterPhaseKind> =
spec.phases.iter().map(|p| p.kind).collect();
for required in [
SubstituterPhaseKind::QueryNarInfo,
SubstituterPhaseKind::FetchNar,
SubstituterPhaseKind::ImportNarToStore,
] {
assert!(
kinds.contains(&required),
"{}: missing required phase {required:?}",
spec.name,
);
}
}
}
#[test]
fn trusted_substituters_verify_signatures() {
let specs = load_canonical().unwrap();
for spec in &specs {
if matches!(spec.trust_level, TrustLevel::Trusted) {
let kinds: Vec<SubstituterPhaseKind> =
spec.phases.iter().map(|p| p.kind).collect();
assert!(
kinds.contains(&SubstituterPhaseKind::VerifyNarSignature),
"{}: trusted substituter must verify signatures",
spec.name,
);
}
}
}
use std::cell::RefCell;
use std::collections::HashMap;
struct MockEnv {
narinfos: HashMap<String, NarInfoRecord>,
nars: HashMap<String, Vec<u8>>,
store: RefCell<HashMap<String, Vec<u8>>>,
verify_sig_returns: bool,
nar_hash_matches: bool,
}
impl MockEnv {
fn new() -> Self {
Self {
narinfos: HashMap::new(),
nars: HashMap::new(),
store: RefCell::new(HashMap::new()),
verify_sig_returns: true,
nar_hash_matches: true,
}
}
fn with_path(mut self, hash: &str, record: NarInfoRecord, nar: Vec<u8>) -> Self {
self.nars.insert(record.url.clone(), nar);
self.narinfos.insert(hash.into(), record);
self
}
}
impl SubstituterEnvironment for MockEnv {
fn query_narinfo(&self, _endpoint: &str, hash: &str)
-> Result<Option<NarInfoRecord>, String>
{
Ok(self.narinfos.get(hash).cloned())
}
fn fetch_nar(&self, _endpoint: &str, url: &str) -> Result<Vec<u8>, String> {
self.nars.get(url).cloned().ok_or_else(|| format!("no nar at {url}"))
}
fn verify_signatures(&self, _record: &NarInfoRecord) -> Result<bool, String> {
Ok(self.verify_sig_returns)
}
fn decompress(&self, _compression: &str, bytes: &[u8])
-> Result<Vec<u8>, String>
{
Ok(bytes.to_vec())
}
fn verify_nar_hash(&self, _expected: &str, _bytes: &[u8])
-> Result<bool, String>
{
Ok(self.nar_hash_matches)
}
fn import_nar(&self, store_path: &str, nar_bytes: &[u8])
-> Result<String, String>
{
self.store.borrow_mut().insert(store_path.into(), nar_bytes.to_vec());
Ok(store_path.into())
}
}
fn rec(hash: &str, store_path: &str, nar: &str) -> NarInfoRecord {
NarInfoRecord {
store_path: store_path.into(),
url: format!("nar/{hash}.nar.xz"),
compression: "xz".into(),
file_hash: format!("sha256:test-{hash}-file"),
nar_hash: format!("sha256:test-{hash}-nar"),
nar_size: nar.len() as u64,
references: Vec::new(),
signatures: vec![format!("cache.nixos.org-1:test-sig-{hash}")],
}
}
#[test]
fn substitute_happy_path_imports_to_store() {
let spec = load_named("cache.nixos.org").unwrap();
let env = MockEnv::new().with_path(
"abc123",
rec("abc123", "/nix/store/abc123-hello", "nar bytes"),
b"nar bytes".to_vec(),
);
let args = SubstituteArgs {
store_path_hash: "abc123".into(),
name_hint: Some("hello".into()),
};
let outcome = apply(&spec, &args, &env).unwrap();
assert_eq!(outcome.store_path, "/nix/store/abc123-hello");
assert!(outcome.nar_hash.contains("abc123"));
assert!(env.store.borrow().contains_key("/nix/store/abc123-hello"));
}
#[test]
fn substitute_missing_path_errors() {
let spec = load_named("cache.nixos.org").unwrap();
let env = MockEnv::new();
let args = SubstituteArgs {
store_path_hash: "nonexistent".into(),
name_hint: None,
};
let err = apply(&spec, &args, &env).unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "narinfo-not-found"),
_ => panic!("expected narinfo-not-found"),
}
}
#[test]
fn trusted_substituter_with_bad_signature_errors() {
let spec = load_named("cache.nixos.org").unwrap(); let mut env = MockEnv::new().with_path(
"abc",
rec("abc", "/nix/store/abc-x", "bytes"),
b"bytes".to_vec(),
);
env.verify_sig_returns = false;
let args = SubstituteArgs {
store_path_hash: "abc".into(),
name_hint: None,
};
let err = apply(&spec, &args, &env).unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "signature-required"),
_ => panic!("expected signature-required"),
}
}
#[test]
fn untrusted_substituter_skips_signature_check() {
let spec = load_named("local-mirror").unwrap(); let mut env = MockEnv::new().with_path(
"xyz",
rec("xyz", "/nix/store/xyz-y", "bytes"),
b"bytes".to_vec(),
);
env.verify_sig_returns = false;
let args = SubstituteArgs {
store_path_hash: "xyz".into(),
name_hint: None,
};
let outcome = apply(&spec, &args, &env).unwrap();
assert_eq!(outcome.store_path, "/nix/store/xyz-y");
}
#[test]
fn nar_hash_mismatch_errors() {
let spec = load_named("cache.nixos.org").unwrap();
let mut env = MockEnv::new().with_path(
"def",
rec("def", "/nix/store/def-z", "bytes"),
b"bytes".to_vec(),
);
env.nar_hash_matches = false;
let args = SubstituteArgs {
store_path_hash: "def".into(),
name_hint: None,
};
let err = apply(&spec, &args, &env).unwrap_err();
match err {
SpecError::Interp { phase, .. } => assert_eq!(phase, "nar-hash-mismatch"),
_ => panic!("expected nar-hash-mismatch"),
}
}
#[test]
fn references_returned_for_closure_walk() {
let spec = load_named("cache.nixos.org").unwrap();
let mut record = rec("ghi", "/nix/store/ghi-app", "bytes");
record.references = vec![
"/nix/store/dep1".into(),
"/nix/store/dep2".into(),
];
let env = MockEnv::new().with_path("ghi", record, b"bytes".to_vec());
let args = SubstituteArgs {
store_path_hash: "ghi".into(),
name_hint: None,
};
let outcome = apply(&spec, &args, &env).unwrap();
assert_eq!(outcome.references.len(), 2);
assert!(outcome.references.contains(&"/nix/store/dep1".into()));
}
}