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,
30 pub trust_root: Vec<u8>,
32 pub signed_index: bool,
39}
40
41impl Realm {
42 pub fn fingerprint(&self) -> String {
46 crate::store::manifest_digest(&self.trust_root)
47 .strip_prefix("sha256:")
48 .expect("digest shape")[..16]
49 .to_string()
50 }
51
52 pub fn effective_root(&self, varve_root: &Path) -> PathBuf {
54 varve_root.join("realms").join(self.fingerprint())
55 }
56}
57
58#[derive(Debug, thiserror::Error)]
59pub enum RealmError {
60 #[error(
61 "no {REALMS_FILE} found walking up from {start} — the pin names realm '{realm}' but no realm definitions exist; commit a {REALMS_FILE} defining it"
62 )]
63 NoRealmsFile { start: String, realm: String },
64 #[error("{path}: not a valid realms file: {reason}")]
65 Parse { path: String, reason: String },
66 #[error(
67 "realm '{realm}' is not defined in {path} — defined realms: {defined:?}. Fix the pin or add the realm."
68 )]
69 Undefined {
70 realm: String,
71 path: String,
72 defined: Vec<String>,
73 },
74 #[error("realm '{realm}' in {path}: {reason}")]
75 BadDefinition {
76 realm: String,
77 path: String,
78 reason: String,
79 },
80 #[error("io error at {path}")]
81 Io {
82 path: String,
83 #[source]
84 source: std::io::Error,
85 },
86}
87
88#[derive(Deserialize)]
89#[serde(deny_unknown_fields)]
90struct RawRealmsFile {
91 #[serde(default)]
92 realm: BTreeMap<String, RawRealm>,
93}
94
95#[derive(Deserialize)]
96#[serde(deny_unknown_fields)]
97struct RawRealm {
98 registry: String,
99 #[serde(rename = "trust-root", default)]
101 trust_root: Option<String>,
102 #[serde(rename = "trust-root-file", default)]
104 trust_root_file: Option<String>,
105 #[serde(rename = "signed-index", default)]
108 signed_index: bool,
109}
110
111pub fn find_realms_file(start: &Path) -> Option<PathBuf> {
113 let mut dir = Some(start);
114 while let Some(d) = dir {
115 let candidate = d.join(REALMS_FILE);
116 if candidate.is_file() {
117 return Some(candidate);
118 }
119 dir = d.parent();
120 }
121 None
122}
123
124pub fn realm_names(start: &Path) -> Result<Vec<String>, RealmError> {
128 let Some(path) = find_realms_file(start) else {
129 return Ok(Vec::new());
130 };
131 let text = std::fs::read_to_string(&path).map_err(|source| RealmError::Io {
132 path: path.display().to_string(),
133 source,
134 })?;
135 let file: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
136 path: path.display().to_string(),
137 reason: e.to_string(),
138 })?;
139 Ok(file.realm.into_keys().collect())
140}
141
142pub fn resolve_realm(start: &Path, name: &str) -> Result<Realm, RealmError> {
144 let Some(path) = find_realms_file(start) else {
145 return Err(RealmError::NoRealmsFile {
146 start: start.display().to_string(),
147 realm: name.to_string(),
148 });
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 raw: RawRealmsFile = toml::from_str(&text).map_err(|e| RealmError::Parse {
155 path: path.display().to_string(),
156 reason: e.to_string(),
157 })?;
158 let Some(def) = raw.realm.get(name) else {
159 return Err(RealmError::Undefined {
160 realm: name.to_string(),
161 path: path.display().to_string(),
162 defined: raw.realm.keys().cloned().collect(),
163 });
164 };
165 let bad = |reason: String| RealmError::BadDefinition {
166 realm: name.to_string(),
167 path: path.display().to_string(),
168 reason,
169 };
170 let hex_key = match (&def.trust_root, &def.trust_root_file) {
171 (Some(_), Some(_)) => {
172 return Err(bad(
173 "both trust-root and trust-root-file given — pick one".into()
174 ));
175 }
176 (Some(inline), None) => inline.trim().to_string(),
177 (None, Some(file)) => {
178 let key_path = path.parent().unwrap_or(Path::new(".")).join(file);
179 std::fs::read_to_string(&key_path)
180 .map_err(|e| {
181 bad(format!(
182 "cannot read trust-root-file {}: {e}",
183 key_path.display()
184 ))
185 })?
186 .trim()
187 .to_string()
188 }
189 (None, None) => return Err(bad("no trust-root or trust-root-file".into())),
190 };
191 if hex_key.len() != 64 || !hex_key.chars().all(|c| c.is_ascii_hexdigit()) {
192 return Err(bad(
193 "trust root is not a 64-hex-char ed25519 public key".into()
194 ));
195 }
196 let trust_root = (0..hex_key.len())
197 .step_by(2)
198 .map(|i| u8::from_str_radix(&hex_key[i..i + 2], 16).expect("checked hex"))
199 .collect();
200 Ok(Realm {
201 name: name.to_string(),
202 registry: def.registry.clone(),
203 trust_root,
204 signed_index: def.signed_index,
205 })
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn realms_dir(content: &str) -> tempfile::TempDir {
213 let tmp = tempfile::tempdir().unwrap();
214 std::fs::write(tmp.path().join(REALMS_FILE), content).unwrap();
215 tmp
216 }
217
218 #[test]
220 fn every_defined_realm_is_named() {
221 let dir = realms_dir(TWO_REALMS);
227 let mut names = realm_names(dir.path()).unwrap();
228 names.sort();
229 assert_eq!(names, ["acme", "pulseengine"], "both realms named");
230
231 let empty = tempfile::tempdir().unwrap();
233 assert!(realm_names(empty.path()).unwrap().is_empty());
234
235 let bad = realms_dir("this is not toml {{{");
238 assert!(realm_names(bad.path()).is_err());
239 }
240
241 const TWO_REALMS: &str = r#"
242[realm.pulseengine]
243registry = "oci://ghcr.io/pulseengine/varve/layers"
244trust-root = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
245
246[realm.acme]
247registry = "oci://ghcr.io/acme/layers"
248trust-root = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
249"#;
250
251 #[test]
253 fn realms_resolve_by_name_with_walk_up_discovery() {
254 let tmp = realms_dir(TWO_REALMS);
255 let deep = tmp.path().join("a/b");
256 std::fs::create_dir_all(&deep).unwrap();
257 let realm = resolve_realm(&deep, "acme").unwrap();
258 assert_eq!(realm.registry, "oci://ghcr.io/acme/layers");
259 assert_eq!(realm.trust_root, vec![0xbb; 32]);
260 }
261
262 #[test]
264 fn different_roots_mean_different_namespaces() {
265 let tmp = realms_dir(TWO_REALMS);
266 let pe = resolve_realm(tmp.path(), "pulseengine").unwrap();
267 let acme = resolve_realm(tmp.path(), "acme").unwrap();
268 assert_ne!(pe.fingerprint(), acme.fingerprint());
269 let root = Path::new("/var/root");
270 assert_ne!(pe.effective_root(root), acme.effective_root(root));
271 assert!(pe.effective_root(root).starts_with("/var/root/realms"));
272 }
273
274 #[test]
276 fn an_undefined_realm_fails_closed_naming_what_exists() {
277 let tmp = realms_dir(TWO_REALMS);
278 let err = resolve_realm(tmp.path(), "evil-corp").unwrap_err();
279 let msg = err.to_string();
280 assert!(msg.contains("evil-corp") && msg.contains("pulseengine") && msg.contains("acme"));
281 }
282
283 #[test]
285 fn a_missing_realms_file_fails_closed_with_guidance() {
286 let tmp = tempfile::tempdir().unwrap();
287 let err = resolve_realm(tmp.path(), "pulseengine").unwrap_err();
288 assert!(err.to_string().contains(REALMS_FILE));
289 }
290
291 #[test]
293 fn trust_root_file_is_read_relative_to_the_realms_file() {
294 let tmp = tempfile::tempdir().unwrap();
295 std::fs::create_dir_all(tmp.path().join("keys")).unwrap();
296 std::fs::write(tmp.path().join("keys/root.pub"), "cc".repeat(32)).unwrap();
297 std::fs::write(
298 tmp.path().join(REALMS_FILE),
299 "[realm.filekey]\nregistry = \"oci://r/x\"\ntrust-root-file = \"keys/root.pub\"\n",
300 )
301 .unwrap();
302 let realm = resolve_realm(tmp.path(), "filekey").unwrap();
303 assert_eq!(realm.trust_root, vec![0xcc; 32]);
304 }
305
306 #[test]
308 fn malformed_definitions_are_refused() {
309 for (name, body) in [
310 ("nokey", "[realm.nokey]\nregistry = \"oci://r/x\"\n"),
311 (
312 "badkey",
313 "[realm.badkey]\nregistry = \"oci://r/x\"\ntrust-root = \"zz\"\n",
314 ),
315 (
318 "shorthex",
319 "[realm.shorthex]\nregistry = \"oci://r/x\"\ntrust-root = \"cccccccccccccccccccccccccccccccc\"\n",
320 ),
321 (
322 "bothkeys",
323 "[realm.bothkeys]\nregistry = \"oci://r/x\"\ntrust-root = \"aa\"\ntrust-root-file = \"f\"\n",
324 ),
325 ] {
326 let tmp = realms_dir(body);
327 assert!(
328 resolve_realm(tmp.path(), name).is_err(),
329 "{name} must refuse"
330 );
331 }
332 }
333
334 #[test]
336 fn a_realm_declares_whether_it_publishes_a_signed_index() {
337 let tmp = realms_dir(
342 r#"
343[realm.declaring]
344registry = "oci://example.test/layers"
345trust-root = "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973"
346signed-index = true
347
348[realm.silent]
349registry = "oci://example.test/other"
350trust-root = "4e771dc62a08be89e3450f8cd807da58ff70af4a4e124ebf2d2b71684cfd9973"
351"#,
352 );
353 assert!(
354 resolve_realm(tmp.path(), "declaring").unwrap().signed_index,
355 "a realm that declares an index must be recorded as declaring it"
356 );
357 assert!(
358 !resolve_realm(tmp.path(), "silent").unwrap().signed_index,
359 "the default must be false, or every existing realm breaks at once"
360 );
361 }
362}