1use std::path::{Path, PathBuf};
2
3use crate::config::{self, PluginDecl, PluginSource};
4use crate::github::GitHubClient;
5use crate::lockfile::{LockEntry, LockFile, load_lockfile, save_lockfile};
6use crate::resolve::asset_filename;
7use crate::verify::{sha256_file, verify_checksum};
8
9#[cfg(target_os = "macos")]
26fn ad_hoc_resign(path: &Path) -> Result<(), String> {
27 let output = std::process::Command::new("codesign")
28 .args(["--force", "--sign", "-"])
29 .arg(path)
30 .output()
31 .map_err(|e| {
32 format!(
33 "failed to invoke codesign for {}: {} \
34 (install Xcode Command Line Tools: 'xcode-select --install')",
35 path.display(),
36 e
37 )
38 })?;
39 if !output.status.success() {
40 return Err(format!(
41 "codesign --force --sign - {} failed: {}",
42 path.display(),
43 String::from_utf8_lossy(&output.stderr).trim()
44 ));
45 }
46 Ok(())
47}
48
49#[cfg(not(target_os = "macos"))]
50fn ad_hoc_resign(_path: &Path) -> Result<(), String> {
51 Ok(())
52}
53
54fn plugin_dir() -> PathBuf {
55 if let Ok(home) = std::env::var("HOME") {
56 PathBuf::from(home).join(".yosh/plugins")
57 } else {
58 PathBuf::from("/tmp/yosh/plugins")
59 }
60}
61
62fn config_dir() -> PathBuf {
63 if let Ok(home) = std::env::var("HOME") {
64 PathBuf::from(home).join(".config/yosh")
65 } else {
66 PathBuf::from("/tmp/yosh")
67 }
68}
69
70pub fn config_path() -> PathBuf {
71 config_dir().join("plugins.toml")
72}
73
74pub fn lock_path() -> PathBuf {
75 config_dir().join("plugins.lock")
76}
77
78pub struct SyncResult {
79 pub succeeded: Vec<String>,
80 pub failed: Vec<(String, String)>, }
82
83pub fn sync(prune: bool) -> Result<SyncResult, String> {
85 let config_path = config_path();
86 let lock_path = lock_path();
87
88 let decls = config::load_config(&config_path)?;
89
90 let existing_lock = match load_lockfile(&lock_path) {
91 Ok(l) => l,
92 Err(e) => {
93 eprintln!("yosh-plugin: warning: {}", e);
94 LockFile { plugin: Vec::new() }
95 }
96 };
97
98 let client = GitHubClient::new();
99 let mut new_entries: Vec<LockEntry> = Vec::new();
100 let mut succeeded: Vec<String> = Vec::new();
101 let mut failed: Vec<(String, String)> = Vec::new();
102
103 for decl in &decls {
104 match sync_one(&client, decl, &existing_lock) {
105 Ok(entry) => {
106 succeeded.push(decl.name.clone());
107 new_entries.push(entry);
108 }
109 Err(e) => {
110 eprintln!("yosh-plugin: {}: {}", decl.name, e);
111 failed.push((decl.name.clone(), e));
112 }
113 }
114 }
115
116 if prune {
118 for old in &existing_lock.plugin {
119 if !decls.iter().any(|d| d.name == old.name) {
120 let path = config::expand_tilde_path(&old.path);
121 if path.exists() {
122 if let Err(e) = std::fs::remove_file(&path) {
123 eprintln!("yosh-plugin: prune {}: {}", old.name, e);
124 } else {
125 eprintln!("yosh-plugin: pruned {}", old.name);
126 }
127 }
128 }
129 }
130 }
131
132 let new_lock = LockFile {
133 plugin: new_entries,
134 };
135 save_lockfile(&lock_path, &new_lock)?;
136
137 Ok(SyncResult { succeeded, failed })
138}
139
140fn sync_one(
141 client: &GitHubClient,
142 decl: &PluginDecl,
143 existing_lock: &LockFile,
144) -> Result<LockEntry, String> {
145 let existing = existing_lock.plugin.iter().find(|e| e.name == decl.name);
146
147 match &decl.source {
148 PluginSource::GitHub { owner, repo } => {
149 let version = decl.version.as_deref().unwrap(); let asset_name = asset_filename(&decl.name, decl.asset.as_deref());
151 let dest_dir = plugin_dir().join(&decl.name);
152 let dest_path = dest_dir.join(&asset_name);
153
154 if let Some(existing) = existing {
156 if existing.version.as_deref() == Some(version) && dest_path.exists() {
157 match verify_checksum(&dest_path, &existing.sha256) {
158 Ok(true) => {
159 return Ok(LockEntry {
160 name: decl.name.clone(),
161 path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
162 enabled: decl.enabled,
163 capabilities: decl.capabilities.clone(),
164 sha256: existing.sha256.clone(),
165 upstream_sha256: existing.upstream_sha256.clone(),
166 source: format!("github:{}/{}", owner, repo),
167 version: Some(version.to_string()),
168 });
169 }
170 Ok(false) => {
171 eprintln!(
172 "yosh-plugin: {}: local binary checksum mismatch, re-downloading",
173 decl.name
174 );
175 }
176 Err(e) => {
177 eprintln!("yosh-plugin: {}: verify failed: {}", decl.name, e);
178 }
179 }
180 }
181 }
182
183 let url = client.find_asset_url(owner, repo, version, &asset_name)?;
185 std::fs::create_dir_all(&dest_dir)
186 .map_err(|e| format!("create dir {}: {}", dest_dir.display(), e))?;
187 client.download(&url, &dest_path)?;
188 let upstream_sha256 = sha256_file(&dest_path)?;
192
193 if let Some(existing) = existing {
198 if existing.version.as_deref() == Some(version) {
199 if let Some(prev_upstream) = existing.upstream_sha256.as_deref() {
200 if upstream_sha256 != prev_upstream {
201 let _ = std::fs::remove_file(&dest_path);
202 return Err(format!(
203 "re-downloaded binary has different checksum \
204 (expected {}, got {}). \
205 The upstream release asset may have been replaced.",
206 prev_upstream, upstream_sha256
207 ));
208 }
209 }
210 }
211 }
212
213 ad_hoc_resign(&dest_path)?;
216 let sha256 = sha256_file(&dest_path)?;
217
218 Ok(LockEntry {
219 name: decl.name.clone(),
220 path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
221 enabled: decl.enabled,
222 capabilities: decl.capabilities.clone(),
223 sha256,
224 upstream_sha256: Some(upstream_sha256),
225 source: format!("github:{}/{}", owner, repo),
226 version: Some(version.to_string()),
227 })
228 }
229 PluginSource::Local { path } => {
230 let resolved = config::expand_tilde_path(path);
231 if !resolved.exists() {
232 return Err(format!("file not found: {}", resolved.display()));
233 }
234 let sha256 = sha256_file(&resolved)?;
235 Ok(LockEntry {
236 name: decl.name.clone(),
237 path: path.clone(),
238 enabled: decl.enabled,
239 capabilities: decl.capabilities.clone(),
240 sha256,
241 upstream_sha256: None,
242 source: format!("local:{}", path),
243 version: None,
244 })
245 }
246 }
247}
248
249#[cfg(test)]
250mod tests {
251 use super::*;
252 use std::io::Write;
253
254 #[test]
255 fn expand_tilde_via_config() {
256 let result = config::expand_tilde_path("~/.yosh/plugins/lib.dylib");
257 assert!(!result.to_string_lossy().starts_with("~"));
258 }
259
260 #[test]
261 fn expand_tilde_absolute_path() {
262 let result = config::expand_tilde_path("/absolute/path");
263 assert_eq!(result, PathBuf::from("/absolute/path"));
264 }
265
266 #[test]
267 fn sync_one_local_plugin() {
268 let mut f = tempfile::NamedTempFile::new().unwrap();
269 f.write_all(b"fake binary content").unwrap();
270 let path = f.path().to_string_lossy().to_string();
271
272 let decl = PluginDecl {
273 name: "local-test".into(),
274 source: PluginSource::Local { path: path.clone() },
275 version: None,
276 enabled: true,
277 capabilities: Some(vec!["io".into()]),
278 asset: None,
279 };
280 let client = GitHubClient::new();
281 let empty_lock = LockFile { plugin: vec![] };
282 let entry = sync_one(&client, &decl, &empty_lock).unwrap();
283 assert_eq!(entry.name, "local-test");
284 assert_eq!(entry.path, path);
285 assert!(!entry.sha256.is_empty());
286 assert!(entry.version.is_none());
287 }
288
289 #[cfg(target_os = "macos")]
290 #[test]
291 fn ad_hoc_resign_succeeds_on_macho_and_aligns_mtime() {
292 let exe = std::env::current_exe().expect("current_exe");
299 let dir = tempfile::tempdir().unwrap();
300 let dest = dir.path().join("resign_target");
301 std::fs::copy(&exe, &dest).expect("copy test binary");
302 ad_hoc_resign(&dest).expect("ad_hoc_resign should succeed on a Mach-O");
303 let verify = std::process::Command::new("codesign")
304 .args(["--verify", "--strict"])
305 .arg(&dest)
306 .output()
307 .expect("invoke codesign --verify");
308 assert!(
309 verify.status.success(),
310 "codesign --verify failed after resign: {}",
311 String::from_utf8_lossy(&verify.stderr)
312 );
313 }
314
315 #[cfg(not(target_os = "macos"))]
316 #[test]
317 fn ad_hoc_resign_is_noop_off_macos() {
318 let f = tempfile::NamedTempFile::new().unwrap();
319 let before = std::fs::metadata(f.path()).unwrap().modified().unwrap();
321 ad_hoc_resign(f.path()).expect("no-op must succeed");
322 let after = std::fs::metadata(f.path()).unwrap().modified().unwrap();
323 assert_eq!(before, after, "no-op must not modify the file");
324 }
325
326 #[test]
327 fn sync_one_local_plugin_missing_file() {
328 let decl = PluginDecl {
329 name: "missing".into(),
330 source: PluginSource::Local {
331 path: "/nonexistent/lib.dylib".into(),
332 },
333 version: None,
334 enabled: true,
335 capabilities: None,
336 asset: None,
337 };
338 let client = GitHubClient::new();
339 let empty_lock = LockFile { plugin: vec![] };
340 let result = sync_one(&client, &decl, &empty_lock);
341 assert!(result.is_err());
342 }
343}