use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use sha2::{Digest, Sha256};
pub fn clone_repo_with_progress(
source_url: &str,
dest: &Path,
mut on_chunk: impl FnMut(&str),
) -> Result<(), String> {
if source_url.starts_with('-') {
return Err(format!("refusing suspicious url: {}", source_url));
}
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("mkdir {}: {}", parent.display(), e))?;
}
let mut child = Command::new("git")
.args(["clone", "--progress", "--depth=1", "--", source_url])
.arg(dest)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
"git not found on PATH".to_string()
} else {
format!("spawn git: {}", e)
}
})?;
let mut stderr = child
.stderr
.take()
.ok_or_else(|| "git stderr was not piped".to_string())?;
let mut buf = [0u8; 4096];
let mut accum = String::new();
let mut last_stderr = String::new();
loop {
match stderr.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let s = String::from_utf8_lossy(&buf[..n]);
accum.push_str(&s);
last_stderr.push_str(&s);
loop {
let split = accum.find(|c: char| c == '\r' || c == '\n');
let Some(pos) = split else { break };
let chunk: String = accum.drain(..pos).collect();
if !accum.is_empty() {
accum.drain(..1);
}
if !chunk.is_empty() {
on_chunk(&chunk);
}
}
if last_stderr.len() > 16 * 1024 {
let cut = last_stderr.len() - 8 * 1024;
last_stderr.replace_range(..cut, "");
}
}
Err(_) => break,
}
}
if !accum.is_empty() {
on_chunk(&accum);
}
let status = child
.wait()
.map_err(|e| format!("wait git: {}", e))?;
if !status.success() {
let _ = std::fs::remove_dir_all(dest);
let trimmed = last_stderr.trim();
let detail = if trimmed.is_empty() {
format!("exit code {:?}", status.code())
} else {
trimmed
.lines()
.filter(|l| !l.trim().is_empty())
.next_back()
.unwrap_or(trimmed)
.to_string()
};
return Err(format!("git clone failed: {}", detail));
}
Ok(())
}
pub fn install_plugin(source_url: &str, dest: &Path) -> Result<String, String> {
install_plugin_with_progress(source_url, dest, |_| {})
}
pub fn install_plugin_with_progress(
source_url: &str,
dest: &Path,
on_chunk: impl FnMut(&str),
) -> Result<String, String> {
if dest.exists() {
return Err(format!("{} already exists on disk; uninstall first", dest.display()));
}
clone_repo_with_progress(source_url, dest, on_chunk)?;
rev_parse_head(dest)
}
pub fn install_plugin_from_subdir(
marketplace_url: &str,
subdir: &str,
dest: &Path,
) -> Result<String, String> {
install_plugin_from_subdir_with_progress(marketplace_url, subdir, dest, |_| {})
}
pub fn install_plugin_from_subdir_with_progress(
marketplace_url: &str,
subdir: &str,
dest: &Path,
on_chunk: impl FnMut(&str),
) -> Result<String, String> {
if !crate::skills::marketplace::is_safe_plugin_name(subdir) {
return Err(format!("refusing unsafe subdir name: {}", subdir));
}
if dest.exists() {
return Err(format!("{} already exists on disk; uninstall first", dest.display()));
}
let parent = dest.parent().ok_or_else(|| "dest has no parent directory".to_string())?;
let dest_name = dest.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| "dest file name is not utf-8".to_string())?;
let tmp = parent.join(format!(".{}-clone-tmp", dest_name));
let _ = std::fs::remove_dir_all(&tmp);
clone_repo_with_progress(marketplace_url, &tmp, on_chunk)?;
let sha = match rev_parse_head(&tmp) {
Ok(s) => s,
Err(e) => {
let _ = std::fs::remove_dir_all(&tmp);
return Err(e);
}
};
let src_subdir = tmp.join(subdir);
if !src_subdir.is_dir() {
let _ = std::fs::remove_dir_all(&tmp);
return Err(format!("subdir '{}' not found in marketplace repo", subdir));
}
if std::fs::rename(&src_subdir, dest).is_err() {
copy_dir_all(&src_subdir, dest).map_err(|e| {
let _ = std::fs::remove_dir_all(&tmp);
format!("copy {} to {}: {}", src_subdir.display(), dest.display(), e)
})?;
}
let _ = std::fs::remove_dir_all(&tmp);
Ok(sha)
}
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst_path)?;
} else if ty.is_file() {
std::fs::copy(entry.path(), dst_path)?;
}
}
Ok(())
}
pub fn uninstall_plugin(path: &Path) -> Result<(), String> {
match std::fs::remove_dir_all(path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("remove {}: {}", path.display(), e)),
}
}
pub fn plugin_dir_sha256(path: &Path) -> Result<String, String> {
if !path.is_dir() {
return Err(format!("{} is not a directory", path.display()));
}
let effective_root = path.join(".synaps-plugin").join("plugin.json");
if effective_root.is_file() {
hash_regular_files(path)
} else {
let mut candidates = Vec::new();
collect_plugin_roots(path, path, &mut candidates)?;
candidates.sort();
if candidates.len() == 1 {
hash_regular_files(&candidates[0])
} else {
hash_regular_files(path)
}
}
}
fn hash_regular_files(path: &Path) -> Result<String, String> {
let mut files = Vec::new();
collect_regular_files(path, path, &mut files)?;
files.sort();
let mut hasher = Sha256::new();
for rel in files {
let full = path.join(&rel);
hasher.update(rel.to_string_lossy().as_bytes());
hasher.update([0]);
let bytes = std::fs::read(&full)
.map_err(|e| format!("read {}: {}", full.display(), e))?;
hasher.update(bytes);
hasher.update([0]);
}
Ok(format!("{:x}", hasher.finalize()))
}
pub fn verify_plugin_dir_checksum(path: &Path, algorithm: &str, expected: &str) -> Result<(), String> {
if algorithm != "sha256" {
return Err(format!("unsupported plugin checksum algorithm: {}", algorithm));
}
let actual = plugin_dir_sha256(path)?;
if actual != expected {
return Err(format!(
"plugin checksum mismatch: expected sha256:{}, got sha256:{}",
expected, actual
));
}
Ok(())
}
fn collect_plugin_roots(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
let path = entry.path();
if entry.file_name().to_string_lossy() == ".git" {
continue;
}
let ty = entry.file_type().map_err(|e| format!("stat {}: {}", path.display(), e))?;
if ty.is_dir() {
if path.join(".synaps-plugin").join("plugin.json").is_file() && path != root {
out.push(path);
} else {
collect_plugin_roots(root, &path, out)?;
}
}
}
Ok(())
}
fn collect_regular_files(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> Result<(), String> {
for entry in std::fs::read_dir(dir).map_err(|e| format!("read dir {}: {}", dir.display(), e))? {
let entry = entry.map_err(|e| format!("read dir {}: {}", dir.display(), e))?;
let path = entry.path();
let name = entry.file_name();
if name.to_string_lossy() == ".git" {
continue;
}
let ty = entry
.file_type()
.map_err(|e| format!("stat {}: {}", path.display(), e))?;
if ty.is_dir() {
collect_regular_files(root, &path, out)?;
} else if ty.is_file() {
let rel = path
.strip_prefix(root)
.map_err(|e| format!("strip prefix {}: {}", path.display(), e))?
.to_path_buf();
out.push(rel);
}
}
Ok(())
}
pub fn update_plugin(install_path: &Path) -> Result<String, String> {
let out = Command::new("git")
.args(["-C"])
.arg(install_path)
.args(["pull", "--ff-only", "-q"])
.output()
.map_err(|e| format!("spawn git: {}", e))?;
if !out.status.success() {
return Err(format!(
"git pull failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
rev_parse_head(install_path)
}
pub fn ls_remote_head(source_url: &str) -> Result<String, String> {
if source_url.starts_with('-') {
return Err(format!("refusing suspicious url: {}", source_url));
}
let out = Command::new("git")
.args(["ls-remote", "--", source_url, "HEAD"])
.output()
.map_err(|e| format!("spawn git: {}", e))?;
if !out.status.success() {
return Err(format!(
"git ls-remote failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
let stdout = String::from_utf8_lossy(&out.stdout);
let sha = stdout
.split_whitespace()
.next()
.ok_or("empty ls-remote output")?;
if sha.len() != 40 {
return Err(format!("unexpected ls-remote output: {}", stdout));
}
Ok(sha.to_string())
}
fn rev_parse_head(repo: &Path) -> Result<String, String> {
let out = Command::new("git")
.args(["-C"])
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.map_err(|e| format!("spawn git: {}", e))?;
if !out.status.success() {
return Err(format!(
"git rev-parse failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn mk_local_repo() -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
std::fs::create_dir_all(&work).unwrap();
Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
std::fs::write(work.join("SKILL.md"),
"---\nname: demo\ndescription: d\n---\nbody").unwrap();
Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
let bare = dir.path().join("bare.git");
Command::new("git").args(["clone", "--bare", "-q",
work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
(dir, bare)
}
#[test]
fn install_clones_and_returns_sha() {
let (_tmp, bare) = mk_local_repo();
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("demo");
let sha = install_plugin(
&format!("file://{}", bare.display()),
&dest,
).unwrap();
assert!(dest.join("SKILL.md").exists());
assert_eq!(sha.len(), 40);
}
#[test]
fn install_with_progress_streams_chunks_and_returns_sha() {
use std::sync::{Arc, Mutex};
let (_tmp, bare) = mk_local_repo();
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("demo");
let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let chunks_clone = Arc::clone(&chunks);
let sha = install_plugin_with_progress(
&format!("file://{}", bare.display()),
&dest,
move |c| chunks_clone.lock().unwrap().push(c.to_string()),
)
.unwrap();
assert_eq!(sha.len(), 40);
assert!(dest.join("SKILL.md").exists());
let captured = chunks.lock().unwrap().clone();
assert!(
!captured.is_empty(),
"expected at least one progress chunk from --progress, got none"
);
}
#[test]
fn install_with_progress_failure_propagates_stderr() {
use std::sync::{Arc, Mutex};
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("demo");
let chunks: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let chunks_clone = Arc::clone(&chunks);
let err = install_plugin_with_progress(
"file:///definitely/not/a/real/repo.git",
&dest,
move |c| chunks_clone.lock().unwrap().push(c.to_string()),
)
.unwrap_err();
assert!(err.contains("git clone failed"), "err was: {err}");
assert!(
!dest.exists(),
"failed clone must not leave a partial dest dir"
);
}
fn mk_local_repo_with_subdir(sub: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().unwrap();
let work = dir.path().join("work");
std::fs::create_dir_all(work.join(sub)).unwrap();
Command::new("git").args(["init", "-q"]).current_dir(&work).status().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&work).status().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(&work).status().unwrap();
std::fs::write(
work.join(sub).join("SKILL.md"),
"---\nname: demo\ndescription: d\n---\nbody",
).unwrap();
std::fs::write(work.join("README.md"), "top level").unwrap();
Command::new("git").args(["add", "."]).current_dir(&work).status().unwrap();
Command::new("git").args(["commit", "-q", "-m", "init"]).current_dir(&work).status().unwrap();
let bare = dir.path().join("bare.git");
Command::new("git").args(["clone", "--bare", "-q",
work.to_str().unwrap(), bare.to_str().unwrap()]).status().unwrap();
(dir, bare)
}
#[test]
fn install_plugin_from_subdir_snapshots_subdir_content() {
let (_tmp, bare) = mk_local_repo_with_subdir("web");
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("web");
let sha = install_plugin_from_subdir(
&format!("file://{}", bare.display()),
"web",
&dest,
).unwrap();
assert_eq!(sha.len(), 40);
assert!(dest.join("SKILL.md").exists());
assert!(!dest.join("README.md").exists());
let tmp_leftover = dest_parent.path().join(".web-clone-tmp");
assert!(!tmp_leftover.exists());
}
#[test]
fn install_plugin_from_subdir_rejects_unsafe_subdir() {
let (_tmp, bare) = mk_local_repo_with_subdir("web");
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("web");
let err = install_plugin_from_subdir(
&format!("file://{}", bare.display()),
"../evil",
&dest,
).unwrap_err();
assert!(err.contains("unsafe"));
assert!(!dest.exists());
}
#[test]
fn install_plugin_from_subdir_fails_when_subdir_missing() {
let (_tmp, bare) = mk_local_repo_with_subdir("web");
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("nope");
let err = install_plugin_from_subdir(
&format!("file://{}", bare.display()),
"nope",
&dest,
).unwrap_err();
assert!(err.contains("not found"));
assert!(!dest.exists());
}
#[test]
fn install_refuses_if_target_exists() {
let (_tmp, bare) = mk_local_repo();
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("demo");
std::fs::create_dir_all(&dest).unwrap();
let err = install_plugin(
&format!("file://{}", bare.display()),
&dest,
).unwrap_err();
assert!(err.contains("already"));
}
#[test]
fn uninstall_removes_directory() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("demo");
std::fs::create_dir_all(&p).unwrap();
std::fs::write(p.join("x"), "y").unwrap();
uninstall_plugin(&p).unwrap();
assert!(!p.exists());
}
#[test]
fn uninstall_missing_dir_is_ok() {
let dir = tempfile::tempdir().unwrap();
let p = dir.path().join("nothere");
assert!(uninstall_plugin(&p).is_ok());
}
#[test]
fn ls_remote_head_returns_sha_on_real_repo() {
let (_tmp, bare) = mk_local_repo();
let sha = ls_remote_head(&format!("file://{}", bare.display())).unwrap();
assert_eq!(sha.len(), 40);
}
#[test]
fn checksum_ignores_git_and_detects_content_changes() {
let dir = tempfile::tempdir().unwrap();
let plugin = dir.path().join("demo");
std::fs::create_dir_all(plugin.join(".synaps-plugin")).unwrap();
std::fs::create_dir_all(plugin.join(".git")).unwrap();
std::fs::write(plugin.join(".synaps-plugin/plugin.json"), "{}").unwrap();
std::fs::write(plugin.join("README.md"), "one").unwrap();
std::fs::write(plugin.join(".git/HEAD"), "ignored").unwrap();
let first = plugin_dir_sha256(&plugin).unwrap();
assert_eq!(first.len(), 64);
verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap();
std::fs::write(plugin.join(".git/HEAD"), "still ignored").unwrap();
assert_eq!(plugin_dir_sha256(&plugin).unwrap(), first);
std::fs::write(plugin.join("README.md"), "two").unwrap();
let second = plugin_dir_sha256(&plugin).unwrap();
assert_ne!(second, first);
let err = verify_plugin_dir_checksum(&plugin, "sha256", &first).unwrap_err();
assert!(err.contains("checksum mismatch"));
}
#[test]
fn update_plugin_fast_forwards_and_returns_new_sha() {
let (_tmp, bare) = mk_local_repo();
let dest_parent = tempfile::tempdir().unwrap();
let dest = dest_parent.path().join("demo");
let initial_sha = install_plugin(
&format!("file://{}", bare.display()),
&dest,
).unwrap();
let pusher_parent = tempfile::tempdir().unwrap();
let pusher = pusher_parent.path().join("push");
Command::new("git").args(["clone", "-q"])
.arg(&bare).arg(&pusher).status().unwrap();
Command::new("git").args(["config", "user.email", "t@t"]).current_dir(&pusher).status().unwrap();
Command::new("git").args(["config", "user.name", "t"]).current_dir(&pusher).status().unwrap();
std::fs::write(pusher.join("second.md"), "more").unwrap();
Command::new("git").args(["add", "."]).current_dir(&pusher).status().unwrap();
Command::new("git").args(["commit", "-q", "-m", "second"]).current_dir(&pusher).status().unwrap();
Command::new("git").args(["push", "-q"]).current_dir(&pusher).status().unwrap();
let updated_sha = update_plugin(&dest).unwrap();
assert_eq!(updated_sha.len(), 40);
assert_ne!(updated_sha, initial_sha);
}
}