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