1use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use sha2::{Digest, Sha256};
8
9pub fn clone_repo_with_progress(
20 source_url: &str,
21 dest: &Path,
22 mut on_chunk: impl FnMut(&str),
23) -> Result<(), String> {
24 if source_url.starts_with('-') {
25 return Err(format!("refusing suspicious url: {}", source_url));
26 }
27 if let Some(parent) = dest.parent() {
28 std::fs::create_dir_all(parent)
29 .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
30 }
31 let mut child = Command::new("git")
32 .args(["clone", "--progress", "--depth=1", "--", source_url])
33 .arg(dest)
34 .stdout(Stdio::null())
35 .stderr(Stdio::piped())
36 .spawn()
37 .map_err(|e| {
38 if e.kind() == std::io::ErrorKind::NotFound {
39 "git not found on PATH".to_string()
40 } else {
41 format!("spawn git: {}", e)
42 }
43 })?;
44
45 let mut stderr = child
46 .stderr
47 .take()
48 .ok_or_else(|| "git stderr was not piped".to_string())?;
49 let mut buf = [0u8; 4096];
50 let mut accum = String::new();
51 let mut last_stderr = String::new();
52 loop {
53 match stderr.read(&mut buf) {
54 Ok(0) => break,
55 Ok(n) => {
56 let s = String::from_utf8_lossy(&buf[..n]);
57 accum.push_str(&s);
58 last_stderr.push_str(&s);
59 loop {
62 let split = accum.find(|c: char| c == '\r' || c == '\n');
63 let Some(pos) = split else { break };
64 let chunk: String = accum.drain(..pos).collect();
65 if !accum.is_empty() {
67 accum.drain(..1);
68 }
69 if !chunk.is_empty() {
70 on_chunk(&chunk);
71 }
72 }
73 if last_stderr.len() > 16 * 1024 {
75 let cut = last_stderr.len() - 8 * 1024;
76 last_stderr.replace_range(..cut, "");
77 }
78 }
79 Err(_) => break,
80 }
81 }
82 if !accum.is_empty() {
84 on_chunk(&accum);
85 }
86
87 let status = child
88 .wait()
89 .map_err(|e| format!("wait git: {}", e))?;
90 if !status.success() {
91 let _ = std::fs::remove_dir_all(dest);
92 let trimmed = last_stderr.trim();
93 let detail = if trimmed.is_empty() {
94 format!("exit code {:?}", status.code())
95 } else {
96 trimmed
98 .lines()
99 .filter(|l| !l.trim().is_empty())
100 .next_back()
101 .unwrap_or(trimmed)
102 .to_string()
103 };
104 return Err(format!("git clone failed: {}", detail));
105 }
106 Ok(())
107}
108
109pub fn install_plugin(source_url: &str, dest: &Path) -> Result<String, String> {
112 install_plugin_with_progress(source_url, dest, |_| {})
113}
114
115pub fn install_plugin_with_progress(
118 source_url: &str,
119 dest: &Path,
120 on_chunk: impl FnMut(&str),
121) -> Result<String, String> {
122 if dest.exists() {
123 return Err(format!("{} already exists on disk; uninstall first", dest.display()));
124 }
125 clone_repo_with_progress(source_url, dest, on_chunk)?;
126 rev_parse_head(dest)
127}
128
129pub fn install_plugin_from_subdir(
141 marketplace_url: &str,
142 subdir: &str,
143 dest: &Path,
144) -> Result<String, String> {
145 install_plugin_from_subdir_with_progress(marketplace_url, subdir, dest, |_| {})
146}
147
148pub fn install_plugin_from_subdir_with_progress(
152 marketplace_url: &str,
153 subdir: &str,
154 dest: &Path,
155 on_chunk: impl FnMut(&str),
156) -> Result<String, String> {
157 if !crate::skills::marketplace::is_safe_plugin_name(subdir) {
158 return Err(format!("refusing unsafe subdir name: {}", subdir));
159 }
160 if dest.exists() {
161 return Err(format!("{} already exists on disk; uninstall first", dest.display()));
162 }
163 let parent = dest.parent().ok_or_else(|| "dest has no parent directory".to_string())?;
164 let dest_name = dest.file_name()
165 .and_then(|s| s.to_str())
166 .ok_or_else(|| "dest file name is not utf-8".to_string())?;
167 let tmp = parent.join(format!(".{}-clone-tmp", dest_name));
168 let _ = std::fs::remove_dir_all(&tmp);
170
171 clone_repo_with_progress(marketplace_url, &tmp, on_chunk)?;
172
173 let sha = match rev_parse_head(&tmp) {
174 Ok(s) => s,
175 Err(e) => {
176 let _ = std::fs::remove_dir_all(&tmp);
177 return Err(e);
178 }
179 };
180
181 let src_subdir = tmp.join(subdir);
182 if !src_subdir.is_dir() {
183 let _ = std::fs::remove_dir_all(&tmp);
184 return Err(format!("subdir '{}' not found in marketplace repo", subdir));
185 }
186
187 if std::fs::rename(&src_subdir, dest).is_err() {
189 copy_dir_all(&src_subdir, dest).map_err(|e| {
190 let _ = std::fs::remove_dir_all(&tmp);
191 format!("copy {} to {}: {}", src_subdir.display(), dest.display(), e)
192 })?;
193 }
194 let _ = std::fs::remove_dir_all(&tmp);
195 Ok(sha)
196}
197
198fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
199 std::fs::create_dir_all(dst)?;
200 for entry in std::fs::read_dir(src)? {
201 let entry = entry?;
202 let ty = entry.file_type()?;
203 let dst_path = dst.join(entry.file_name());
204 if ty.is_dir() {
205 copy_dir_all(&entry.path(), &dst_path)?;
206 } else if ty.is_file() {
207 std::fs::copy(entry.path(), dst_path)?;
208 }
209 }
211 Ok(())
212}
213
214pub fn uninstall_plugin(path: &Path) -> Result<(), String> {
216 match std::fs::remove_dir_all(path) {
217 Ok(()) => Ok(()),
218 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
219 Err(e) => Err(format!("remove {}: {}", path.display(), e)),
220 }
221}
222
223pub fn plugin_dir_sha256(path: &Path) -> Result<String, String> {
231 if !path.is_dir() {
232 return Err(format!("{} is not a directory", path.display()));
233 }
234 let effective_root = path.join(".synaps-plugin").join("plugin.json");
235 if effective_root.is_file() {
236 hash_regular_files(path)
237 } else {
238 let mut candidates = Vec::new();
239 collect_plugin_roots(path, path, &mut candidates)?;
240 candidates.sort();
241 if candidates.len() == 1 {
242 hash_regular_files(&candidates[0])
243 } else {
244 hash_regular_files(path)
245 }
246 }
247}
248
249fn hash_regular_files(path: &Path) -> Result<String, String> {
250 let mut files = Vec::new();
251 collect_regular_files(path, path, &mut files)?;
252 files.sort();
253
254 let mut hasher = Sha256::new();
255 for rel in files {
256 let full = path.join(&rel);
257 hasher.update(rel.to_string_lossy().as_bytes());
258 hasher.update([0]);
259 let bytes = std::fs::read(&full)
260 .map_err(|e| format!("read {}: {}", full.display(), e))?;
261 hasher.update(bytes);
262 hasher.update([0]);
263 }
264 Ok(format!("{:x}", hasher.finalize()))
265}
266
267pub fn verify_plugin_dir_checksum(path: &Path, algorithm: &str, expected: &str) -> Result<(), String> {
268 if algorithm != "sha256" {
269 return Err(format!("unsupported plugin checksum algorithm: {}", algorithm));
270 }
271 let actual = plugin_dir_sha256(path)?;
272 if actual != expected {
273 return Err(format!(
274 "plugin checksum mismatch: expected sha256:{}, got sha256:{}",
275 expected, actual
276 ));
277 }
278 Ok(())
279}
280
281fn collect_plugin_roots(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
282 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
283 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
284 let path = entry.path();
285 if entry.file_name().to_string_lossy() == ".git" {
286 continue;
287 }
288 let ty = entry.file_type().map_err(|e| format!("stat {}: {}", path.display(), e))?;
289 if ty.is_dir() {
290 if path.join(".synaps-plugin").join("plugin.json").is_file() && path != root {
291 out.push(path);
292 } else {
293 collect_plugin_roots(root, &path, out)?;
294 }
295 }
296 }
297 Ok(())
298}
299
300fn collect_regular_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
301 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
302 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
303 let path = entry.path();
304 let name = entry.file_name();
305 if name.to_string_lossy() == ".git" {
306 continue;
307 }
308 let ty = entry
309 .file_type()
310 .map_err(|e| format!("stat {}: {}", path.display(), e))?;
311 if ty.is_dir() {
312 collect_regular_files(root, &path, out)?;
313 } else if ty.is_file() {
314 let rel = path
315 .strip_prefix(root)
316 .map_err(|e| format!("strip prefix {}: {}", path.display(), e))?
317 .to_path_buf();
318 out.push(rel);
319 }
320 }
321 Ok(())
322}
323
324pub fn update_plugin(install_path: &Path) -> Result<String, String> {
326 let out = Command::new("git")
327 .args(["-C"])
328 .arg(install_path)
329 .args(["pull", "--ff-only", "-q"])
330 .output()
331 .map_err(|e| format!("spawn git: {}", e))?;
332 if !out.status.success() {
333 return Err(format!(
334 "git pull failed: {}",
335 String::from_utf8_lossy(&out.stderr).trim()
336 ));
337 }
338 rev_parse_head(install_path)
339}
340
341pub fn ls_remote_head(source_url: &str) -> Result<String, String> {
343 if source_url.starts_with('-') {
344 return Err(format!("refusing suspicious url: {}", source_url));
345 }
346 let out = Command::new("git")
347 .args(["ls-remote", "--", source_url, "HEAD"])
348 .output()
349 .map_err(|e| format!("spawn git: {}", e))?;
350 if !out.status.success() {
351 return Err(format!(
352 "git ls-remote failed: {}",
353 String::from_utf8_lossy(&out.stderr).trim()
354 ));
355 }
356 let stdout = String::from_utf8_lossy(&out.stdout);
357 let sha = stdout
358 .split_whitespace()
359 .next()
360 .ok_or("empty ls-remote output")?;
361 if sha.len() != 40 {
362 return Err(format!("unexpected ls-remote output: {}", stdout));
363 }
364 Ok(sha.to_string())
365}
366
367fn rev_parse_head(repo: &Path) -> Result<String, String> {
368 let out = Command::new("git")
369 .args(["-C"])
370 .arg(repo)
371 .args(["rev-parse", "HEAD"])
372 .output()
373 .map_err(|e| format!("spawn git: {}", e))?;
374 if !out.status.success() {
375 return Err(format!(
376 "git rev-parse failed: {}",
377 String::from_utf8_lossy(&out.stderr).trim()
378 ));
379 }
380 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
381}
382
383#[cfg(test)]
384mod tests {
385 use super::*;
386 use std::process::Command;
387
388 fn mk_local_repo() -> (tempfile::TempDir, std::path::PathBuf) {
390 let dir = tempfile::tempdir().unwrap();
391 let work = dir.path().join("work");
392 std::fs::create_dir_all(&work).unwrap();
393 Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
394 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
395 Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
396 std::fs::write(work.join("SKILL.md"),
397 "---\nname: demo\ndescription: d\n---\nbody").unwrap();
398 Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
399 Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
400
401 let bare = dir.path().join("bare.git");
402 Command::new("git").args(["clone", "--bare", "-q",
403 work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
404 (dir, bare)
405 }
406
407 #[test]
408 fn install_clones_and_returns_sha() {
409 let (_tmp, bare) = mk_local_repo();
410 let dest_parent = tempfile::tempdir().unwrap();
411 let dest = dest_parent.path().join("demo");
412 let sha = install_plugin(
413 &format!("file://{}", bare.display()),
414 &dest,
415 ).unwrap();
416 assert!(dest.join("SKILL.md").exists());
417 assert_eq!(sha.len(), 40);
418 }
419
420 #[test]
421 fn install_with_progress_streams_chunks_and_returns_sha() {
422 use std::sync::{Arc, Mutex};
423 let (_tmp, bare) = mk_local_repo();
424 let dest_parent = tempfile::tempdir().unwrap();
425 let dest = dest_parent.path().join("demo");
426 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
427 let chunks_clone = Arc::clone(&chunks);
428 let sha = install_plugin_with_progress(
429 &format!("file://{}", bare.display()),
430 &dest,
431 move |c| chunks_clone.lock().unwrap().push(c.to_string()),
432 )
433 .unwrap();
434 assert_eq!(sha.len(), 40);
435 assert!(dest.join("SKILL.md").exists());
436 let captured = chunks.lock().unwrap().clone();
437 assert!(
441 !captured.is_empty(),
442 "expected at least one progress chunk from --progress, got none"
443 );
444 }
445
446 #[test]
447 fn install_with_progress_failure_propagates_stderr() {
448 use std::sync::{Arc, Mutex};
449 let dest_parent = tempfile::tempdir().unwrap();
450 let dest = dest_parent.path().join("demo");
451 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
452 let chunks_clone = Arc::clone(&chunks);
453 let err = install_plugin_with_progress(
454 "file:///definitely/not/a/real/repo.git",
455 &dest,
456 move |c| chunks_clone.lock().unwrap().push(c.to_string()),
457 )
458 .unwrap_err();
459 assert!(err.contains("git clone failed"), "err was: {err}");
460 assert!(
461 !dest.exists(),
462 "failed clone must not leave a partial dest dir"
463 );
464 }
465
466 fn mk_local_repo_with_subdir(sub: &str) -> (tempfile::TempDir, std::path::PathBuf) {
469 let dir = tempfile::tempdir().unwrap();
470 let work = dir.path().join("work");
471 std::fs::create_dir_all(work.join(sub)).unwrap();
472 Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
473 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
474 Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
475 std::fs::write(
476 work.join(sub).join("SKILL.md"),
477 "---\nname: demo\ndescription: d\n---\nbody",
478 ).unwrap();
479 std::fs::write(work.join("README.md"), "top level").unwrap();
480 Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
481 Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
482
483 let bare = dir.path().join("bare.git");
484 Command::new("git").args(["clone", "--bare", "-q",
485 work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
486 (dir, bare)
487 }
488
489 #[test]
490 fn install_plugin_from_subdir_snapshots_subdir_content() {
491 let (_tmp, bare) = mk_local_repo_with_subdir("web");
492 let dest_parent = tempfile::tempdir().unwrap();
493 let dest = dest_parent.path().join("web");
494 let sha = install_plugin_from_subdir(
495 &format!("file://{}", bare.display()),
496 "web",
497 &dest,
498 ).unwrap();
499 assert_eq!(sha.len(), 40);
500 assert!(dest.join("SKILL.md").exists());
502 assert!(!dest.join("README.md").exists());
504 let tmp_leftover = dest_parent.path().join(".web-clone-tmp");
506 assert!(!tmp_leftover.exists());
507 }
508
509 #[test]
510 fn install_plugin_from_subdir_rejects_unsafe_subdir() {
511 let (_tmp, bare) = mk_local_repo_with_subdir("web");
512 let dest_parent = tempfile::tempdir().unwrap();
513 let dest = dest_parent.path().join("web");
514 let err = install_plugin_from_subdir(
515 &format!("file://{}", bare.display()),
516 "../evil",
517 &dest,
518 ).unwrap_err();
519 assert!(err.contains("unsafe"));
520 assert!(!dest.exists());
521 }
522
523 #[test]
524 fn install_plugin_from_subdir_fails_when_subdir_missing() {
525 let (_tmp, bare) = mk_local_repo_with_subdir("web");
526 let dest_parent = tempfile::tempdir().unwrap();
527 let dest = dest_parent.path().join("nope");
528 let err = install_plugin_from_subdir(
529 &format!("file://{}", bare.display()),
530 "nope",
531 &dest,
532 ).unwrap_err();
533 assert!(err.contains("not found"));
534 assert!(!dest.exists());
535 }
536
537 #[test]
538 fn install_refuses_if_target_exists() {
539 let (_tmp, bare) = mk_local_repo();
540 let dest_parent = tempfile::tempdir().unwrap();
541 let dest = dest_parent.path().join("demo");
542 std::fs::create_dir_all(&dest).unwrap();
543 let err = install_plugin(
544 &format!("file://{}", bare.display()),
545 &dest,
546 ).unwrap_err();
547 assert!(err.contains("already"));
548 }
549
550 #[test]
551 fn uninstall_removes_directory() {
552 let dir = tempfile::tempdir().unwrap();
553 let p = dir.path().join("demo");
554 std::fs::create_dir_all(&p).unwrap();
555 std::fs::write(p.join("x"), "y").unwrap();
556 uninstall_plugin(&p).unwrap();
557 assert!(!p.exists());
558 }
559
560 #[test]
561 fn uninstall_missing_dir_is_ok() {
562 let dir = tempfile::tempdir().unwrap();
563 let p = dir.path().join("nothere");
564 assert!(uninstall_plugin(&p).is_ok());
565 }
566
567 #[test]
568 fn ls_remote_head_returns_sha_on_real_repo() {
569 let (_tmp, bare) = mk_local_repo();
570 let sha = ls_remote_head(&format!("file://{}", bare.display())).unwrap();
571 assert_eq!(sha.len(), 40);
572 }
573
574 #[test]
575 fn checksum_ignores_git_and_detects_content_changes() {
576 let dir = tempfile::tempdir().unwrap();
577 let plugin = dir.path().join("demo");
578 std::fs::create_dir_all(plugin.join(".synaps-plugin")).unwrap();
579 std::fs::create_dir_all(plugin.join(".git")).unwrap();
580 std::fs::write(plugin.join(".synaps-plugin/plugin.json"), "{}").unwrap();
581 std::fs::write(plugin.join("README.md"), "one").unwrap();
582 std::fs::write(plugin.join(".git/HEAD"), "ignored").unwrap();
583
584 let first = plugin_dir_sha256(&plugin).unwrap();
585 assert_eq!(first.len(), 64);
586 verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap();
587
588 std::fs::write(plugin.join(".git/HEAD"), "still ignored").unwrap();
589 assert_eq!(plugin_dir_sha256(&plugin).unwrap(), first);
590
591 std::fs::write(plugin.join("README.md"), "two").unwrap();
592 let second = plugin_dir_sha256(&plugin).unwrap();
593 assert_ne!(second, first);
594 let err = verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap_err();
595 assert!(err.contains("checksum mismatch"));
596 }
597
598 #[test]
599 fn update_plugin_fast_forwards_and_returns_new_sha() {
600 let (_tmp, bare) = mk_local_repo();
601 let dest_parent = tempfile::tempdir().unwrap();
602 let dest = dest_parent.path().join("demo");
603 let initial_sha = install_plugin(
604 &format!("file://{}", bare.display()),
605 &dest,
606 ).unwrap();
607
608 let pusher_parent = tempfile::tempdir().unwrap();
610 let pusher = pusher_parent.path().join("push");
611 Command::new("git").args(["clone", "-q"])
612 .arg(&bare).arg(&pusher).status().unwrap();
613 Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&pusher).status().unwrap();
614 Command::new("git").args(["config", "user.name", "t"]).current_dir(&pusher).status().unwrap();
615 std::fs::write(pusher.join("second.md"), "more").unwrap();
616 Command::new("git").args(["add", "."]).current_dir(&pusher).status().unwrap();
617 Command::new("git").args(["commit", "-q", "-m", "second"]).current_dir(&pusher).status().unwrap();
618 Command::new("git").args(["push", "-q"]).current_dir(&pusher).status().unwrap();
619
620 let updated_sha = update_plugin(&dest).unwrap();
621 assert_eq!(updated_sha.len(), 40);
622 assert_ne!(updated_sha, initial_sha);
623 }
624}