greentic_component/cmd/
hash.rs1use 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 #[arg(default_value = "component.manifest.json")]
15 pub manifest: PathBuf,
16 #[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!(normalized, wasm);
136 }
137
138 #[test]
139 fn resolve_wasm_path_uses_manifest_artifact_when_present() {
140 let manifest = json!({
141 "artifacts": { "component_wasm": "dist/component.wasm" }
142 });
143
144 let path = resolve_wasm_path(&manifest, None).expect("artifact path");
145
146 assert_eq!(path, PathBuf::from("dist/component.wasm"));
147 }
148
149 #[test]
150 fn normalize_or_canonicalize_canonicalizes_absolute_paths() {
151 let root = tempfile::tempdir().expect("tempdir");
152 let wasm = root.path().join("component.wasm");
153 fs::write(&wasm, b"wasm").expect("write wasm");
154
155 let normalized = normalize_or_canonicalize(root.path(), &wasm).expect("absolute path");
156
157 assert_eq!(normalized, wasm.canonicalize().expect("canonical wasm"));
158 }
159
160 #[test]
161 fn run_updates_manifest_hash_using_manifest_artifact() {
162 let _guard = cwd_lock().lock().expect("cwd lock");
163 let original_cwd = std::env::current_dir().expect("cwd");
164 let dir = tempfile::tempdir().expect("tempdir");
165 std::env::set_current_dir(dir.path()).expect("set cwd");
166
167 let wasm = dir.path().join("component.wasm");
168 fs::write(&wasm, b"updated-wasm").expect("write wasm");
169 let manifest_path = dir.path().join("component.manifest.json");
170 fs::write(
171 &manifest_path,
172 serde_json::to_string_pretty(&json!({
173 "artifacts": { "component_wasm": "component.wasm" },
174 "hashes": { "component_wasm": "blake3:old" }
175 }))
176 .expect("manifest json"),
177 )
178 .expect("write manifest");
179
180 run(HashArgs {
181 manifest: PathBuf::from("component.manifest.json"),
182 wasm: None,
183 })
184 .expect("run hash command");
185
186 let updated: Value =
187 serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read manifest"))
188 .expect("parse updated manifest");
189 let expected = format!("blake3:{}", blake3::hash(b"updated-wasm").to_hex());
190 assert_eq!(updated["hashes"]["component_wasm"], Value::String(expected));
191
192 std::env::set_current_dir(original_cwd).expect("restore cwd");
193 }
194
195 #[test]
196 fn run_prefers_explicit_wasm_override() {
197 let _guard = cwd_lock().lock().expect("cwd lock");
198 let original_cwd = std::env::current_dir().expect("cwd");
199 let dir = tempfile::tempdir().expect("tempdir");
200 std::env::set_current_dir(dir.path()).expect("set cwd");
201
202 let manifest_wasm = dir.path().join("component.wasm");
203 let override_wasm = dir.path().join("override.wasm");
204 fs::write(&manifest_wasm, b"manifest-wasm").expect("write manifest wasm");
205 fs::write(&override_wasm, b"override-wasm").expect("write override wasm");
206 let manifest_path = dir.path().join("component.manifest.json");
207 fs::write(
208 &manifest_path,
209 serde_json::to_string_pretty(&json!({
210 "artifacts": { "component_wasm": "component.wasm" },
211 "hashes": { "component_wasm": "blake3:old" }
212 }))
213 .expect("manifest json"),
214 )
215 .expect("write manifest");
216
217 run(HashArgs {
218 manifest: PathBuf::from("component.manifest.json"),
219 wasm: Some(PathBuf::from("override.wasm")),
220 })
221 .expect("run hash command");
222
223 let updated: Value =
224 serde_json::from_str(&fs::read_to_string(&manifest_path).expect("read manifest"))
225 .expect("parse updated manifest");
226 let expected = format!("blake3:{}", blake3::hash(b"override-wasm").to_hex());
227 assert_eq!(updated["hashes"]["component_wasm"], Value::String(expected));
228
229 std::env::set_current_dir(original_cwd).expect("restore cwd");
230 }
231}