1use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use serde::Deserialize;
20
21pub const REALMS_FILE: &str = "varve-realms.toml";
24
25#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Realm {
28 pub name: String,
29 pub registry: String,
33 pub sources: Vec<String>,
39 pub trust_root: Vec<u8>,
41 pub signed_index: bool,
48}
49
50impl Realm {
51 pub fn fingerprint(&self) -> String {
55 crate::store::manifest_digest(&self.trust_root)
56 .strip_prefix("sha256:")
57 .expect("digest shape")[..16]
58 .to_string()
59 }
60
61 pub fn effective_root(&self, varve_root: &Path) -> PathBuf {
63 varve_root.join("realms").join(self.fingerprint())
64 }
65}
66
67#[derive(Debug, thiserror::Error)]
68pub enum RealmError {
69 #[error(
70 "no {REALMS_FILE} found walking up from {start} — the pin names realm '{realm}' but no realm definitions exist; commit a {REALMS_FILE} defining it"
71 )]
72 NoRealmsFile { start: String, realm: String },
73 #[error("{path}: not a valid realms file: {reason}")]
74 Parse { path: String, reason: String },
75 #[error(
76 "realm '{realm}' is not defined in {path} — defined realms: {defined:?}. Fix the pin or add the realm."
77 )]
78 Undefined {
79 realm: String,
80 path: String,
81 defined: Vec<String>,
82 },
83 #[error("realm '{realm}' in {path}: {reason}")]
84 BadDefinition {
85 realm: String,
86 path: String,
87 reason: String,
88 },
89 #[error("io error at {path}")]
90 Io {
91 path: String,
92 #[source]
93 source: std::io::Error,
94 },
95}
96
97#[derive(Deserialize)]
98#[serde(deny_unknown_fields)]
99struct RawRealmsFile {
100 #[serde(default)]
101 realm: BTreeMap<String, RawRealm>,
102}
103
104#[derive(Deserialize)]
105#[serde(deny_unknown_fields)]
106struct RawRealm {
107 registry: String,
108 #[serde(default)]
117 mirrors: Vec<String>,
118 #[serde(rename = "trust-root", default)]
120 trust_root: Option<String>,
121 #[serde(rename = "trust-root-file", default)]
123 trust_root_file: Option<String>,
124 #[serde(rename = "signed-index", default)]
127 signed_index: bool,
128}
129
130pub fn find_realms_file(start: &Path) -> Option<PathBuf> {
132 let mut dir = Some(start);
133 while let Some(d) = dir {
134 let candidate = d.join(REALMS_FILE);
135 if candidate.is_file() {
136 return Some(candidate);
137 }
138 dir = d.parent();
139 }
140 None
141}
142
143pub fn realm_names(start: &Path) -> Result<Vec<String>, RealmError> {
147 let Some(path) = find_realms_file(start) else {
148 return Ok(Vec::new());
149 };
150 let text = std::fs::read_to_string(&path).map_err(|source| RealmError::Io {
151 path: path.display().to_string(),
152 source,
153 })?;
154 let file: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
155 path: path.display().to_string(),
156 reason: e.to_string(),
157 })?;
158 Ok(file.realm.into_keys().collect())
159}
160
161pub fn resolve_realm(start: &Path, name: &str) -> Result<Realm, RealmError> {
163 let Some(path) = find_realms_file(start) else {
164 return Err(RealmError::NoRealmsFile {
165 start: start.display().to_string(),
166 realm: name.to_string(),
167 });
168 };
169 let text = std::fs::read_to_string(&path).map_err(|source| RealmError::Io {
170 path: path.display().to_string(),
171 source,
172 })?;
173 let raw: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
174 path: path.display().to_string(),
175 reason: e.to_string(),
176 })?;
177 let Some(def) = raw.realm.get(name) else {
178 return Err(RealmError::Undefined {
179 realm: name.to_string(),
180 path: path.display().to_string(),
181 defined: raw.realm.keys().cloned().collect(),
182 });
183 };
184 let bad = |reason: String| RealmError::BadDefinition {
185 realm: name.to_string(),
186 path: path.display().to_string(),
187 reason,
188 };
189 let hex_key = match (&def.trust_root, &def.trust_root_file) {
190 (Some(_), Some(_)) => {
191 return Err(bad(
192 "both trust-root and trust-root-file given — pick one".into()
193 ));
194 }
195 (Some(inline), None) => inline.trim().to_string(),
196 (None, Some(file)) => {
197 let key_path = path.parent().unwrap_or(Path::new(".")).join(file);
198 std::fs::read_to_string(&key_path)
199 .map_err(|e| {
200 bad(format!(
201 "cannot read trust-root-file {}: {e}",
202 key_path.display()
203 ))
204 })?
205 .trim()
206 .to_string()
207 }
208 (None, None) => return Err(bad("no trust-root or trust-root-file".into())),
209 };
210 if hex_key.len() != 64 || !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
211 return Err(bad(
212 "trust root is not a 64-hex-char ed25519 public key".into()
213 ));
214 }
215 let trust_root = (0..hex_key.len())
216 .step_by(2)
217 .map(|i| u8::from_str_radix(&hex_key[i..i + 2], 16).expect("checked hex"))
218 .collect();
219 Ok(Realm {
220 name: name.to_string(),
221 registry: def.registry.clone(),
222 sources: std::iter::once(def.registry.clone())
223 .chain(def.mirrors.iter().cloned())
224 .collect(),
225 trust_root,
226 signed_index: def.signed_index,
227 })
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 fn realms_dir(content: &str) -> tempfile::TempDir {
235 let tmp = tempfile::tempdir().unwrap();
236 std::fs::write(tmp.path().join(REALMS_FILE), content).unwrap();
237 tmp
238 }
239
240 #[test]
242 fn every_defined_realm_is_named() {
243 let dir = realms_dir(TWO_REALMS);
249 let mut names = realm_names(dir.path()).unwrap();
250 names.sort();
251 assert_eq!(names, ["acme", "pulseengine"], "both realms named");
252
253 let empty = tempfile::tempdir().unwrap();
255 assert!(realm_names(empty.path()).unwrap().is_empty());
256
257 let bad = realms_dir("this is not toml {{{");
260 assert!(realm_names(bad.path()).is_err());
261 }
262
263 const TWO_REALMS: &str = r#"
264[realm.pulseengine]
265registry = "oci://ghcr.io/pulseengine/varve/layers"
266trust-root = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
267
268[realm.acme]
269registry = "oci://ghcr.io/acme/layers"
270trust-root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
271"#;
272
273 #[test]
275 fn realms_resolve_by_name_with_walk_up_discovery() {
276 let tmp = realms_dir(TWO_REALMS);
277 let deep = tmp.path().join("a/b");
278 std::fs::create_dir_all(&deep).unwrap();
279 let realm = resolve_realm(&deep, "acme").unwrap();
280 assert_eq!(realm.registry, "oci://ghcr.io/acme/layers");
281 assert_eq!(realm.trust_root, vec![0xbb; 32]);
282 }
283
284 #[test]
286 fn different_roots_mean_different_namespaces() {
287 let tmp = realms_dir(TWO_REALMS);
288 let pe = resolve_realm(tmp.path(), "pulseengine").unwrap();
289 let acme = resolve_realm(tmp.path(), "acme").unwrap();
290 assert_ne!(pe.fingerprint(), acme.fingerprint());
291 let root = Path::new("/var/root");
292 assert_ne!(pe.effective_root(root), acme.effective_root(root));
293 assert!(pe.effective_root(root).starts_with("/var/root/realms"));
294 }
295
296 #[test]
298 fn an_undefined_realm_fails_closed_naming_what_exists() {
299 let tmp = realms_dir(TWO_REALMS);
300 let err = resolve_realm(tmp.path(), "evil-corp").unwrap_err();
301 let msg = err.to_string();
302 assert!(msg.contains("evil-corp") && msg.contains("pulseengine") && msg.contains("acme"));
303 }
304
305 #[test]
307 fn a_missing_realms_file_fails_closed_with_guidance() {
308 let tmp = tempfile::tempdir().unwrap();
309 let err = resolve_realm(tmp.path(), "pulseengine").unwrap_err();
310 assert!(err.to_string().contains(REALMS_FILE));
311 }
312
313 #[test]
315 fn trust_root_file_is_read_relative_to_the_realms_file() {
316 let tmp = tempfile::tempdir().unwrap();
317 std::fs::create_dir_all(tmp.path().join("keys")).unwrap();
318 std::fs::write(tmp.path().join("keys/root.pub"), "cc".repeat(32)).unwrap();
319 std::fs::write(
320 tmp.path().join(REALMS_FILE),
321 "[realm.filekey]\nregistry = \"oci://r/x\"\ntrust-root-file = \"keys/root.pub\"\n",
322 )
323 .unwrap();
324 let realm = resolve_realm(tmp.path(), "filekey").unwrap();
325 assert_eq!(realm.trust_root, vec![0xcc; 32]);
326 }
327
328 #[test]
330 fn malformed_definitions_are_refused() {
331 for (name, body) in [
332 ("nokey", "[realm.nokey]\nregistry = \"oci://r/x\"\n"),
333 (
334 "badkey",
335 "[realm.badkey]\nregistry = \"oci://r/x\"\ntrust-root = \"zz\"\n",
336 ),
337 (
340 "shorthex",
341 "[realm.shorthex]\nregistry = \"oci://r/x\"\ntrust-root = \"cccccccccccccccccccccccccccccccc\"\n",
342 ),
343 (
344 "bothkeys",
345 "[realm.bothkeys]\nregistry = \"oci://r/x\"\ntrust-root = \"aa\"\ntrust-root-file = \"f\"\n",
346 ),
347 ] {
348 let tmp = realms_dir(body);
349 assert!(
350 resolve_realm(tmp.path(), name).is_err(),
351 "{name} must refuse"
352 );
353 }
354 }
355
356 #[test]
358 fn a_realm_declares_whether_it_publishes_a_signed_index() {
359 let tmp = realms_dir(
364 r#"
365[realm.declaring]
366registry = "oci://example.test/layers"
367trust-root = "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973"
368signed-index = true
369
370[realm.silent]
371registry = "oci://example.test/other"
372trust-root = "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973"
373"#,
374 );
375 assert!(
376 resolve_realm(tmp.path(), "declaring").unwrap().signed_index,
377 "a realm that declares an index must be recorded as declaring it"
378 );
379 assert!(
380 !resolve_realm(tmp.path(), "silent").unwrap().signed_index,
381 "the default must be false, or every existing realm breaks at once"
382 );
383 }
384}
385
386#[cfg(test)]
387mod mirror_tests {
388 use super::*;
389
390 fn parse(text: &str, name: &str) -> Realm {
391 let dir = std::env::temp_dir().join(format!("varve-realm-mirror-{name}"));
392 let _ = std::fs::remove_dir_all(&dir);
393 std::fs::create_dir_all(&dir).expect("scratch");
394 std::fs::write(dir.join(REALMS_FILE), text).expect("write");
395 resolve_realm(&dir, name).expect("parses")
396 }
397
398 #[test]
402 fn a_realm_naming_one_registry_still_works_and_has_one_source() {
403 let r = parse(
404 "[realm.solo]\nregistry = \"oci://ghcr.io/o/r\"\n\
405 trust-root = \"4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973\"\n",
406 "solo",
407 );
408 assert_eq!(r.registry, "oci://ghcr.io/o/r");
409 assert_eq!(r.sources, vec!["oci://ghcr.io/o/r".to_string()]);
410 }
411
412 #[test]
416 fn mirrors_follow_the_primary_in_the_order_they_are_written() {
417 let r = parse(
418 "[realm.many]\nregistry = \"oci://primary\"\n\
419 mirrors = [\"oci://second\", \"oci://third\"]\n\
420 trust-root = \"4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973\"\n",
421 "many",
422 );
423 assert_eq!(
424 r.sources,
425 vec![
426 "oci://primary".to_string(),
427 "oci://second".to_string(),
428 "oci://third".to_string()
429 ]
430 );
431 assert_eq!(r.registry, "oci://primary");
433 }
434
435 #[test]
440 fn mirrors_cannot_carry_a_trust_root_of_their_own() {
441 let dir = std::env::temp_dir().join("varve-realm-mirror-root");
442 let _ = std::fs::remove_dir_all(&dir);
443 std::fs::create_dir_all(&dir).expect("scratch");
444 std::fs::write(
445 dir.join(REALMS_FILE),
446 "[realm.x]\nregistry = \"oci://a\"\n\
447 mirrors = [{ registry = \"oci://b\", trust-root = \"dead\" }]\n\
448 trust-root = \"4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973\"\n",
449 )
450 .expect("write");
451 assert!(
452 resolve_realm(&dir, "x").is_err(),
453 "a mirror must not be able to declare its own trust root"
454 );
455 }
456}