1use std::io::Read;
4use std::path::{Path, PathBuf};
5use std::process::{Command, Stdio};
6
7use sha2::{Digest, Sha256};
8
9fn git_command(action: &str) -> Result<Command, String> {
13 let program = agent_core::git_program::git_program().map_err(|_| git_unavailable(action))?;
14 Ok(Command::new(program))
15}
16
17fn git_unavailable(action: &str) -> String {
18 format!(
19 "{action} needs git. Install Apple's Command Line Tools \
20 (run `xcode-select --install`), then try again."
21 )
22}
23
24pub fn clone_repo_with_progress(
35 source_url: &str,
36 dest: &Path,
37 mut on_chunk: impl FnMut(&str),
38) -> Result<(), String> {
39 if source_url.starts_with('-') {
40 return Err(format!("refusing suspicious url: {}", source_url));
41 }
42 let mut git = git_command("Plugin install")?;
43 if let Some(parent) = dest.parent() {
44 std::fs::create_dir_all(parent)
45 .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
46 }
47 let mut child = git
48 .args(["clone", "--progress", "--depth=1", "--", source_url])
49 .arg(dest)
50 .stdout(Stdio::null())
51 .stderr(Stdio::piped())
52 .spawn()
53 .map_err(|e| {
54 if e.kind() == std::io::ErrorKind::NotFound {
55 "git not found on PATH".to_string()
56 } else {
57 format!("spawn git: {}", e)
58 }
59 })?;
60
61 let mut stderr = child
62 .stderr
63 .take()
64 .ok_or_else(|| "git stderr was not piped".to_string())?;
65 let mut buf = [0u8; 4096];
66 let mut accum = String::new();
67 let mut last_stderr = String::new();
68 loop {
69 match stderr.read(&mut buf) {
70 Ok(0) => break,
71 Ok(n) => {
72 let s = String::from_utf8_lossy(&buf[..n]);
73 accum.push_str(&s);
74 last_stderr.push_str(&s);
75 loop {
78 let split = accum.find(['\r', '\n']);
79 let Some(pos) = split else { break };
80 let chunk: String = accum.drain(..pos).collect();
81 if !accum.is_empty() {
83 accum.drain(..1);
84 }
85 if !chunk.is_empty() {
86 on_chunk(&chunk);
87 }
88 }
89 if last_stderr.len() > 16 * 1024 {
91 let cut = last_stderr.len() - 8 * 1024;
92 last_stderr.replace_range(..cut, "");
93 }
94 }
95 Err(_) => break,
96 }
97 }
98 if !accum.is_empty() {
100 on_chunk(&accum);
101 }
102
103 let status = child.wait().map_err(|e| format!("wait git: {}", e))?;
104 if !status.success() {
105 let _ = std::fs::remove_dir_all(dest);
106 let trimmed = last_stderr.trim();
107 let detail = if trimmed.is_empty() {
108 format!("exit code {:?}", status.code())
109 } else {
110 trimmed
112 .lines()
113 .rfind(|l| !l.trim().is_empty())
114 .unwrap_or(trimmed)
115 .to_string()
116 };
117 return Err(format!("git clone failed: {}", detail));
118 }
119 Ok(())
120}
121
122pub fn install_plugin(source_url: &str, dest: &Path) -> Result<String, String> {
125 install_plugin_with_progress(source_url, dest, |_| {})
126}
127
128pub fn install_plugin_with_progress(
131 source_url: &str,
132 dest: &Path,
133 on_chunk: impl FnMut(&str),
134) -> Result<String, String> {
135 if dest.exists() {
136 return Err(format!(
137 "{} already exists on disk; uninstall first",
138 dest.display()
139 ));
140 }
141 clone_repo_with_progress(source_url, dest, on_chunk)?;
142 rev_parse_head(dest, "Plugin install")
143}
144
145pub fn install_plugin_from_subdir(
157 marketplace_url: &str,
158 subdir: &str,
159 dest: &Path,
160) -> Result<String, String> {
161 install_plugin_from_subdir_with_progress(marketplace_url, subdir, dest, |_| {})
162}
163
164pub fn install_plugin_from_subdir_with_progress(
168 marketplace_url: &str,
169 subdir: &str,
170 dest: &Path,
171 on_chunk: impl FnMut(&str),
172) -> Result<String, String> {
173 if !crate::skills::marketplace::is_safe_plugin_name(subdir) {
174 return Err(format!("refusing unsafe subdir name: {}", subdir));
175 }
176 if dest.exists() {
177 return Err(format!(
178 "{} already exists on disk; uninstall first",
179 dest.display()
180 ));
181 }
182 let parent = dest
183 .parent()
184 .ok_or_else(|| "dest has no parent directory".to_string())?;
185 let dest_name = dest
186 .file_name()
187 .and_then(|s| s.to_str())
188 .ok_or_else(|| "dest file name is not utf-8".to_string())?;
189 let tmp = parent.join(format!(".{}-clone-tmp", dest_name));
190 let _ = std::fs::remove_dir_all(&tmp);
192
193 clone_repo_with_progress(marketplace_url, &tmp, on_chunk)?;
194
195 let sha = match rev_parse_head(&tmp, "Plugin install") {
196 Ok(s) => s,
197 Err(e) => {
198 let _ = std::fs::remove_dir_all(&tmp);
199 return Err(e);
200 }
201 };
202
203 let src_subdir = tmp.join(subdir);
204 if !src_subdir.is_dir() {
205 let _ = std::fs::remove_dir_all(&tmp);
206 return Err(format!("subdir '{}' not found in marketplace repo", subdir));
207 }
208
209 if std::fs::rename(&src_subdir, dest).is_err() {
211 copy_dir_all(&src_subdir, dest).map_err(|e| {
212 let _ = std::fs::remove_dir_all(&tmp);
213 format!("copy {} to {}: {}", src_subdir.display(), dest.display(), e)
214 })?;
215 }
216 let _ = std::fs::remove_dir_all(&tmp);
217 Ok(sha)
218}
219
220fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
221 std::fs::create_dir_all(dst)?;
222 for entry in std::fs::read_dir(src)? {
223 let entry = entry?;
224 let ty = entry.file_type()?;
225 let dst_path = dst.join(entry.file_name());
226 if ty.is_dir() {
227 copy_dir_all(&entry.path(), &dst_path)?;
228 } else if ty.is_file() {
229 std::fs::copy(entry.path(), dst_path)?;
230 }
231 }
233 Ok(())
234}
235
236pub fn uninstall_plugin(path: &Path) -> Result<(), String> {
238 match std::fs::remove_dir_all(path) {
239 Ok(()) => Ok(()),
240 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
241 Err(e) => Err(format!("remove {}: {}", path.display(), e)),
242 }
243}
244
245pub fn plugin_dir_sha256(path: &Path) -> Result<String, String> {
253 if !path.is_dir() {
254 return Err(format!("{} is not a directory", path.display()));
255 }
256 let effective_root = path.join(".synaps-plugin").join("plugin.json");
257 if effective_root.is_file() {
258 hash_regular_files(path)
259 } else {
260 let mut candidates = Vec::new();
261 collect_plugin_roots(path, path, &mut candidates)?;
262 candidates.sort();
263 if candidates.len() == 1 {
264 hash_regular_files(&candidates[0])
265 } else {
266 hash_regular_files(path)
267 }
268 }
269}
270
271fn hash_regular_files(path: &Path) -> Result<String, String> {
272 let mut files = Vec::new();
273 collect_regular_files(path, path, &mut files)?;
274 files.sort();
275
276 let mut hasher = Sha256::new();
277 for rel in files {
278 let full = path.join(&rel);
279 hasher.update(rel.to_string_lossy().as_bytes());
280 hasher.update([0]);
281 let bytes = std::fs::read(&full).map_err(|e| format!("read {}: {}", full.display(), e))?;
282 hasher.update(bytes);
283 hasher.update([0]);
284 }
285 Ok(format!("{:x}", hasher.finalize()))
286}
287
288pub fn verify_plugin_dir_checksum(
289 path: &Path,
290 algorithm: &str,
291 expected: &str,
292) -> Result<(), String> {
293 if algorithm != "sha256" {
294 return Err(format!(
295 "unsupported plugin checksum algorithm: {}",
296 algorithm
297 ));
298 }
299 let actual = plugin_dir_sha256(path)?;
300 if actual != expected {
301 return Err(format!(
302 "plugin checksum mismatch: expected sha256:{}, got sha256:{}",
303 expected, actual
304 ));
305 }
306 Ok(())
307}
308
309fn collect_plugin_roots(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
310 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
311 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
312 let path = entry.path();
313 if entry.file_name().to_string_lossy() == ".git" {
314 continue;
315 }
316 let ty = entry
317 .file_type()
318 .map_err(|e| format!("stat {}: {}", path.display(), e))?;
319 if ty.is_dir() {
320 if path.join(".synaps-plugin").join("plugin.json").is_file() && path != root {
321 out.push(path);
322 } else {
323 collect_plugin_roots(root, &path, out)?;
324 }
325 }
326 }
327 Ok(())
328}
329
330fn collect_regular_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
331 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
332 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
333 let path = entry.path();
334 let name = entry.file_name();
335 if name.to_string_lossy() == ".git" {
336 continue;
337 }
338 let ty = entry
339 .file_type()
340 .map_err(|e| format!("stat {}: {}", path.display(), e))?;
341 if ty.is_dir() {
342 collect_regular_files(root, &path, out)?;
343 } else if ty.is_file() {
344 let rel = path
345 .strip_prefix(root)
346 .map_err(|e| format!("strip prefix {}: {}", path.display(), e))?
347 .to_path_buf();
348 out.push(rel);
349 }
350 }
351 Ok(())
352}
353
354pub fn update_plugin(install_path: &Path) -> Result<String, String> {
356 let out = git_command("Plugin update")?
357 .args(["-C"])
358 .arg(install_path)
359 .args(["pull", "--ff-only", "-q"])
360 .output()
361 .map_err(|e| format!("spawn git: {}", e))?;
362 if !out.status.success() {
363 return Err(format!(
364 "git pull failed: {}",
365 String::from_utf8_lossy(&out.stderr).trim()
366 ));
367 }
368 rev_parse_head(install_path, "Plugin update")
369}
370
371pub fn ls_remote_head(source_url: &str) -> Result<String, String> {
373 if source_url.starts_with('-') {
374 return Err(format!("refusing suspicious url: {}", source_url));
375 }
376 let out = git_command("Plugin update check")?
377 .args(["ls-remote", "--", source_url, "HEAD"])
378 .output()
379 .map_err(|e| format!("spawn git: {}", e))?;
380 if !out.status.success() {
381 return Err(format!(
382 "git ls-remote failed: {}",
383 String::from_utf8_lossy(&out.stderr).trim()
384 ));
385 }
386 let stdout = String::from_utf8_lossy(&out.stdout);
387 let sha = stdout
388 .split_whitespace()
389 .next()
390 .ok_or("empty ls-remote output")?;
391 if sha.len() != 40 {
392 return Err(format!("unexpected ls-remote output: {}", stdout));
393 }
394 Ok(sha.to_string())
395}
396
397fn rev_parse_head(repo: &Path, action: &str) -> Result<String, String> {
398 let out = git_command(action)?
399 .args(["-C"])
400 .arg(repo)
401 .args(["rev-parse", "HEAD"])
402 .output()
403 .map_err(|e| format!("spawn git: {}", e))?;
404 if !out.status.success() {
405 return Err(format!(
406 "git rev-parse failed: {}",
407 String::from_utf8_lossy(&out.stderr).trim()
408 ));
409 }
410 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
411}
412
413#[cfg(test)]
414mod tests {
415 use super::*;
416 use std::process::Command;
417
418 fn mk_local_repo() -> (tempfile::TempDir, std::path::PathBuf) {
420 let dir = tempfile::tempdir().unwrap();
421 let work = dir.path().join("work");
422 std::fs::create_dir_all(&work).unwrap();
423 Command::new("git")
424 .args(["init", "-q"])
425 .current_dir(&work)
426 .status()
427 .unwrap();
428 Command::new("git")
429 .args(["config", "user.email", "t@t"])
430 .current_dir(&work)
431 .status()
432 .unwrap();
433 Command::new("git")
434 .args(["config", "user.name", "t"])
435 .current_dir(&work)
436 .status()
437 .unwrap();
438 std::fs::write(
439 work.join("SKILL.md"),
440 "---\nname: demo\ndescription: d\n---\nbody",
441 )
442 .unwrap();
443 Command::new("git")
444 .args(["add", "."])
445 .current_dir(&work)
446 .status()
447 .unwrap();
448 Command::new("git")
449 .args(["commit", "-q", "-m", "init"])
450 .current_dir(&work)
451 .status()
452 .unwrap();
453
454 let bare = dir.path().join("bare.git");
455 Command::new("git")
456 .args([
457 "clone",
458 "--bare",
459 "-q",
460 work.to_str().unwrap(),
461 bare.to_str().unwrap(),
462 ])
463 .status()
464 .unwrap();
465 (dir, bare)
466 }
467
468 #[test]
469 fn install_clones_and_returns_sha() {
470 let (_tmp, bare) = mk_local_repo();
471 let dest_parent = tempfile::tempdir().unwrap();
472 let dest = dest_parent.path().join("demo");
473 let sha = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap();
474 assert!(dest.join("SKILL.md").exists());
475 assert_eq!(sha.len(), 40);
476 }
477
478 #[test]
479 fn install_with_progress_streams_chunks_and_returns_sha() {
480 use std::sync::{Arc, Mutex};
481 let (_tmp, bare) = mk_local_repo();
482 let dest_parent = tempfile::tempdir().unwrap();
483 let dest = dest_parent.path().join("demo");
484 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
485 let chunks_clone = Arc::clone(&chunks);
486 let sha =
487 install_plugin_with_progress(&format!("file://{}", bare.display()), &dest, move |c| {
488 chunks_clone.lock().unwrap().push(c.to_string())
489 })
490 .unwrap();
491 assert_eq!(sha.len(), 40);
492 assert!(dest.join("SKILL.md").exists());
493 let captured = chunks.lock().unwrap().clone();
494 assert!(
498 !captured.is_empty(),
499 "expected at least one progress chunk from --progress, got none"
500 );
501 }
502
503 #[test]
504 fn install_with_progress_failure_propagates_stderr() {
505 use std::sync::{Arc, Mutex};
506 let dest_parent = tempfile::tempdir().unwrap();
507 let dest = dest_parent.path().join("demo");
508 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
509 let chunks_clone = Arc::clone(&chunks);
510 let err = install_plugin_with_progress(
511 "file:///definitely/not/a/real/repo.git",
512 &dest,
513 move |c| chunks_clone.lock().unwrap().push(c.to_string()),
514 )
515 .unwrap_err();
516 assert!(err.contains("git clone failed"), "err was: {err}");
517 assert!(
518 !dest.exists(),
519 "failed clone must not leave a partial dest dir"
520 );
521 }
522
523 fn mk_local_repo_with_subdir(sub: &str) -> (tempfile::TempDir, std::path::PathBuf) {
526 let dir = tempfile::tempdir().unwrap();
527 let work = dir.path().join("work");
528 std::fs::create_dir_all(work.join(sub)).unwrap();
529 Command::new("git")
530 .args(["init", "-q"])
531 .current_dir(&work)
532 .status()
533 .unwrap();
534 Command::new("git")
535 .args(["config", "user.email", "t@t"])
536 .current_dir(&work)
537 .status()
538 .unwrap();
539 Command::new("git")
540 .args(["config", "user.name", "t"])
541 .current_dir(&work)
542 .status()
543 .unwrap();
544 std::fs::write(
545 work.join(sub).join("SKILL.md"),
546 "---\nname: demo\ndescription: d\n---\nbody",
547 )
548 .unwrap();
549 std::fs::write(work.join("README.md"), "top level").unwrap();
550 Command::new("git")
551 .args(["add", "."])
552 .current_dir(&work)
553 .status()
554 .unwrap();
555 Command::new("git")
556 .args(["commit", "-q", "-m", "init"])
557 .current_dir(&work)
558 .status()
559 .unwrap();
560
561 let bare = dir.path().join("bare.git");
562 Command::new("git")
563 .args([
564 "clone",
565 "--bare",
566 "-q",
567 work.to_str().unwrap(),
568 bare.to_str().unwrap(),
569 ])
570 .status()
571 .unwrap();
572 (dir, bare)
573 }
574
575 #[test]
576 fn install_plugin_from_subdir_snapshots_subdir_content() {
577 let (_tmp, bare) = mk_local_repo_with_subdir("web");
578 let dest_parent = tempfile::tempdir().unwrap();
579 let dest = dest_parent.path().join("web");
580 let sha = install_plugin_from_subdir(&format!("file://{}", bare.display()), "web", &dest)
581 .unwrap();
582 assert_eq!(sha.len(), 40);
583 assert!(dest.join("SKILL.md").exists());
585 assert!(!dest.join("README.md").exists());
587 let tmp_leftover = dest_parent.path().join(".web-clone-tmp");
589 assert!(!tmp_leftover.exists());
590 }
591
592 #[test]
593 fn install_plugin_from_subdir_rejects_unsafe_subdir() {
594 let (_tmp, bare) = mk_local_repo_with_subdir("web");
595 let dest_parent = tempfile::tempdir().unwrap();
596 let dest = dest_parent.path().join("web");
597 let err =
598 install_plugin_from_subdir(&format!("file://{}", bare.display()), "../evil", &dest)
599 .unwrap_err();
600 assert!(err.contains("unsafe"));
601 assert!(!dest.exists());
602 }
603
604 #[test]
605 fn install_plugin_from_subdir_fails_when_subdir_missing() {
606 let (_tmp, bare) = mk_local_repo_with_subdir("web");
607 let dest_parent = tempfile::tempdir().unwrap();
608 let dest = dest_parent.path().join("nope");
609 let err = install_plugin_from_subdir(&format!("file://{}", bare.display()), "nope", &dest)
610 .unwrap_err();
611 assert!(err.contains("not found"));
612 assert!(!dest.exists());
613 }
614
615 #[test]
616 fn install_refuses_if_target_exists() {
617 let (_tmp, bare) = mk_local_repo();
618 let dest_parent = tempfile::tempdir().unwrap();
619 let dest = dest_parent.path().join("demo");
620 std::fs::create_dir_all(&dest).unwrap();
621 let err = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap_err();
622 assert!(err.contains("already"));
623 }
624
625 #[test]
626 fn uninstall_removes_directory() {
627 let dir = tempfile::tempdir().unwrap();
628 let p = dir.path().join("demo");
629 std::fs::create_dir_all(&p).unwrap();
630 std::fs::write(p.join("x"), "y").unwrap();
631 uninstall_plugin(&p).unwrap();
632 assert!(!p.exists());
633 }
634
635 #[test]
636 fn uninstall_missing_dir_is_ok() {
637 let dir = tempfile::tempdir().unwrap();
638 let p = dir.path().join("nothere");
639 assert!(uninstall_plugin(&p).is_ok());
640 }
641
642 #[test]
643 fn missing_developer_git_refusal_names_the_fix() {
644 assert_eq!(
645 git_unavailable("Plugin update check"),
646 "Plugin update check needs git. Install Apple's Command Line Tools (run `xcode-select --install`), then try again."
647 );
648 }
649
650 #[cfg(not(target_os = "macos"))]
651 #[test]
652 fn other_platforms_run_git_from_path() {
653 let git = git_command("Plugin install").unwrap();
654 assert_eq!(git.get_program(), "git");
655 }
656
657 #[test]
658 fn ls_remote_head_returns_sha_on_real_repo() {
659 let (_tmp, bare) = mk_local_repo();
660 let sha = ls_remote_head(&format!("file://{}", bare.display())).unwrap();
661 assert_eq!(sha.len(), 40);
662 }
663
664 #[test]
665 fn checksum_ignores_git_and_detects_content_changes() {
666 let dir = tempfile::tempdir().unwrap();
667 let plugin = dir.path().join("demo");
668 std::fs::create_dir_all(plugin.join(".synaps-plugin")).unwrap();
669 std::fs::create_dir_all(plugin.join(".git")).unwrap();
670 std::fs::write(plugin.join(".synaps-plugin/plugin.json"), "{}").unwrap();
671 std::fs::write(plugin.join("README.md"), "one").unwrap();
672 std::fs::write(plugin.join(".git/HEAD"), "ignored").unwrap();
673
674 let first = plugin_dir_sha256(&plugin).unwrap();
675 assert_eq!(first.len(), 64);
676 verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap();
677
678 std::fs::write(plugin.join(".git/HEAD"), "still ignored").unwrap();
679 assert_eq!(plugin_dir_sha256(&plugin).unwrap(), first);
680
681 std::fs::write(plugin.join("README.md"), "two").unwrap();
682 let second = plugin_dir_sha256(&plugin).unwrap();
683 assert_ne!(second, first);
684 let err = verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap_err();
685 assert!(err.contains("checksum mismatch"));
686 }
687
688 #[test]
689 fn update_plugin_fast_forwards_and_returns_new_sha() {
690 let (_tmp, bare) = mk_local_repo();
691 let dest_parent = tempfile::tempdir().unwrap();
692 let dest = dest_parent.path().join("demo");
693 let initial_sha = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap();
694
695 let pusher_parent = tempfile::tempdir().unwrap();
697 let pusher = pusher_parent.path().join("push");
698 Command::new("git")
699 .args(["clone", "-q"])
700 .arg(&bare)
701 .arg(&pusher)
702 .status()
703 .unwrap();
704 Command::new("git")
705 .args(["config", "user.email", "t@t"])
706 .current_dir(&pusher)
707 .status()
708 .unwrap();
709 Command::new("git")
710 .args(["config", "user.name", "t"])
711 .current_dir(&pusher)
712 .status()
713 .unwrap();
714 std::fs::write(pusher.join("second.md"), "more").unwrap();
715 Command::new("git")
716 .args(["add", "."])
717 .current_dir(&pusher)
718 .status()
719 .unwrap();
720 Command::new("git")
721 .args(["commit", "-q", "-m", "second"])
722 .current_dir(&pusher)
723 .status()
724 .unwrap();
725 Command::new("git")
726 .args(["push", "-q"])
727 .current_dir(&pusher)
728 .status()
729 .unwrap();
730
731 let updated_sha = update_plugin(&dest).unwrap();
732 assert_eq!(updated_sha.len(), 40);
733 assert_ne!(updated_sha, initial_sha);
734 }
735}