Skip to main content

greentic_component/cmd/
hash.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use clap::{Args, Parser};
6use serde_json::Value;
7
8use crate::path_safety::normalize_under_root;
9
10#[derive(Args, Debug, Clone)]
11#[command(about = "Recompute the wasm hash inside component.manifest.json")]
12pub struct HashArgs {
13    /// Path to component.manifest.json
14    #[arg(default_value = "component.manifest.json")]
15    pub manifest: PathBuf,
16    /// Optional override for the wasm artifact path
17    #[arg(long)]
18    pub wasm: Option<PathBuf>,
19}
20
21#[derive(Parser, Debug)]
22struct HashCli {
23    #[command(flatten)]
24    args: HashArgs,
25}
26
27pub fn parse_from_cli() -> HashArgs {
28    HashCli::parse().args
29}
30
31pub fn run(args: HashArgs) -> Result<()> {
32    let workspace_root = std::env::current_dir()
33        .context("failed to read current directory")?
34        .canonicalize()
35        .context("failed to canonicalize workspace root")?;
36    let manifest_path =
37        normalize_or_canonicalize(&workspace_root, &args.manifest).with_context(|| {
38            format!(
39                "manifest path escapes workspace root: {}",
40                args.manifest.display()
41            )
42        })?;
43    let manifest_text = fs::read_to_string(&manifest_path)
44        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
45    let mut manifest: Value = serde_json::from_str(&manifest_text)
46        .with_context(|| format!("invalid json: {}", manifest_path.display()))?;
47    let manifest_root = manifest_path
48        .parent()
49        .unwrap_or(workspace_root.as_path())
50        .canonicalize()
51        .with_context(|| {
52            format!(
53                "failed to canonicalize manifest directory {}",
54                manifest_path.display()
55            )
56        })?;
57    let wasm_candidate = resolve_wasm_path(&manifest, args.wasm.as_deref())?;
58    let wasm_path =
59        normalize_or_canonicalize(&manifest_root, &wasm_candidate).with_context(|| {
60            format!(
61                "wasm path escapes manifest root {}",
62                manifest_root.display()
63            )
64        })?;
65    let wasm_bytes = fs::read(&wasm_path)
66        .with_context(|| format!("failed to read wasm at {}", wasm_path.display()))?;
67    let digest = blake3::hash(&wasm_bytes).to_hex().to_string();
68    manifest["hashes"]["component_wasm"] = Value::String(format!("blake3:{digest}"));
69    let formatted = serde_json::to_string_pretty(&manifest)?;
70    fs::write(&manifest_path, formatted + "\n")
71        .with_context(|| format!("failed to write {}", manifest_path.display()))?;
72    println!(
73        "Updated {} with hash of {}",
74        manifest_path.display(),
75        wasm_path.display()
76    );
77    Ok(())
78}
79
80fn resolve_wasm_path(manifest: &Value, override_path: Option<&Path>) -> Result<PathBuf> {
81    if let Some(path) = override_path {
82        return Ok(path.to_path_buf());
83    }
84    let artifact = manifest
85        .get("artifacts")
86        .and_then(|art| art.get("component_wasm"))
87        .and_then(Value::as_str)
88        .ok_or_else(|| anyhow::anyhow!("manifest is missing artifacts.component_wasm"))?;
89    Ok(PathBuf::from(artifact))
90}
91
92fn normalize_or_canonicalize(root: &Path, candidate: &Path) -> Result<PathBuf> {
93    if candidate.is_absolute() {
94        return candidate
95            .canonicalize()
96            .with_context(|| format!("failed to canonicalize {}", candidate.display()));
97    }
98    normalize_under_root(root, candidate)
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104    use serde_json::json;
105    use std::sync::{Mutex, OnceLock};
106
107    fn cwd_lock() -> &'static Mutex<()> {
108        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
109        LOCK.get_or_init(|| Mutex::new(()))
110    }
111
112    #[test]
113    fn resolve_wasm_path_prefers_explicit_override() {
114        let manifest = json!({
115            "artifacts": { "component_wasm": "component.wasm" }
116        });
117        let path = resolve_wasm_path(&manifest, Some(Path::new("override.wasm"))).unwrap();
118        assert_eq!(path, PathBuf::from("override.wasm"));
119    }
120
121    #[test]
122    fn resolve_wasm_path_errors_when_manifest_is_missing_artifact() {
123        let err = resolve_wasm_path(&json!({}), None).expect_err("artifact path required");
124        assert!(err.to_string().contains("artifacts.component_wasm"));
125    }
126
127    #[test]
128    fn normalize_or_canonicalize_allows_relative_paths_under_root() {
129        let root = tempfile::tempdir().expect("tempdir");
130        let wasm = root.path().join("component.wasm");
131        fs::write(&wasm, b"wasm").expect("write wasm");
132
133        let normalized =
134            normalize_or_canonicalize(root.path(), Path::new("component.wasm")).unwrap();
135        assert_eq!(
136            normalized,
137            wasm.canonicalize().expect("canonical wasm path")
138        );
139    }
140
141    #[test]
142    fn resolve_wasm_path_uses_manifest_artifact_when_present() {
143        let manifest = json!({
144            "artifacts": { "component_wasm": "dist/component.wasm" }
145        });
146
147        let path = resolve_wasm_path(&manifest, None).expect("artifact path");
148
149        assert_eq!(path, PathBuf::from("dist/component.wasm"));
150    }
151
152    #[test]
153    fn normalize_or_canonicalize_canonicalizes_absolute_paths() {
154        let root = tempfile::tempdir().expect("tempdir");
155        let wasm = root.path().join("component.wasm");
156        fs::write(&wasm, b"wasm").expect("write wasm");
157
158        let normalized = normalize_or_canonicalize(root.path(), &wasm).expect("absolute path");
159
160        assert_eq!(normalized, wasm.canonicalize().expect("canonical wasm"));
161    }
162
163    #[test]
164    fn run_updates_manifest_hash_using_manifest_artifact() {
165        let _guard = cwd_lock().lock().expect("cwd lock");
166        let original_cwd = std::env::current_dir().expect("cwd");
167        let dir = tempfile::tempdir().expect("tempdir");
168        std::env::set_current_dir(dir.path()).expect("set cwd");
169
170        let wasm = dir.path().join("component.wasm");
171        fs::write(&wasm, b"updated-wasm").expect("write wasm");
172        let manifest_path = dir.path().join("component.manifest.json");
173        fs::write(
174            &manifest_path,
175            serde_json::to_string_pretty(&json!({
176                "artifacts": { "component_wasm": "component.wasm" },
177                "hashes": { "component_wasm": "blake3:old" }
178            }))
179            .expect("manifest json"),
180        )
181        .expect("write manifest");
182
183        run(HashArgs {
184            manifest: PathBuf::from("component.manifest.json"),
185            wasm: None,
186        })
187        .expect("run hash command");
188
189        let updated: Value =
190            serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read manifest"))
191                .expect("parse updated manifest");
192        let expected = format!("blake3:{}", blake3::hash(b"updated-wasm").to_hex());
193        assert_eq!(updated["hashes"]["component_wasm"], Value::String(expected));
194
195        std::env::set_current_dir(original_cwd).expect("restore cwd");
196    }
197
198    #[test]
199    fn run_prefers_explicit_wasm_override() {
200        let _guard = cwd_lock().lock().expect("cwd lock");
201        let original_cwd = std::env::current_dir().expect("cwd");
202        let dir = tempfile::tempdir().expect("tempdir");
203        std::env::set_current_dir(dir.path()).expect("set cwd");
204
205        let manifest_wasm = dir.path().join("component.wasm");
206        let override_wasm = dir.path().join("override.wasm");
207        fs::write(&manifest_wasm, b"manifest-wasm").expect("write manifest wasm");
208        fs::write(&override_wasm, b"override-wasm").expect("write override wasm");
209        let manifest_path = dir.path().join("component.manifest.json");
210        fs::write(
211            &manifest_path,
212            serde_json::to_string_pretty(&json!({
213                "artifacts": { "component_wasm": "component.wasm" },
214                "hashes": { "component_wasm": "blake3:old" }
215            }))
216            .expect("manifest json"),
217        )
218        .expect("write manifest");
219
220        run(HashArgs {
221            manifest: PathBuf::from("component.manifest.json"),
222            wasm: Some(PathBuf::from("override.wasm")),
223        })
224        .expect("run hash command");
225
226        let updated: Value =
227            serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read manifest"))
228                .expect("parse updated manifest");
229        let expected = format!("blake3:{}", blake3::hash(b"override-wasm").to_hex());
230        assert_eq!(updated["hashes"]["component_wasm"], Value::String(expected));
231
232        std::env::set_current_dir(original_cwd).expect("restore cwd");
233    }
234}