1use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::BTreeMap;
15use std::path::{Component, Path, PathBuf};
16
17pub const MANIFEST: &str = "plugin.json";
18pub const SIGNATURE: &str = "plugin.sig";
19const MAX_FILES: usize = 4096;
20const MAX_TOTAL_BYTES: u64 = 256 * 1024 * 1024;
21
22#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
23#[serde(deny_unknown_fields)]
24pub struct PluginManifest {
25 pub name: String,
26 pub version: String,
27 #[serde(default)]
29 pub providers: Vec<String>,
30 pub files: BTreeMap<String, String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Installed {
36 pub name: String,
37 pub version: String,
38 pub path: PathBuf,
39 pub signer: String,
40}
41
42fn ident_ok(s: &str) -> bool {
43 !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) && !s.starts_with('.')
44}
45
46fn rel_ok(p: &str) -> bool {
47 let path = Path::new(p);
48 !p.is_empty() && p != MANIFEST && p != SIGNATURE && path.components().all(|c| matches!(c, Component::Normal(_)))
49}
50
51pub fn load_trusted_keys(file: &Path) -> Result<Vec<VerifyingKey>, String> {
52 let text = std::fs::read_to_string(file).map_err(|e| format!("trusted keys: {e}"))?;
53 let mut keys = Vec::new();
54 for line in text.lines() {
55 let line = line.split('#').next().unwrap_or("").trim();
56 if line.is_empty() {
57 continue;
58 }
59 let hex_key = line.split_whitespace().next().unwrap_or("");
60 let bytes: [u8; 32] = hex::decode(hex_key).ok().and_then(|b| b.try_into().ok()).ok_or("trusted keys: bad key")?;
61 keys.push(VerifyingKey::from_bytes(&bytes).map_err(|_| "trusted keys: bad key".to_owned())?);
62 }
63 Ok(keys)
64}
65
66fn walk(root: &Path, dir: &Path, out: &mut Vec<String>) -> Result<(), String> {
67 for entry in std::fs::read_dir(dir).map_err(|e| e.to_string())? {
68 let entry = entry.map_err(|e| e.to_string())?;
69 let ft = entry.file_type().map_err(|e| e.to_string())?;
70 let path = entry.path();
71 if ft.is_symlink() {
72 return Err(format!("link not allowed: {}", path.strip_prefix(root).unwrap_or(&path).display()));
73 } else if ft.is_dir() {
74 walk(root, &path, out)?;
75 } else if ft.is_file() {
76 let rel = path.strip_prefix(root).map_err(|e| e.to_string())?;
77 out.push(rel.to_str().ok_or("non-UTF-8 path")?.replace('\\', "/"));
78 } else {
79 return Err("special file not allowed".into());
80 }
81 if out.len() > MAX_FILES + 2 {
82 return Err("too many files".into());
83 }
84 }
85 Ok(())
86}
87
88fn sha256_file(path: &Path) -> Result<String, String> {
89 let bytes = std::fs::read(path).map_err(|e| e.to_string())?;
90 Ok(hex::encode(Sha256::digest(&bytes)))
91}
92
93pub fn verify(pkg: &Path, trusted: &[VerifyingKey]) -> Result<(PluginManifest, String), String> {
95 let manifest_bytes = std::fs::read(pkg.join(MANIFEST)).map_err(|e| format!("manifest: {e}"))?;
96 let sig_text = std::fs::read_to_string(pkg.join(SIGNATURE)).map_err(|e| format!("signature: {e}"))?;
97 let sig_bytes: [u8; 64] = hex::decode(sig_text.trim()).ok().and_then(|b| b.try_into().ok()).ok_or("signature: malformed")?;
98 let sig = Signature::from_bytes(&sig_bytes);
99 let signer = trusted
100 .iter()
101 .find(|k| k.verify(&manifest_bytes, &sig).is_ok())
102 .ok_or("signature: not signed by a trusted key")?;
103 let manifest: PluginManifest = serde_json::from_slice(&manifest_bytes).map_err(|e| format!("manifest: {e}"))?;
104 if !ident_ok(&manifest.name) || !ident_ok(&manifest.version) {
105 return Err("manifest: bad name or version".into());
106 }
107 if manifest.files.len() > MAX_FILES || manifest.files.keys().any(|p| !rel_ok(p)) {
108 return Err("manifest: bad file list".into());
109 }
110 let mut present = Vec::new();
111 walk(pkg, pkg, &mut present)?;
112 present.retain(|p| p != MANIFEST && p != SIGNATURE);
113 present.sort();
114 let declared: Vec<&String> = manifest.files.keys().collect();
115 if present.iter().collect::<Vec<_>>() != declared {
116 return Err("package files do not match the manifest".into());
117 }
118 let mut total = 0u64;
119 for (rel, want) in &manifest.files {
120 let path = pkg.join(rel);
121 total += std::fs::metadata(&path).map_err(|e| e.to_string())?.len();
122 if total > MAX_TOTAL_BYTES {
123 return Err("package too large".into());
124 }
125 if sha256_file(&path)? != want.to_ascii_lowercase() {
126 return Err(format!("hash mismatch: {rel}"));
127 }
128 }
129 Ok((manifest, hex::encode(signer.as_bytes())))
130}
131
132pub fn install(pkg: &Path, root: &Path, trusted: &[VerifyingKey]) -> Result<Installed, String> {
135 let (manifest, signer) = verify(pkg, trusted)?;
136 let dest = root.join(&manifest.name).join(&manifest.version);
137 let parent = dest.parent().expect("has parent");
138 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
139 let tmp = parent.join(format!(".{}.tmp-{}", manifest.version, std::process::id()));
140 let _ = std::fs::remove_dir_all(&tmp);
141 for rel in manifest.files.keys().map(String::as_str).chain([MANIFEST, SIGNATURE]) {
142 let to = tmp.join(rel);
143 std::fs::create_dir_all(to.parent().expect("parent")).map_err(|e| e.to_string())?;
144 std::fs::copy(pkg.join(rel), &to).map_err(|e| e.to_string())?;
145 }
146 if let Err(e) = verify(&tmp, trusted) {
148 let _ = std::fs::remove_dir_all(&tmp);
149 return Err(format!("copy failed verification: {e}"));
150 }
151 if dest.exists() {
152 std::fs::remove_dir_all(&dest).map_err(|e| e.to_string())?;
153 }
154 std::fs::rename(&tmp, &dest).map_err(|e| e.to_string())?;
155 Ok(Installed { name: manifest.name, version: manifest.version, path: dest, signer })
156}
157
158pub fn list(root: &Path, trusted: &[VerifyingKey]) -> Vec<Result<Installed, (PathBuf, String)>> {
160 let mut out = Vec::new();
161 let Ok(names) = std::fs::read_dir(root) else { return out };
162 for name in names.flatten() {
163 let Ok(versions) = std::fs::read_dir(name.path()) else { continue };
164 for v in versions.flatten() {
165 let path = v.path();
166 if v.file_name().to_string_lossy().starts_with('.') {
167 continue;
168 }
169 out.push(match verify(&path, trusted) {
170 Ok((m, signer)) => Ok(Installed { name: m.name, version: m.version, path, signer }),
171 Err(e) => Err((path, e)),
172 });
173 }
174 }
175 out
176}
177
178pub fn sign(pkg: &Path, name: &str, version: &str, providers: Vec<String>, secret_hex: &str) -> Result<String, String> {
181 let seed: [u8; 32] = hex::decode(secret_hex.trim()).ok().and_then(|b| b.try_into().ok()).ok_or("bad secret key")?;
182 let key = SigningKey::from_bytes(&seed);
183 let mut rels = Vec::new();
184 walk(pkg, pkg, &mut rels)?;
185 rels.retain(|p| p != MANIFEST && p != SIGNATURE);
186 let mut files = BTreeMap::new();
187 for rel in rels {
188 files.insert(rel.clone(), sha256_file(&pkg.join(&rel))?);
189 }
190 let manifest = PluginManifest { name: name.into(), version: version.into(), providers, files };
191 let bytes = serde_json::to_vec_pretty(&manifest).map_err(|e| e.to_string())?;
192 std::fs::write(pkg.join(MANIFEST), &bytes).map_err(|e| e.to_string())?;
193 std::fs::write(pkg.join(SIGNATURE), hex::encode(key.sign(&bytes).to_bytes())).map_err(|e| e.to_string())?;
194 Ok(hex::encode(key.verifying_key().as_bytes()))
195}
196
197pub fn keygen() -> (String, String) {
199 let mut seed = [0u8; 32];
200 getrandom::fill(&mut seed).expect("os randomness");
201 let key = SigningKey::from_bytes(&seed);
202 (hex::encode(seed), hex::encode(key.verifying_key().as_bytes()))
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn setup() -> (tempfile::TempDir, PathBuf, Vec<VerifyingKey>, String) {
210 let dir = tempfile::tempdir().unwrap();
211 let pkg = dir.path().join("pkg");
212 std::fs::create_dir_all(pkg.join("skills/review")).unwrap();
213 std::fs::write(pkg.join("skills/review/SKILL.md"), b"review").unwrap();
214 std::fs::write(pkg.join("README"), b"hi").unwrap();
215 let (secret, public) = keygen();
216 sign(&pkg, "review-kit", "1.0.0", vec!["claude".into()], &secret).unwrap();
217 let key = VerifyingKey::from_bytes(&hex::decode(&public).unwrap().try_into().unwrap()).unwrap();
218 (dir, pkg, vec![key], secret)
219 }
220
221 #[test]
222 fn signed_plugin_installs_and_lists() {
223 let (dir, pkg, trusted, _) = setup();
224 let root = dir.path().join("plugins");
225 let inst = install(&pkg, &root, &trusted).unwrap();
226 assert_eq!(inst.path, root.join("review-kit/1.0.0"));
227 assert!(inst.path.join("skills/review/SKILL.md").is_file());
228 let listed = list(&root, &trusted);
229 assert_eq!(listed.len(), 1);
230 assert!(listed[0].is_ok());
231 std::fs::write(inst.path.join("README"), b"evil").unwrap();
233 assert!(list(&root, &trusted)[0].is_err());
234 }
235
236 #[test]
237 fn refuses_untrusted_tampered_extra_and_links() {
238 let (dir, pkg, trusted, _) = setup();
239 let root = dir.path().join("plugins");
240 let (_, other) = keygen();
241 let stranger = vec![VerifyingKey::from_bytes(&hex::decode(other).unwrap().try_into().unwrap()).unwrap()];
242 assert!(install(&pkg, &root, &stranger).unwrap_err().contains("trusted"));
243 std::fs::write(pkg.join("README"), b"changed").unwrap();
244 assert!(install(&pkg, &root, &trusted).unwrap_err().contains("hash mismatch"));
245 std::fs::write(pkg.join("README"), b"hi").unwrap();
246 std::fs::write(pkg.join("extra"), b"x").unwrap();
247 assert!(install(&pkg, &root, &trusted).unwrap_err().contains("do not match"));
248 std::fs::remove_file(pkg.join("extra")).unwrap();
249 #[cfg(unix)]
250 {
251 std::os::unix::fs::symlink("/etc/passwd", pkg.join("link")).unwrap();
252 assert!(install(&pkg, &root, &trusted).unwrap_err().contains("link"));
253 std::fs::remove_file(pkg.join("link")).unwrap();
254 }
255 let m = std::fs::read_to_string(pkg.join(MANIFEST)).unwrap().replace("1.0.0", "9.9.9");
257 std::fs::write(pkg.join(MANIFEST), m).unwrap();
258 assert!(install(&pkg, &root, &trusted).is_err());
259 assert!(!root.join("review-kit").exists());
260 }
261
262 #[test]
263 fn rejects_path_escape_in_manifest() {
264 assert!(!rel_ok("../x"));
265 assert!(!rel_ok("/etc/x"));
266 assert!(!rel_ok(MANIFEST));
267 assert!(rel_ok("skills/a/SKILL.md"));
268 }
269
270 #[test]
271 fn trusted_keys_file_parses_comments() {
272 let dir = tempfile::tempdir().unwrap();
273 let (_, public) = keygen();
274 let f = dir.path().join("keys");
275 std::fs::write(&f, format!("# publishers\n{public} owner\n\n")).unwrap();
276 assert_eq!(load_trusted_keys(&f).unwrap().len(), 1);
277 std::fs::write(&f, "zz\n").unwrap();
278 assert!(load_trusted_keys(&f).is_err());
279 }
280}
281
282pub fn provider_dir(provider: &str) -> Option<&'static str> {
284 Some(match provider {
285 "claude" => ".claude",
286 "codex" => ".codex",
287 "kimi" => ".kimi",
288 "grok" => ".grok",
289 "gemini" => ".gemini",
290 _ => return None,
291 })
292}
293
294const MANAGED_LIST: &str = ".hatchery-plugins.json";
295
296pub fn sync_into_home(root: &Path, trusted: &[VerifyingKey], home: &Path) -> (usize, Vec<String>) {
302 const ALL: &[&str] = &["claude", "codex", "kimi", "grok", "gemini"];
303 let list_path = home.join(MANAGED_LIST);
304 let previous: BTreeMap<String, String> = std::fs::read(&list_path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
305 let mut managed: BTreeMap<String, String> = BTreeMap::new();
306 let mut skipped = Vec::new();
307 let mut chosen: BTreeMap<String, Installed> = BTreeMap::new();
309 for item in list(root, trusted) {
310 match item {
311 Ok(i) => {
312 if chosen.get(&i.name).is_none_or(|c| c.version < i.version) {
313 chosen.insert(i.name.clone(), i);
314 }
315 }
316 Err((p, e)) => skipped.push(format!("{}: {e}", p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default())),
317 }
318 }
319 for inst in chosen.values() {
320 let Ok(bytes) = std::fs::read(inst.path.join(MANIFEST)) else { continue };
321 let Ok(m) = serde_json::from_slice::<PluginManifest>(&bytes) else { continue };
322 let providers: Vec<&str> = if m.providers.is_empty() { ALL.to_vec() } else { m.providers.iter().map(String::as_str).collect() };
323 for p in providers {
324 let Some(dir) = provider_dir(p) else { continue };
325 for (rel, hash) in &m.files {
326 let target_rel = format!("{dir}/{rel}");
327 let target = home.join(&target_rel);
328 let ours = previous.contains_key(&target_rel) || managed.contains_key(&target_rel);
329 if target.exists() && !ours {
330 if sha256_file(&target).ok().as_deref() != Some(hash.as_str()) {
331 skipped.push(format!("{target_rel}: exists and is not managed"));
332 }
333 continue;
334 }
335 if sha256_file(&target).ok().as_deref() != Some(hash.as_str()) {
336 let _ = std::fs::create_dir_all(target.parent().expect("parent"));
337 if let Err(e) = std::fs::copy(inst.path.join(rel), &target) {
338 skipped.push(format!("{target_rel}: {e}"));
339 continue;
340 }
341 }
342 managed.insert(target_rel, hash.clone());
343 }
344 }
345 }
346 for (rel, hash) in &previous {
348 if !managed.contains_key(rel) {
349 let path = home.join(rel);
350 if sha256_file(&path).ok().as_deref() == Some(hash.as_str()) {
351 let _ = std::fs::remove_file(&path);
352 let mut dir = std::path::Path::new(rel).parent();
355 while let Some(d) = dir.filter(|d| d.components().count() > 2) {
356 if std::fs::remove_dir(home.join(d)).is_err() {
357 break;
358 }
359 dir = d.parent();
360 }
361 }
362 }
363 }
364 let placed = managed.len();
365 let _ = std::fs::write(&list_path, serde_json::to_vec_pretty(&managed).unwrap_or_default());
366 (placed, skipped)
367}
368
369#[cfg(test)]
370mod sync_tests {
371 use super::*;
372
373 #[test]
374 fn sync_places_updates_and_removes_only_managed_files() {
375 let dir = tempfile::tempdir().unwrap();
376 let (secret, public) = keygen();
377 let trusted = vec![VerifyingKey::from_bytes(&hex::decode(public).unwrap().try_into().unwrap()).unwrap()];
378 let pkg = dir.path().join("pkg");
379 std::fs::create_dir_all(pkg.join("skills/review")).unwrap();
380 std::fs::write(pkg.join("skills/review/SKILL.md"), b"v1").unwrap();
381 sign(&pkg, "kit", "1.0.0", vec!["claude".into()], &secret).unwrap();
382 let root = dir.path().join("plugins");
383 install(&pkg, &root, &trusted).unwrap();
384 let home = dir.path().join("home");
385 std::fs::create_dir_all(home.join(".claude/skills/mine")).unwrap();
386 std::fs::write(home.join(".claude/skills/mine/SKILL.md"), b"user").unwrap();
387
388 let (placed, skipped) = sync_into_home(&root, &trusted, &home);
389 assert_eq!((placed, skipped.len()), (1, 0));
390 assert_eq!(std::fs::read(home.join(".claude/skills/review/SKILL.md")).unwrap(), b"v1");
391 assert!(!home.join(".codex").exists());
392
393 std::fs::write(pkg.join("skills/review/SKILL.md"), b"v2").unwrap();
395 sign(&pkg, "kit", "1.1.0", vec!["claude".into()], &secret).unwrap();
396 install(&pkg, &root, &trusted).unwrap();
397 sync_into_home(&root, &trusted, &home);
398 assert_eq!(std::fs::read(home.join(".claude/skills/review/SKILL.md")).unwrap(), b"v2");
399
400 std::fs::remove_dir_all(&root).unwrap();
402 let (placed, _) = sync_into_home(&root, &trusted, &home);
403 assert_eq!(placed, 0);
404 assert!(!home.join(".claude/skills/review/SKILL.md").exists());
405 assert!(!home.join(".claude/skills/review").exists());
406 assert!(home.join(".claude/skills/mine").exists());
407 assert_eq!(std::fs::read(home.join(".claude/skills/mine/SKILL.md")).unwrap(), b"user");
408 }
409
410 #[test]
411 fn foreign_file_is_not_overwritten() {
412 let dir = tempfile::tempdir().unwrap();
413 let (secret, public) = keygen();
414 let trusted = vec![VerifyingKey::from_bytes(&hex::decode(public).unwrap().try_into().unwrap()).unwrap()];
415 let pkg = dir.path().join("pkg");
416 std::fs::create_dir_all(&pkg).unwrap();
417 std::fs::write(pkg.join("AGENTS.md"), b"plugin").unwrap();
418 sign(&pkg, "kit", "1.0.0", vec!["codex".into()], &secret).unwrap();
419 let root = dir.path().join("plugins");
420 install(&pkg, &root, &trusted).unwrap();
421 let home = dir.path().join("home");
422 std::fs::create_dir_all(home.join(".codex")).unwrap();
423 std::fs::write(home.join(".codex/AGENTS.md"), b"mine").unwrap();
424 let (placed, skipped) = sync_into_home(&root, &trusted, &home);
425 assert_eq!(placed, 0);
426 assert!(skipped[0].contains("not managed"));
427 assert_eq!(std::fs::read(home.join(".codex/AGENTS.md")).unwrap(), b"mine");
428 }
429}