1use crate::core;
4use crate::kernel::{self, Form};
5use crate::snapshot::{
6 self, Digest, LibraryRef, NamespaceImage, ResolvedSnapshot, SecretRequirement,
7 SnapshotArtifact, SnapshotManifest,
8};
9use sha2::{Digest as ShaDigest, Sha256};
10use std::collections::{BTreeMap, BTreeSet};
11use std::fs;
12use std::path::Path;
13
14pub fn run(args: &[String]) -> Result<(), String> {
15 match args.first().map(String::as_str) {
16 Some("build") => build(&args[1..]),
17 Some("verify") => verify(&args[1..]),
18 Some("inspect") => inspect(&args[1..]),
19 Some("diff") => diff(&args[1..]),
20 Some(command) => Err(format!("unknown snapshot command: {command}")),
21 None => Err("usage: hara snapshot <build|verify|inspect|diff> ...".into()),
22 }
23}
24
25fn build(args: &[String]) -> Result<(), String> {
26 let source = args
27 .first()
28 .ok_or("usage: hara snapshot build SNAPSHOT.edn --output FILE.hss")?;
29 let output = option(args, "--output").ok_or("snapshot build requires --output FILE.hss")?;
30 println!("{}", build_paths(Path::new(source), Path::new(output))?);
31 Ok(())
32}
33
34pub fn build_paths(source: &Path, output: &Path) -> Result<String, String> {
35 let source_path = source.to_path_buf();
36 let source_text = fs::read_to_string(&source_path)
37 .map_err(|error| format!("cannot read {}: {error}", source_path.display()))?;
38 let form = kernel::parse(&source_text)
39 .map_err(|error| format!("cannot parse {}: {error}", source_path.display()))?;
40 let artifact = artifact_from_form(&form, source_path.parent().unwrap_or(Path::new(".")))?;
41 let bytes = snapshot::encode(&artifact)?;
42 fs::write(output, &bytes)
43 .map_err(|error| format!("cannot write {}: {error}", output.display()))?;
44 let resolved = resolve_with_declared_base(
45 &artifact,
46 &form,
47 source_path.parent().unwrap_or(Path::new(".")),
48 )?;
49 Ok(format!(
50 "snapshot build: {} {} bytes{}",
51 snapshot::hex(&resolved.digest),
52 bytes.len(),
53 if artifact.is_incremental() {
54 " incremental"
55 } else {
56 ""
57 }
58 ))
59}
60
61fn verify(args: &[String]) -> Result<(), String> {
62 let path = args
63 .first()
64 .ok_or("usage: hara snapshot verify FILE.hss [--base BASE.hss]")?;
65 println!(
66 "{}",
67 verify_paths(Path::new(path), option(args, "--base").map(Path::new))?
68 );
69 Ok(())
70}
71
72pub fn verify_paths(path: &Path, base: Option<&Path>) -> Result<String, String> {
73 let artifact = read_artifact(path)?;
74 let base = base.map(read_full_resolved).transpose()?;
75 let resolved = artifact.resolve(base.as_ref())?;
76 Ok(format!(
77 "snapshot verify: {} namespaces={} libraries={} secrets={}",
78 snapshot::hex(&resolved.digest),
79 resolved.manifest.namespaces.len(),
80 resolved.manifest.libraries.len(),
81 resolved.manifest.secrets.len()
82 ))
83}
84
85fn inspect(args: &[String]) -> Result<(), String> {
86 let path = args
87 .first()
88 .ok_or("usage: hara snapshot inspect FILE.hss")?;
89 print!("{}", inspect_path(Path::new(path))?);
90 Ok(())
91}
92
93pub fn inspect_path(path: &Path) -> Result<String, String> {
94 use std::fmt::Write as _;
95 let artifact = read_artifact(path)?;
96 let mut output = String::new();
97 writeln!(output, "format: HSS0").unwrap();
98 writeln!(
99 output,
100 "artifact: {}",
101 snapshot::hex(&snapshot::artifact_digest(&fs::read(path).map_err(io)?))
102 )
103 .unwrap();
104 writeln!(
105 output,
106 "base: {}",
107 artifact
108 .base
109 .as_ref()
110 .map(snapshot::hex)
111 .unwrap_or_else(|| "none".into())
112 )
113 .unwrap();
114 writeln!(output, "language: {}", artifact.manifest.language_version).unwrap();
115 writeln!(output, "libraries: {}", artifact.manifest.libraries.len()).unwrap();
116 for library in &artifact.manifest.libraries {
117 writeln!(
118 output,
119 " {} {} {}",
120 library.id,
121 library.version,
122 snapshot::hex(&library.digest)
123 )
124 .unwrap();
125 }
126 writeln!(output, "namespaces: {}", artifact.manifest.namespaces.len()).unwrap();
127 for namespace in &artifact.manifest.namespaces {
128 writeln!(
129 output,
130 " {} {} {}",
131 namespace.name,
132 snapshot::hex(&namespace.digest),
133 if namespace.halc.is_some() {
134 "embedded"
135 } else {
136 "inherited"
137 }
138 )
139 .unwrap();
140 }
141 writeln!(output, "entrypoints:").unwrap();
142 for (name, target) in &artifact.manifest.entrypoints {
143 writeln!(output, " {name} -> {target}").unwrap();
144 }
145 writeln!(
146 output,
147 "secret requirements: {}",
148 artifact.manifest.secrets.len()
149 )
150 .unwrap();
151 for secret in &artifact.manifest.secrets {
152 writeln!(
153 output,
154 " {} required={} version={} purpose={}",
155 secret.id,
156 secret.required,
157 secret.version.as_deref().unwrap_or("unspecified"),
158 secret.purpose
159 )
160 .unwrap();
161 }
162 Ok(output)
163}
164
165fn diff(args: &[String]) -> Result<(), String> {
166 let [left, right] = args else {
167 return Err("usage: hara snapshot diff LEFT.hss RIGHT.hss".into());
168 };
169 print!("{}", diff_paths(Path::new(left), Path::new(right))?);
170 Ok(())
171}
172
173pub fn diff_paths(left: &Path, right: &Path) -> Result<String, String> {
174 use std::fmt::Write as _;
175 let left = read_full_resolved(left)?;
176 let right_artifact = read_artifact(right)?;
177 let right = if right_artifact.base == Some(left.digest) {
178 right_artifact.resolve(Some(&left))?
179 } else {
180 right_artifact.resolve(None)?
181 };
182 let mut output = String::new();
183 writeln!(output, "left: {}", snapshot::hex(&left.digest)).unwrap();
184 writeln!(output, "right: {}", snapshot::hex(&right.digest)).unwrap();
185 output.push_str(&report_set_diff(
186 "namespaces",
187 left.manifest
188 .namespaces
189 .iter()
190 .map(|value| value.name.as_str()),
191 right
192 .manifest
193 .namespaces
194 .iter()
195 .map(|value| value.name.as_str()),
196 ));
197 output.push_str(&report_set_diff(
198 "entrypoints",
199 left.manifest.entrypoints.keys().map(String::as_str),
200 right.manifest.entrypoints.keys().map(String::as_str),
201 ));
202 output.push_str(&report_set_diff(
203 "state",
204 left.manifest.initial_state.keys().map(String::as_str),
205 right.manifest.initial_state.keys().map(String::as_str),
206 ));
207 output.push_str(&report_set_diff(
208 "secrets",
209 left.manifest.secrets.iter().map(|value| value.id.as_str()),
210 right.manifest.secrets.iter().map(|value| value.id.as_str()),
211 ));
212 Ok(output)
213}
214
215fn artifact_from_form(form: &Form, root: &Path) -> Result<SnapshotArtifact, String> {
216 let entries = as_map(form, "snapshot document must be a map")?;
217 reject_secret_values(entries)?;
218 let language_version = string(
219 required(entries, "snapshot/language-version")?,
220 ":snapshot/language-version",
221 )?;
222 let dependency_lock_digest =
223 digest_form(required(entries, "snapshot/dependency-lock-digest")?)?;
224 let base_path = optional_string(entries, "snapshot/base")?;
225 let base = base_path
226 .as_deref()
227 .map(|path| read_full_resolved(&root.join(path)))
228 .transpose()?;
229
230 let libraries = optional_vector(entries, "snapshot/libraries")?
231 .unwrap_or_default()
232 .iter()
233 .map(|form| {
234 let library = as_map(form, "snapshot library must be a map")?;
235 Ok(LibraryRef {
236 id: name(required(library, "library/id")?, ":library/id")?,
237 version: string(required(library, "library/version")?, ":library/version")?,
238 digest: digest_form(required(library, "library/digest")?)?,
239 })
240 })
241 .collect::<Result<Vec<_>, String>>()?;
242
243 let namespaces = optional_vector(entries, "snapshot/namespaces")?
244 .unwrap_or_default()
245 .iter()
246 .map(|form| namespace(form, root, base.as_ref()))
247 .collect::<Result<Vec<_>, _>>()?;
248
249 let entrypoints = optional_map(entries, "snapshot/entrypoints")?
250 .unwrap_or_default()
251 .iter()
252 .map(|(key, value)| {
253 Ok((
254 name(key, "entrypoint name")?,
255 name(value, "entrypoint target")?,
256 ))
257 })
258 .collect::<Result<BTreeMap<_, _>, String>>()?;
259
260 let initial_state = optional_map(entries, "snapshot/initial-state")?
261 .unwrap_or_default()
262 .iter()
263 .map(|(key, value)| Ok((name(key, "state name")?, core::form_to_value(value)?)))
264 .collect::<Result<BTreeMap<_, _>, String>>()?;
265
266 let capabilities = optional_collection(entries, "snapshot/capabilities")?
267 .unwrap_or_default()
268 .iter()
269 .map(|value| name(value, "capability"))
270 .collect::<Result<BTreeSet<_>, _>>()?;
271
272 let secrets = optional_vector(entries, "snapshot/secrets")?
273 .unwrap_or_default()
274 .iter()
275 .map(secret_requirement)
276 .collect::<Result<Vec<_>, _>>()?;
277
278 Ok(SnapshotArtifact {
279 base: base.as_ref().map(|value| value.digest),
280 manifest: SnapshotManifest {
281 language_version,
282 dependency_lock_digest,
283 libraries,
284 namespaces,
285 entrypoints,
286 initial_state,
287 capabilities,
288 secrets,
289 accelerators: vec![],
290 },
291 })
292}
293
294fn resolve_with_declared_base(
295 artifact: &SnapshotArtifact,
296 form: &Form,
297 root: &Path,
298) -> Result<ResolvedSnapshot, String> {
299 let entries = as_map(form, "snapshot document must be a map")?;
300 let base = optional_string(entries, "snapshot/base")?
301 .map(|path| read_full_resolved(&root.join(path)))
302 .transpose()?;
303 artifact.resolve(base.as_ref())
304}
305
306fn namespace(
307 form: &Form,
308 root: &Path,
309 base: Option<&ResolvedSnapshot>,
310) -> Result<NamespaceImage, String> {
311 let entries = as_map(form, "snapshot namespace must be a map")?;
312 let name = name(required(entries, "namespace/name")?, ":namespace/name")?;
313 if optional_bool(entries, "namespace/inherit")?.unwrap_or(false) {
314 let inherited = base
315 .and_then(|base| {
316 base.manifest
317 .namespaces
318 .iter()
319 .find(|value| value.name == name)
320 })
321 .ok_or_else(|| format!("snapshot inherited namespace is absent from base: {name}"))?;
322 return Ok(NamespaceImage {
323 name,
324 digest: inherited.digest,
325 halc: None,
326 });
327 }
328 let path = optional_string(entries, "namespace/halc")?.ok_or_else(|| {
329 format!("snapshot namespace {name} requires :namespace/halc or :namespace/inherit")
330 })?;
331 let bytes =
332 fs::read(root.join(&path)).map_err(|error| format!("cannot read {path}: {error}"))?;
333 let module = kernel::halc::decode_halc(&bytes)
334 .map_err(|error| format!("invalid HALC namespace {name}: {error}"))?;
335 if module.namespace != name {
336 return Err(format!(
337 "snapshot namespace name mismatch: declared {name}, HALC {}",
338 module.namespace
339 ));
340 }
341 Ok(NamespaceImage {
342 name,
343 digest: Sha256::digest(&bytes).into(),
344 halc: Some(bytes),
345 })
346}
347
348fn secret_requirement(form: &Form) -> Result<SecretRequirement, String> {
349 let entries = as_map(form, "secret requirement must be a map")?;
350 reject_secret_values(entries)?;
351 Ok(SecretRequirement {
352 id: name(required(entries, "secret/id")?, ":secret/id")?,
353 purpose: string(required(entries, "secret/purpose")?, ":secret/purpose")?,
354 required: optional_bool(entries, "secret/required")?.unwrap_or(true),
355 version: optional_string(entries, "secret/provider-version")?,
356 })
357}
358
359fn reject_secret_values(entries: &[(Form, Form)]) -> Result<(), String> {
360 for (key, _) in entries {
361 if matches!(key, Form::Keyword(value) if matches!(value.as_str(), "secret/value" | "secret/bytes" | "secret/key"))
362 {
363 return Err(
364 "snapshot secret material is forbidden; declare only a secret requirement".into(),
365 );
366 }
367 }
368 Ok(())
369}
370
371fn read_artifact(path: &Path) -> Result<SnapshotArtifact, String> {
372 let bytes =
373 fs::read(path).map_err(|error| format!("cannot read {}: {error}", path.display()))?;
374 snapshot::decode(&bytes)
375}
376
377fn read_full_resolved(path: &Path) -> Result<ResolvedSnapshot, String> {
378 let artifact = read_artifact(path)?;
379 if artifact.is_incremental() {
380 return Err(format!(
381 "{} is incremental; provide a resolved full base",
382 path.display()
383 ));
384 }
385 artifact.resolve(None)
386}
387
388fn report_set_diff<'a>(
389 kind: &str,
390 left: impl Iterator<Item = &'a str>,
391 right: impl Iterator<Item = &'a str>,
392) -> String {
393 use std::fmt::Write as _;
394 let mut output = String::new();
395 let left = left.collect::<BTreeSet<_>>();
396 let right = right.collect::<BTreeSet<_>>();
397 for value in right.difference(&left) {
398 writeln!(output, "+ {kind} {value}").unwrap();
399 }
400 for value in left.difference(&right) {
401 writeln!(output, "- {kind} {value}").unwrap();
402 }
403 output
404}
405
406fn option<'a>(args: &'a [String], name: &str) -> Option<&'a str> {
407 args.windows(2)
408 .find(|pair| pair[0] == name)
409 .map(|pair| pair[1].as_str())
410}
411
412fn as_map<'a>(form: &'a Form, message: &str) -> Result<&'a [(Form, Form)], String> {
413 match form {
414 Form::Map(entries) => Ok(entries),
415 _ => Err(message.into()),
416 }
417}
418
419fn required<'a>(entries: &'a [(Form, Form)], key: &str) -> Result<&'a Form, String> {
420 get(entries, key).ok_or_else(|| format!("missing :{key}"))
421}
422
423fn get<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
424 entries.iter().find_map(|(candidate, value)| {
425 matches!(candidate, Form::Keyword(name) if name == key).then_some(value)
426 })
427}
428
429fn optional_map<'a>(
430 entries: &'a [(Form, Form)],
431 key: &str,
432) -> Result<Option<&'a [(Form, Form)]>, String> {
433 get(entries, key)
434 .map(|value| as_map(value, &format!(":{key} must be a map")))
435 .transpose()
436}
437
438fn optional_vector<'a>(
439 entries: &'a [(Form, Form)],
440 key: &str,
441) -> Result<Option<&'a [Form]>, String> {
442 get(entries, key)
443 .map(|value| match value {
444 Form::Vector(values) => Ok(values.as_slice()),
445 _ => Err(format!(":{key} must be a vector")),
446 })
447 .transpose()
448}
449
450fn optional_collection<'a>(
451 entries: &'a [(Form, Form)],
452 key: &str,
453) -> Result<Option<&'a [Form]>, String> {
454 get(entries, key)
455 .map(|value| match value {
456 Form::Vector(values) | Form::Set(values) => Ok(values.as_slice()),
457 _ => Err(format!(":{key} must be a vector or set")),
458 })
459 .transpose()
460}
461
462fn optional_string(entries: &[(Form, Form)], key: &str) -> Result<Option<String>, String> {
463 get(entries, key)
464 .map(|value| string(value, &format!(":{key}")))
465 .transpose()
466}
467
468fn optional_bool(entries: &[(Form, Form)], key: &str) -> Result<Option<bool>, String> {
469 get(entries, key)
470 .map(|value| match value {
471 Form::Bool(value) => Ok(*value),
472 _ => Err(format!(":{key} must be boolean")),
473 })
474 .transpose()
475}
476
477fn string(form: &Form, label: &str) -> Result<String, String> {
478 match form {
479 Form::String(value) => Ok(value.clone()),
480 _ => Err(format!("{label} must be a string")),
481 }
482}
483
484fn name(form: &Form, label: &str) -> Result<String, String> {
485 match form {
486 Form::String(value) | Form::Symbol(value) | Form::Keyword(value) if !value.is_empty() => {
487 Ok(value.clone())
488 }
489 _ => Err(format!("{label} must be a non-empty name")),
490 }
491}
492
493fn digest_form(form: &Form) -> Result<Digest, String> {
494 let value = string(form, "digest")?;
495 let value = value.strip_prefix("sha256:").unwrap_or(&value);
496 if value.len() != 64 {
497 return Err("digest must contain 64 hexadecimal characters".into());
498 }
499 let mut digest = [0u8; 32];
500 for (index, byte) in digest.iter_mut().enumerate() {
501 *byte = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16)
502 .map_err(|_| "digest contains non-hexadecimal characters")?;
503 }
504 Ok(digest)
505}
506
507fn io(error: std::io::Error) -> String {
508 error.to_string()
509}
510
511#[cfg(test)]
512mod tests {
513 use super::*;
514
515 #[test]
516 fn rejects_inline_secret_material() {
517 let form = kernel::parse(
518 "{:snapshot/language-version \"0.1\" \
519 :snapshot/dependency-lock-digest \"0000000000000000000000000000000000000000000000000000000000000000\" \
520 :snapshot/secrets [{:secret/id :api :secret/purpose \"api\" :secret/value \"no\"}]}"
521 ).unwrap();
522 assert!(artifact_from_form(&form, Path::new("."))
523 .unwrap_err()
524 .contains("secret material"));
525 }
526
527 #[test]
528 fn builds_a_minimal_manifest() {
529 let form = kernel::parse(
530 "{:snapshot/language-version \"0.1\" \
531 :snapshot/dependency-lock-digest \"0000000000000000000000000000000000000000000000000000000000000000\" \
532 :snapshot/entrypoints {:api app/handler} \
533 :snapshot/initial-state {:flags {:enabled true}} \
534 :snapshot/secrets [{:secret/id :api :secret/purpose \"sign\"}]}"
535 ).unwrap();
536 let artifact = artifact_from_form(&form, Path::new(".")).unwrap();
537 assert_eq!(artifact.manifest.entrypoints["api"], "app/handler");
538 assert_eq!(artifact.manifest.secrets[0].id, "api");
539 assert!(artifact.resolve(None).is_ok());
540 }
541}