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(['\r', '\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.wait().map_err(|e| format!("wait git: {}", e))?;
88 if !status.success() {
89 let _ = std::fs::remove_dir_all(dest);
90 let trimmed = last_stderr.trim();
91 let detail = if trimmed.is_empty() {
92 format!("exit code {:?}", status.code())
93 } else {
94 trimmed
96 .lines()
97 .rfind(|l| !l.trim().is_empty())
98 .unwrap_or(trimmed)
99 .to_string()
100 };
101 return Err(format!("git clone failed: {}", detail));
102 }
103 Ok(())
104}
105
106pub fn install_plugin(source_url: &str, dest: &Path) -> Result<String, String> {
109 install_plugin_with_progress(source_url, dest, |_| {})
110}
111
112pub fn install_plugin_with_progress(
115 source_url: &str,
116 dest: &Path,
117 on_chunk: impl FnMut(&str),
118) -> Result<String, String> {
119 if dest.exists() {
120 return Err(format!(
121 "{} already exists on disk; uninstall first",
122 dest.display()
123 ));
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!(
162 "{} already exists on disk; uninstall first",
163 dest.display()
164 ));
165 }
166 let parent = dest
167 .parent()
168 .ok_or_else(|| "dest has no parent directory".to_string())?;
169 let dest_name = dest
170 .file_name()
171 .and_then(|s| s.to_str())
172 .ok_or_else(|| "dest file name is not utf-8".to_string())?;
173 let tmp = parent.join(format!(".{}-clone-tmp", dest_name));
174 let _ = std::fs::remove_dir_all(&tmp);
176
177 clone_repo_with_progress(marketplace_url, &tmp, on_chunk)?;
178
179 let sha = match rev_parse_head(&tmp) {
180 Ok(s) => s,
181 Err(e) => {
182 let _ = std::fs::remove_dir_all(&tmp);
183 return Err(e);
184 }
185 };
186
187 let src_subdir = tmp.join(subdir);
188 if !src_subdir.is_dir() {
189 let _ = std::fs::remove_dir_all(&tmp);
190 return Err(format!("subdir '{}' not found in marketplace repo", subdir));
191 }
192
193 if std::fs::rename(&src_subdir, dest).is_err() {
195 copy_dir_all(&src_subdir, dest).map_err(|e| {
196 let _ = std::fs::remove_dir_all(&tmp);
197 format!("copy {} to {}: {}", src_subdir.display(), dest.display(), e)
198 })?;
199 }
200 let _ = std::fs::remove_dir_all(&tmp);
201 Ok(sha)
202}
203
204fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
205 std::fs::create_dir_all(dst)?;
206 for entry in std::fs::read_dir(src)? {
207 let entry = entry?;
208 let ty = entry.file_type()?;
209 let dst_path = dst.join(entry.file_name());
210 if ty.is_dir() {
211 copy_dir_all(&entry.path(), &dst_path)?;
212 } else if ty.is_file() {
213 std::fs::copy(entry.path(), dst_path)?;
214 }
215 }
217 Ok(())
218}
219
220pub fn uninstall_plugin(path: &Path) -> Result<(), String> {
222 match std::fs::remove_dir_all(path) {
223 Ok(()) => Ok(()),
224 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
225 Err(e) => Err(format!("remove {}: {}", path.display(), e)),
226 }
227}
228
229pub fn plugin_dir_sha256(path: &Path) -> Result<String, String> {
237 if !path.is_dir() {
238 return Err(format!("{} is not a directory", path.display()));
239 }
240 let effective_root = path.join(".synaps-plugin").join("plugin.json");
241 if effective_root.is_file() {
242 hash_regular_files(path)
243 } else {
244 let mut candidates = Vec::new();
245 collect_plugin_roots(path, path, &mut candidates)?;
246 candidates.sort();
247 if candidates.len() == 1 {
248 hash_regular_files(&candidates[0])
249 } else {
250 hash_regular_files(path)
251 }
252 }
253}
254
255fn hash_regular_files(path: &Path) -> Result<String, String> {
256 let mut files = Vec::new();
257 collect_regular_files(path, path, &mut files)?;
258 files.sort();
259
260 let mut hasher = Sha256::new();
261 for rel in files {
262 let full = path.join(&rel);
263 hasher.update(rel.to_string_lossy().as_bytes());
264 hasher.update([0]);
265 let bytes = std::fs::read(&full).map_err(|e| format!("read {}: {}", full.display(), e))?;
266 hasher.update(bytes);
267 hasher.update([0]);
268 }
269 Ok(format!("{:x}", hasher.finalize()))
270}
271
272pub fn verify_plugin_dir_checksum(
273 path: &Path,
274 algorithm: &str,
275 expected: &str,
276) -> Result<(), String> {
277 if algorithm != "sha256" {
278 return Err(format!(
279 "unsupported plugin checksum algorithm: {}",
280 algorithm
281 ));
282 }
283 let actual = plugin_dir_sha256(path)?;
284 if actual != expected {
285 return Err(format!(
286 "plugin checksum mismatch: expected sha256:{}, got sha256:{}",
287 expected, actual
288 ));
289 }
290 Ok(())
291}
292
293fn collect_plugin_roots(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
294 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
295 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
296 let path = entry.path();
297 if entry.file_name().to_string_lossy() == ".git" {
298 continue;
299 }
300 let ty = entry
301 .file_type()
302 .map_err(|e| format!("stat {}: {}", path.display(), e))?;
303 if ty.is_dir() {
304 if path.join(".synaps-plugin").join("plugin.json").is_file() && path != root {
305 out.push(path);
306 } else {
307 collect_plugin_roots(root, &path, out)?;
308 }
309 }
310 }
311 Ok(())
312}
313
314fn collect_regular_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
315 for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
316 let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
317 let path = entry.path();
318 let name = entry.file_name();
319 if name.to_string_lossy() == ".git" {
320 continue;
321 }
322 let ty = entry
323 .file_type()
324 .map_err(|e| format!("stat {}: {}", path.display(), e))?;
325 if ty.is_dir() {
326 collect_regular_files(root, &path, out)?;
327 } else if ty.is_file() {
328 let rel = path
329 .strip_prefix(root)
330 .map_err(|e| format!("strip prefix {}: {}", path.display(), e))?
331 .to_path_buf();
332 out.push(rel);
333 }
334 }
335 Ok(())
336}
337
338pub fn update_plugin(install_path: &Path) -> Result<String, String> {
340 let out = Command::new("git")
341 .args(["-C"])
342 .arg(install_path)
343 .args(["pull", "--ff-only", "-q"])
344 .output()
345 .map_err(|e| format!("spawn git: {}", e))?;
346 if !out.status.success() {
347 return Err(format!(
348 "git pull failed: {}",
349 String::from_utf8_lossy(&out.stderr).trim()
350 ));
351 }
352 rev_parse_head(install_path)
353}
354
355pub fn ls_remote_head(source_url: &str) -> Result<String, String> {
357 if source_url.starts_with('-') {
358 return Err(format!("refusing suspicious url: {}", source_url));
359 }
360 let out = Command::new("git")
361 .args(["ls-remote", "--", source_url, "HEAD"])
362 .output()
363 .map_err(|e| format!("spawn git: {}", e))?;
364 if !out.status.success() {
365 return Err(format!(
366 "git ls-remote failed: {}",
367 String::from_utf8_lossy(&out.stderr).trim()
368 ));
369 }
370 let stdout = String::from_utf8_lossy(&out.stdout);
371 let sha = stdout
372 .split_whitespace()
373 .next()
374 .ok_or("empty ls-remote output")?;
375 if sha.len() != 40 {
376 return Err(format!("unexpected ls-remote output: {}", stdout));
377 }
378 Ok(sha.to_string())
379}
380
381fn rev_parse_head(repo: &Path) -> Result<String, String> {
382 let out = Command::new("git")
383 .args(["-C"])
384 .arg(repo)
385 .args(["rev-parse", "HEAD"])
386 .output()
387 .map_err(|e| format!("spawn git: {}", e))?;
388 if !out.status.success() {
389 return Err(format!(
390 "git rev-parse failed: {}",
391 String::from_utf8_lossy(&out.stderr).trim()
392 ));
393 }
394 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
395}
396
397#[cfg(test)]
398mod tests {
399 use super::*;
400 use std::process::Command;
401
402 fn mk_local_repo() -> (tempfile::TempDir, std::path::PathBuf) {
404 let dir = tempfile::tempdir().unwrap();
405 let work = dir.path().join("work");
406 std::fs::create_dir_all(&work).unwrap();
407 Command::new("git")
408 .args(["init", "-q"])
409 .current_dir(&work)
410 .status()
411 .unwrap();
412 Command::new("git")
413 .args(["config", "user.email", "t@t"])
414 .current_dir(&work)
415 .status()
416 .unwrap();
417 Command::new("git")
418 .args(["config", "user.name", "t"])
419 .current_dir(&work)
420 .status()
421 .unwrap();
422 std::fs::write(
423 work.join("SKILL.md"),
424 "---\nname: demo\ndescription: d\n---\nbody",
425 )
426 .unwrap();
427 Command::new("git")
428 .args(["add", "."])
429 .current_dir(&work)
430 .status()
431 .unwrap();
432 Command::new("git")
433 .args(["commit", "-q", "-m", "init"])
434 .current_dir(&work)
435 .status()
436 .unwrap();
437
438 let bare = dir.path().join("bare.git");
439 Command::new("git")
440 .args([
441 "clone",
442 "--bare",
443 "-q",
444 work.to_str().unwrap(),
445 bare.to_str().unwrap(),
446 ])
447 .status()
448 .unwrap();
449 (dir, bare)
450 }
451
452 #[test]
453 fn install_clones_and_returns_sha() {
454 let (_tmp, bare) = mk_local_repo();
455 let dest_parent = tempfile::tempdir().unwrap();
456 let dest = dest_parent.path().join("demo");
457 let sha = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap();
458 assert!(dest.join("SKILL.md").exists());
459 assert_eq!(sha.len(), 40);
460 }
461
462 #[test]
463 fn install_with_progress_streams_chunks_and_returns_sha() {
464 use std::sync::{Arc, Mutex};
465 let (_tmp, bare) = mk_local_repo();
466 let dest_parent = tempfile::tempdir().unwrap();
467 let dest = dest_parent.path().join("demo");
468 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
469 let chunks_clone = Arc::clone(&chunks);
470 let sha =
471 install_plugin_with_progress(&format!("file://{}", bare.display()), &dest, move |c| {
472 chunks_clone.lock().unwrap().push(c.to_string())
473 })
474 .unwrap();
475 assert_eq!(sha.len(), 40);
476 assert!(dest.join("SKILL.md").exists());
477 let captured = chunks.lock().unwrap().clone();
478 assert!(
482 !captured.is_empty(),
483 "expected at least one progress chunk from --progress, got none"
484 );
485 }
486
487 #[test]
488 fn install_with_progress_failure_propagates_stderr() {
489 use std::sync::{Arc, Mutex};
490 let dest_parent = tempfile::tempdir().unwrap();
491 let dest = dest_parent.path().join("demo");
492 let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
493 let chunks_clone = Arc::clone(&chunks);
494 let err = install_plugin_with_progress(
495 "file:///definitely/not/a/real/repo.git",
496 &dest,
497 move |c| chunks_clone.lock().unwrap().push(c.to_string()),
498 )
499 .unwrap_err();
500 assert!(err.contains("git clone failed"), "err was: {err}");
501 assert!(
502 !dest.exists(),
503 "failed clone must not leave a partial dest dir"
504 );
505 }
506
507 fn mk_local_repo_with_subdir(sub: &str) -> (tempfile::TempDir, std::path::PathBuf) {
510 let dir = tempfile::tempdir().unwrap();
511 let work = dir.path().join("work");
512 std::fs::create_dir_all(work.join(sub)).unwrap();
513 Command::new("git")
514 .args(["init", "-q"])
515 .current_dir(&work)
516 .status()
517 .unwrap();
518 Command::new("git")
519 .args(["config", "user.email", "t@t"])
520 .current_dir(&work)
521 .status()
522 .unwrap();
523 Command::new("git")
524 .args(["config", "user.name", "t"])
525 .current_dir(&work)
526 .status()
527 .unwrap();
528 std::fs::write(
529 work.join(sub).join("SKILL.md"),
530 "---\nname: demo\ndescription: d\n---\nbody",
531 )
532 .unwrap();
533 std::fs::write(work.join("README.md"), "top level").unwrap();
534 Command::new("git")
535 .args(["add", "."])
536 .current_dir(&work)
537 .status()
538 .unwrap();
539 Command::new("git")
540 .args(["commit", "-q", "-m", "init"])
541 .current_dir(&work)
542 .status()
543 .unwrap();
544
545 let bare = dir.path().join("bare.git");
546 Command::new("git")
547 .args([
548 "clone",
549 "--bare",
550 "-q",
551 work.to_str().unwrap(),
552 bare.to_str().unwrap(),
553 ])
554 .status()
555 .unwrap();
556 (dir, bare)
557 }
558
559 #[test]
560 fn install_plugin_from_subdir_snapshots_subdir_content() {
561 let (_tmp, bare) = mk_local_repo_with_subdir("web");
562 let dest_parent = tempfile::tempdir().unwrap();
563 let dest = dest_parent.path().join("web");
564 let sha = install_plugin_from_subdir(&format!("file://{}", bare.display()), "web", &dest)
565 .unwrap();
566 assert_eq!(sha.len(), 40);
567 assert!(dest.join("SKILL.md").exists());
569 assert!(!dest.join("README.md").exists());
571 let tmp_leftover = dest_parent.path().join(".web-clone-tmp");
573 assert!(!tmp_leftover.exists());
574 }
575
576 #[test]
577 fn install_plugin_from_subdir_rejects_unsafe_subdir() {
578 let (_tmp, bare) = mk_local_repo_with_subdir("web");
579 let dest_parent = tempfile::tempdir().unwrap();
580 let dest = dest_parent.path().join("web");
581 let err =
582 install_plugin_from_subdir(&format!("file://{}", bare.display()), "../evil", &dest)
583 .unwrap_err();
584 assert!(err.contains("unsafe"));
585 assert!(!dest.exists());
586 }
587
588 #[test]
589 fn install_plugin_from_subdir_fails_when_subdir_missing() {
590 let (_tmp, bare) = mk_local_repo_with_subdir("web");
591 let dest_parent = tempfile::tempdir().unwrap();
592 let dest = dest_parent.path().join("nope");
593 let err = install_plugin_from_subdir(&format!("file://{}", bare.display()), "nope", &dest)
594 .unwrap_err();
595 assert!(err.contains("not found"));
596 assert!(!dest.exists());
597 }
598
599 #[test]
600 fn install_refuses_if_target_exists() {
601 let (_tmp, bare) = mk_local_repo();
602 let dest_parent = tempfile::tempdir().unwrap();
603 let dest = dest_parent.path().join("demo");
604 std::fs::create_dir_all(&dest).unwrap();
605 let err = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap_err();
606 assert!(err.contains("already"));
607 }
608
609 #[test]
610 fn uninstall_removes_directory() {
611 let dir = tempfile::tempdir().unwrap();
612 let p = dir.path().join("demo");
613 std::fs::create_dir_all(&p).unwrap();
614 std::fs::write(p.join("x"), "y").unwrap();
615 uninstall_plugin(&p).unwrap();
616 assert!(!p.exists());
617 }
618
619 #[test]
620 fn uninstall_missing_dir_is_ok() {
621 let dir = tempfile::tempdir().unwrap();
622 let p = dir.path().join("nothere");
623 assert!(uninstall_plugin(&p).is_ok());
624 }
625
626 #[test]
627 fn ls_remote_head_returns_sha_on_real_repo() {
628 let (_tmp, bare) = mk_local_repo();
629 let sha = ls_remote_head(&format!("file://{}", bare.display())).unwrap();
630 assert_eq!(sha.len(), 40);
631 }
632
633 #[test]
634 fn checksum_ignores_git_and_detects_content_changes() {
635 let dir = tempfile::tempdir().unwrap();
636 let plugin = dir.path().join("demo");
637 std::fs::create_dir_all(plugin.join(".synaps-plugin")).unwrap();
638 std::fs::create_dir_all(plugin.join(".git")).unwrap();
639 std::fs::write(plugin.join(".synaps-plugin/plugin.json"), "{}").unwrap();
640 std::fs::write(plugin.join("README.md"), "one").unwrap();
641 std::fs::write(plugin.join(".git/HEAD"), "ignored").unwrap();
642
643 let first = plugin_dir_sha256(&plugin).unwrap();
644 assert_eq!(first.len(), 64);
645 verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap();
646
647 std::fs::write(plugin.join(".git/HEAD"), "still ignored").unwrap();
648 assert_eq!(plugin_dir_sha256(&plugin).unwrap(), first);
649
650 std::fs::write(plugin.join("README.md"), "two").unwrap();
651 let second = plugin_dir_sha256(&plugin).unwrap();
652 assert_ne!(second, first);
653 let err = verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap_err();
654 assert!(err.contains("checksum mismatch"));
655 }
656
657 #[test]
658 fn update_plugin_fast_forwards_and_returns_new_sha() {
659 let (_tmp, bare) = mk_local_repo();
660 let dest_parent = tempfile::tempdir().unwrap();
661 let dest = dest_parent.path().join("demo");
662 let initial_sha = install_plugin(&format!("file://{}", bare.display()), &dest).unwrap();
663
664 let pusher_parent = tempfile::tempdir().unwrap();
666 let pusher = pusher_parent.path().join("push");
667 Command::new("git")
668 .args(["clone", "-q"])
669 .arg(&bare)
670 .arg(&pusher)
671 .status()
672 .unwrap();
673 Command::new("git")
674 .args(["config", "user.email", "t@t"])
675 .current_dir(&pusher)
676 .status()
677 .unwrap();
678 Command::new("git")
679 .args(["config", "user.name", "t"])
680 .current_dir(&pusher)
681 .status()
682 .unwrap();
683 std::fs::write(pusher.join("second.md"), "more").unwrap();
684 Command::new("git")
685 .args(["add", "."])
686 .current_dir(&pusher)
687 .status()
688 .unwrap();
689 Command::new("git")
690 .args(["commit", "-q", "-m", "second"])
691 .current_dir(&pusher)
692 .status()
693 .unwrap();
694 Command::new("git")
695 .args(["push", "-q"])
696 .current_dir(&pusher)
697 .status()
698 .unwrap();
699
700 let updated_sha = update_plugin(&dest).unwrap();
701 assert_eq!(updated_sha.len(), 40);
702 assert_ne!(updated_sha, initial_sha);
703 }
704}