1use std::path::{Path, PathBuf};
22
23#[derive(Debug, serde::Deserialize)]
25pub struct RegistryIndex {
26 #[serde(default)]
27 pub schema_version: String,
28 pub plugins: Vec<RegistryEntry>,
29}
30
31#[derive(Debug, Clone, serde::Deserialize)]
33pub struct RegistryEntry {
34 pub name: String,
35 #[serde(default)]
36 pub version: String,
37 #[serde(default)]
38 pub description: String,
39 pub artifact: String,
42 pub sha256: String,
44 #[serde(default)]
47 pub signature: Option<String>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq)]
52pub struct InstallReport {
53 pub name: String,
54 pub version: String,
55 pub wasm_path: PathBuf,
56 pub sig_path: Option<PathBuf>,
57 pub sha256: String,
58 pub signed_by: Option<String>,
60}
61
62fn sha256_hex(bytes: &[u8]) -> String {
63 use sha2::{Digest, Sha256};
64 let digest = Sha256::digest(bytes);
65 let mut s = String::with_capacity(64);
66 for b in digest.iter() {
67 s.push_str(&format!("{:02x}", b));
68 }
69 s
70}
71
72fn verify_against_trust_root(
78 bytes: &[u8],
79 sig_b64: &str,
80 trust_root: &Path,
81) -> Result<String, String> {
82 use base64::Engine as _;
83 use ed25519_dalek::{Signature, Verifier, VerifyingKey};
84
85 let b64 = base64::engine::general_purpose::STANDARD;
86 let sig_bytes = b64
87 .decode(sig_b64.trim())
88 .map_err(|e| format!("decode signature: {e}"))?;
89 let sig_arr: [u8; 64] = sig_bytes
90 .as_slice()
91 .try_into()
92 .map_err(|_| format!("signature must be 64 bytes, got {}", sig_bytes.len()))?;
93 let sig = Signature::from_bytes(&sig_arr);
94
95 let mut any = false;
96 let entries = std::fs::read_dir(trust_root)
97 .map_err(|e| format!("read trust root {}: {e}", trust_root.display()))?;
98 for ent in entries.flatten() {
99 let p = ent.path();
100 if p.extension().and_then(|e| e.to_str()) != Some("pub") {
101 continue;
102 }
103 let raw = std::fs::read_to_string(&p).map_err(|e| format!("read {}: {e}", p.display()))?;
104 let kb = b64
105 .decode(raw.trim())
106 .map_err(|e| format!("decode {}: {e}", p.display()))?;
107 let karr: [u8; 32] = kb
108 .as_slice()
109 .try_into()
110 .map_err(|_| format!("{} must hold a 32-byte pubkey", p.display()))?;
111 let vk = VerifyingKey::from_bytes(&karr)
112 .map_err(|e| format!("{} invalid pubkey: {e}", p.display()))?;
113 any = true;
114 if vk.verify(bytes, &sig).is_ok() {
115 let label = p
116 .file_stem()
117 .and_then(|s| s.to_str())
118 .unwrap_or("?")
119 .to_string();
120 return Ok(label);
121 }
122 }
123 if !any {
124 return Err(format!(
125 "trust root {} has no *.pub keys",
126 trust_root.display()
127 ));
128 }
129 Err("signature does not match any trusted key".to_string())
130}
131
132pub fn load_index(index_path: &Path) -> Result<RegistryIndex, String> {
134 let raw = std::fs::read_to_string(index_path)
135 .map_err(|e| format!("read registry index {}: {}", index_path.display(), e))?;
136 serde_json::from_str(&raw).map_err(|e| format!("parse registry index: {}", e))
137}
138
139fn resolve_artifact_path(index_path: &Path, artifact: &str) -> Result<PathBuf, String> {
142 if artifact.starts_with("http://") || artifact.starts_with("https://") {
143 return Err(format!(
144 "artefact {artifact} is remote; this build installs only local/file:// artefacts"
145 ));
146 }
147 let raw = artifact.strip_prefix("file://").unwrap_or(artifact);
148 let p = Path::new(raw);
149 if p.is_absolute() {
150 Ok(p.to_path_buf())
151 } else {
152 let base = index_path.parent().unwrap_or_else(|| Path::new("."));
154 Ok(base.join(p))
155 }
156}
157
158fn http_get(url: &str) -> Result<Vec<u8>, String> {
164 use std::io::{Read, Write};
165 let rest = url
166 .strip_prefix("http://")
167 .ok_or_else(|| format!("not an http:// url: {url}"))?;
168 let (authority, path) = match rest.find('/') {
169 Some(i) => (&rest[..i], &rest[i..]),
170 None => (rest, "/"),
171 };
172 let connect_addr = if authority.contains(':') {
173 authority.to_string()
174 } else {
175 format!("{authority}:80")
176 };
177 let mut stream = std::net::TcpStream::connect(&connect_addr)
178 .map_err(|e| format!("connect {connect_addr}: {e}"))?;
179 let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30)));
180 let req = format!(
181 "GET {path} HTTP/1.1\r\nHost: {authority}\r\nUser-Agent: helios-plugin\r\n\
182 Accept: */*\r\nConnection: close\r\n\r\n"
183 );
184 stream
185 .write_all(req.as_bytes())
186 .map_err(|e| format!("send request: {e}"))?;
187 let mut buf = Vec::new();
188 stream
189 .read_to_end(&mut buf)
190 .map_err(|e| format!("read response from {connect_addr}: {e}"))?;
191
192 let sep = buf
193 .windows(4)
194 .position(|w| w == b"\r\n\r\n")
195 .ok_or_else(|| "malformed HTTP response (no header terminator)".to_string())?;
196 let status_line_end = buf.iter().position(|&b| b == b'\r').unwrap_or(0);
197 let status_line = std::str::from_utf8(&buf[..status_line_end]).unwrap_or("");
198 let code = status_line.split_whitespace().nth(1).unwrap_or("");
199 if code != "200" {
200 return Err(format!("HTTP {} fetching {url}", status_line.trim()));
201 }
202 Ok(buf[sep + 4..].to_vec())
203}
204
205pub fn find_entry<'a>(
207 index: &'a RegistryIndex,
208 name: &str,
209 version: Option<&str>,
210) -> Result<&'a RegistryEntry, String> {
211 let mut matches = index.plugins.iter().filter(|e| e.name == name);
212 match version {
213 Some(v) => matches
214 .find(|e| e.version == v)
215 .ok_or_else(|| format!("no plugin '{name}' at version '{v}' in registry")),
216 None => matches
217 .next()
218 .ok_or_else(|| format!("no plugin '{name}' in registry")),
219 }
220}
221
222pub fn install(
228 index_path: &Path,
229 name: &str,
230 version: Option<&str>,
231 dest_dir: &Path,
232 trust_root: Option<&Path>,
233) -> Result<InstallReport, String> {
234 let index = load_index(index_path)?;
235 let entry = find_entry(&index, name, version)?.clone();
236
237 let bytes = if entry.artifact.starts_with("http://") {
242 http_get(&entry.artifact)?
243 } else if entry.artifact.starts_with("https://") {
244 return Err(format!(
245 "https:// artefact fetch is not supported in this build ({}); use an \
246 http:// URL (e.g. a localhost TLS-terminating mirror) or a file:// / local \
247 artefact — the index sha256 verifies the bytes regardless of transport",
248 entry.artifact
249 ));
250 } else {
251 let artifact_path = resolve_artifact_path(index_path, &entry.artifact)?;
252 std::fs::read(&artifact_path)
253 .map_err(|e| format!("read artefact {}: {}", artifact_path.display(), e))?
254 };
255
256 let actual = sha256_hex(&bytes);
258 if !actual.eq_ignore_ascii_case(&entry.sha256) {
259 return Err(format!(
260 "sha256 mismatch for '{name}': index={} actual={actual}",
261 entry.sha256
262 ));
263 }
264
265 let mut signed_by = None;
268 if let Some(root) = trust_root {
269 let sig = entry
270 .signature
271 .as_deref()
272 .ok_or_else(|| format!("'{name}' has no signature but a trust root was supplied"))?;
273 let label = verify_against_trust_root(&bytes, sig, root)
274 .map_err(|e| format!("signature verification failed for '{name}': {e}"))?;
275 signed_by = Some(label);
276 }
277
278 std::fs::create_dir_all(dest_dir)
279 .map_err(|e| format!("create dest dir {}: {}", dest_dir.display(), e))?;
280 let wasm_path = dest_dir.join(format!("{name}.wasm"));
281 std::fs::write(&wasm_path, &bytes)
282 .map_err(|e| format!("write {}: {}", wasm_path.display(), e))?;
283
284 let sig_path = if let Some(sig) = entry.signature.as_deref() {
285 let p = dest_dir.join(format!("{name}.sig"));
286 std::fs::write(&p, sig).map_err(|e| format!("write {}: {}", p.display(), e))?;
287 Some(p)
288 } else {
289 None
290 };
291
292 Ok(InstallReport {
293 name: entry.name,
294 version: entry.version,
295 wasm_path,
296 sig_path,
297 sha256: actual,
298 signed_by,
299 })
300}
301
302#[derive(Debug, Clone, PartialEq, Eq)]
304pub struct VerifyReport {
305 pub sha256: String,
306 pub signed_by: Option<String>,
309}
310
311pub fn verify(
317 wasm_path: &Path,
318 trust_root: Option<&Path>,
319 sig_path: Option<&Path>,
320) -> Result<VerifyReport, String> {
321 let bytes =
322 std::fs::read(wasm_path).map_err(|e| format!("read {}: {}", wasm_path.display(), e))?;
323 let sha256 = sha256_hex(&bytes);
324
325 let mut signed_by = None;
326 if let Some(root) = trust_root {
327 let sig_file = match sig_path {
328 Some(p) => p.to_path_buf(),
329 None => wasm_path.with_extension("sig"),
330 };
331 let sig = std::fs::read_to_string(&sig_file)
332 .map_err(|e| format!("read signature {}: {}", sig_file.display(), e))?;
333 let label = verify_against_trust_root(&bytes, sig.trim(), root)
334 .map_err(|e| format!("signature verification failed: {e}"))?;
335 signed_by = Some(label);
336 }
337 Ok(VerifyReport { sha256, signed_by })
338}
339
340pub fn scaffold(name: &str, dir: &Path) -> Result<PathBuf, String> {
345 if name.is_empty()
346 || !name
347 .chars()
348 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
349 {
350 return Err(format!("invalid plugin name '{name}' (use [A-Za-z0-9_-])"));
351 }
352 let root = dir.join(name);
353 if root.exists() {
354 return Err(format!("{} already exists", root.display()));
355 }
356 std::fs::create_dir_all(root.join("src"))
357 .map_err(|e| format!("create {}: {}", root.display(), e))?;
358
359 let manifest = format!(
360 "name: {name}\nversion: 0.1.0\ndescription: A HeliosProxy plugin\nlicense: Apache-2.0\nhooks:\n - pre_query\npermissions: []\n"
361 );
362 std::fs::write(root.join("plugin.yaml"), manifest)
363 .map_err(|e| format!("write plugin.yaml: {e}"))?;
364
365 let lib_rs = "// Minimal HeliosProxy WASM plugin stub.\n// Build to wasm32-unknown-unknown, then `helios-plugin` pack + sign.\n//\n// Export the hooks named in plugin.yaml; the host calls pre_query(ptr,len)\n// before forwarding a query. Return 0 to allow, non-zero to block.\n#[no_mangle]\npub extern \"C\" fn pre_query(_ptr: i32, _len: i32) -> i32 {\n 0 // allow\n}\n";
366 std::fs::write(root.join("src/lib.rs"), lib_rs)
367 .map_err(|e| format!("write src/lib.rs: {e}"))?;
368
369 let readme = format!(
370 "# {name}\n\nA HeliosProxy WASM plugin.\n\n## Build\n\n```\ncargo build --release --target wasm32-unknown-unknown\n```\n\nThen pack + sign the resulting `.wasm` and add it to your registry index so\n`helios-plugin install {name}` can deploy it.\n"
371 );
372 std::fs::write(root.join("README.md"), readme).map_err(|e| format!("write README.md: {e}"))?;
373
374 Ok(root)
375}
376
377#[cfg(test)]
378mod tests {
379 use super::*;
380 use base64::Engine as _;
381 use ed25519_dalek::{Signer, SigningKey};
382
383 const WASM: &[u8] = b"\x00asm\x01\x00\x00\x00pretend-real-plugin-wasm";
384
385 fn b64(bytes: &[u8]) -> String {
386 base64::engine::general_purpose::STANDARD.encode(bytes)
387 }
388
389 fn make_registry(dir: &Path, signature: Option<&str>) -> PathBuf {
391 std::fs::write(dir.join("colmask.wasm"), WASM).unwrap();
392 let sig_field = signature
393 .map(|s| format!(",\n \"signature\": \"{s}\""))
394 .unwrap_or_default();
395 let index = format!(
396 "{{\n \"schema_version\": \"1\",\n \"plugins\": [\n {{\n \"name\": \"colmask\",\n \"version\": \"0.1.0\",\n \"artifact\": \"colmask.wasm\",\n \"sha256\": \"{}\"{sig_field}\n }}\n ]\n}}",
397 sha256_hex(WASM)
398 );
399 let index_path = dir.join("index.json");
400 std::fs::write(&index_path, index).unwrap();
401 index_path
402 }
403
404 #[test]
405 fn install_unsigned_lands_wasm() {
406 let src = tempfile::tempdir().unwrap();
407 let dst = tempfile::tempdir().unwrap();
408 let index = make_registry(src.path(), None);
409
410 let r = install(&index, "colmask", None, dst.path(), None).unwrap();
411 assert_eq!(r.name, "colmask");
412 assert!(r.wasm_path.exists());
413 assert!(r.sig_path.is_none());
414 assert!(r.signed_by.is_none());
415 assert_eq!(std::fs::read(&r.wasm_path).unwrap(), WASM);
416 }
417
418 #[test]
419 fn install_rejects_sha256_mismatch() {
420 let src = tempfile::tempdir().unwrap();
421 let dst = tempfile::tempdir().unwrap();
422 let index = make_registry(src.path(), None);
424 std::fs::write(src.path().join("colmask.wasm"), b"tampered").unwrap();
425
426 let err = install(&index, "colmask", None, dst.path(), None).unwrap_err();
427 assert!(err.contains("sha256 mismatch"), "{err}");
428 }
429
430 #[test]
431 fn install_verifies_signature_against_trust_root() {
432 let src = tempfile::tempdir().unwrap();
433 let dst = tempfile::tempdir().unwrap();
434 let trust = tempfile::tempdir().unwrap();
435
436 let key = SigningKey::from_bytes(&[7u8; 32]);
438 std::fs::write(
439 trust.path().join("official.pub"),
440 b64(&key.verifying_key().to_bytes()),
441 )
442 .unwrap();
443 let sig = b64(&key.sign(WASM).to_bytes());
444 let index = make_registry(src.path(), Some(&sig));
445
446 let r = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap();
447 assert_eq!(r.signed_by.as_deref(), Some("official"));
448 assert!(r.sig_path.as_ref().unwrap().exists());
449 }
450
451 #[test]
452 fn install_rejects_untrusted_signature() {
453 let src = tempfile::tempdir().unwrap();
454 let dst = tempfile::tempdir().unwrap();
455 let trust = tempfile::tempdir().unwrap();
456
457 let official = SigningKey::from_bytes(&[7u8; 32]);
459 std::fs::write(
460 trust.path().join("official.pub"),
461 b64(&official.verifying_key().to_bytes()),
462 )
463 .unwrap();
464 let attacker = SigningKey::from_bytes(&[0xABu8; 32]);
465 let sig = b64(&attacker.sign(WASM).to_bytes());
466 let index = make_registry(src.path(), Some(&sig));
467
468 let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
469 assert!(err.contains("signature verification failed"), "{err}");
470 }
471
472 #[test]
473 fn install_requires_signature_when_trust_root_set() {
474 let src = tempfile::tempdir().unwrap();
475 let dst = tempfile::tempdir().unwrap();
476 let trust = tempfile::tempdir().unwrap();
477 let key = SigningKey::from_bytes(&[7u8; 32]);
478 std::fs::write(
479 trust.path().join("official.pub"),
480 b64(&key.verifying_key().to_bytes()),
481 )
482 .unwrap();
483 let index = make_registry(src.path(), None); let err = install(&index, "colmask", None, dst.path(), Some(trust.path())).unwrap_err();
486 assert!(err.contains("no signature"), "{err}");
487 }
488
489 #[test]
490 fn rejects_remote_artifact_offline() {
491 let p = resolve_artifact_path(Path::new("/tmp/index.json"), "https://example/colmask.wasm");
492 assert!(p.unwrap_err().contains("remote"));
493 }
494
495 #[test]
496 fn install_rejects_https_artifact() {
497 let src = tempfile::tempdir().unwrap();
498 let dst = tempfile::tempdir().unwrap();
499 let index = src.path().join("idx.json");
500 std::fs::write(
501 &index,
502 r#"{"plugins":[{"name":"x","artifact":"https://example/x.wasm","sha256":"00"}]}"#,
503 )
504 .unwrap();
505 let err = install(&index, "x", None, dst.path(), None).unwrap_err();
506 assert!(err.contains("https://"), "{err}");
507 }
508
509 #[test]
510 fn verify_digest_only_without_trust_root() {
511 let dir = tempfile::tempdir().unwrap();
512 let wasm = dir.path().join("p.wasm");
513 std::fs::write(&wasm, WASM).unwrap();
514 let r = verify(&wasm, None, None).unwrap();
515 assert_eq!(r.sha256, sha256_hex(WASM));
516 assert!(r.signed_by.is_none());
517 }
518
519 #[test]
520 fn verify_signature_via_sidecar() {
521 let dir = tempfile::tempdir().unwrap();
522 let trust = tempfile::tempdir().unwrap();
523 let key = SigningKey::from_bytes(&[7u8; 32]);
524 std::fs::write(
525 trust.path().join("official.pub"),
526 b64(&key.verifying_key().to_bytes()),
527 )
528 .unwrap();
529 let wasm = dir.path().join("p.wasm");
530 std::fs::write(&wasm, WASM).unwrap();
531 std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
533
534 let r = verify(&wasm, Some(trust.path()), None).unwrap();
535 assert_eq!(r.signed_by.as_deref(), Some("official"));
536 }
537
538 #[test]
539 fn verify_rejects_tampered_artifact() {
540 let dir = tempfile::tempdir().unwrap();
541 let trust = tempfile::tempdir().unwrap();
542 let key = SigningKey::from_bytes(&[7u8; 32]);
543 std::fs::write(
544 trust.path().join("official.pub"),
545 b64(&key.verifying_key().to_bytes()),
546 )
547 .unwrap();
548 let wasm = dir.path().join("p.wasm");
549 std::fs::write(dir.path().join("p.sig"), b64(&key.sign(WASM).to_bytes())).unwrap();
551 std::fs::write(&wasm, b"tampered-wasm").unwrap();
552
553 let err = verify(&wasm, Some(trust.path()), None).unwrap_err();
554 assert!(err.contains("signature verification failed"), "{err}");
555 }
556
557 #[test]
558 fn scaffold_creates_skeleton() {
559 let dir = tempfile::tempdir().unwrap();
560 let root = scaffold("my-plugin", dir.path()).unwrap();
561 assert!(root.join("plugin.yaml").exists());
562 assert!(root.join("src/lib.rs").exists());
563 assert!(root.join("README.md").exists());
564 assert!(scaffold("bad name", dir.path()).is_err());
566 assert!(scaffold("my-plugin", dir.path()).is_err());
567 }
568}