Skip to main content

yosh_plugin_manager/
sync.rs

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/// Re-apply an ad-hoc code signature to a freshly-downloaded Mach-O so that
10/// its embedded `cs_mtime` matches the file's filesystem mtime.
11///
12/// macOS XNU rejects pages whose `cs_mtime != mtime` for ad-hoc / linker-signed
13/// binaries (`cs_invalid_page`) and SIGKILLs the loading process. The mismatch
14/// is unavoidable for any dylib that is signed in CI, transported through
15/// artifact upload/download/release, and finally fetched over HTTP — every hop
16/// rewrites the file's mtime while the signature's `cs_mtime` is frozen at
17/// build time.
18///
19/// `codesign --force --sign -` replaces the signature with a fresh ad-hoc one
20/// whose `cs_mtime` is aligned with the file's current mtime, mirroring the
21/// approach Homebrew uses when pouring arm64 bottles.
22///
23/// On non-macOS targets this is a no-op (other kernels do not enforce
24/// `cs_mtime`).
25#[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)>, // (name, error)
81}
82
83/// Run the sync flow: read config, diff against lock, download/verify, write lock.
84pub 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    // Prune: delete binaries for plugins removed from config
117    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(); // validated in config
150            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            // Check if unchanged (same version, already in lock, file exists)
155            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            // Download
184            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            // Hash the upstream bytes BEFORE re-signing so we can detect silent
189            // upstream replacement across machines (the post-resign sha256 is
190            // machine-specific on macOS).
191            let upstream_sha256 = sha256_file(&dest_path)?;
192
193            // If re-downloading same version and the upstream hash drifted,
194            // that's suspicious. Skip the check when the previous lock entry
195            // predates upstream_sha256 (legacy entry) — we have nothing to
196            // compare against and re-recording the value is the recovery path.
197            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            // Re-sign on macOS so cs_mtime matches the file's mtime; otherwise
214            // dlopen will be SIGKILLed by XNU's code-signing enforcement.
215            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        // Copy the test binary itself (a real Mach-O) and re-sign it. After
293        // re-signing the file's mtime should be very close to the moment of
294        // signing — the same condition the kernel uses to validate
295        // ad-hoc-signed binaries at load time. We can't read cs_mtime from
296        // userspace easily, but `codesign --verify` exercising the full check
297        // is the real assertion.
298        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        // No content needed — the helper must not touch the file at all.
320        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}