Skip to main content

hara_native/
snapshot_tool.rs

1//! Native CLI adapter for portable HSS0 snapshots.
2
3use 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}