Skip to main content

yosh_plugin_manager/
sync.rs

1use std::path::PathBuf;
2
3use crate::config::{self, PluginDecl, PluginSource};
4use crate::github::GitHubClient;
5use crate::lockfile::{LockEntry, LockFile, load_lockfile, save_lockfile};
6use crate::metadata_extract::{self, ExtractedMetadata};
7use crate::precompile::{self, PrecompileOutput};
8use crate::resolve::asset_filename;
9use crate::verify::{sha256_file, verify_checksum};
10
11fn plugin_dir() -> PathBuf {
12    if let Ok(home) = std::env::var("HOME") {
13        PathBuf::from(home).join(".yosh/plugins")
14    } else {
15        PathBuf::from("/tmp/yosh/plugins")
16    }
17}
18
19fn config_dir() -> PathBuf {
20    if let Ok(home) = std::env::var("HOME") {
21        PathBuf::from(home).join(".config/yosh")
22    } else {
23        PathBuf::from("/tmp/yosh")
24    }
25}
26
27/// Per-plugin cwasm cache root. Mirrors `<HOME>/.yosh/plugins/<name>/`
28/// — the `.cwasm` and sidecar live next to the source `.wasm`. The host
29/// cache validator checks that the directory is mode 0700 and uid-owned,
30/// so we co-locate them under the plugin dir which we already control.
31fn cache_dir_for(plugin_name: &str) -> PathBuf {
32    plugin_dir().join(plugin_name)
33}
34
35pub fn config_path() -> PathBuf {
36    config_dir().join("plugins.toml")
37}
38
39pub fn lock_path() -> PathBuf {
40    config_dir().join("plugins.lock")
41}
42
43pub struct SyncResult {
44    pub succeeded: Vec<String>,
45    pub failed: Vec<(String, String)>, // (name, error)
46}
47
48/// Run the sync flow: read config, diff against lock, download/verify, write lock.
49pub fn sync(prune: bool) -> Result<SyncResult, String> {
50    let config_path = config_path();
51    let lock_path = lock_path();
52
53    let decls = config::load_config(&config_path)?;
54
55    let existing_lock = match load_lockfile(&lock_path) {
56        Ok(l) => l,
57        Err(e) => {
58            eprintln!("yosh-plugin: warning: {}", e);
59            LockFile { plugin: Vec::new() }
60        }
61    };
62
63    let client = GitHubClient::new();
64    // One engine each, shared across plugins. Building engines is non-trivial
65    // (cranelift initialisation), so reusing them for the whole sync run
66    // amortises the cost. precompile and metadata engines are flag-equivalent
67    // but kept as separate handles so each call site documents its semantic
68    // intent (cwasm production vs one-shot metadata watchdog).
69    let precompile_engine = precompile::make_engine()?;
70    let metadata_engine = precompile::make_engine()?;
71
72    let mut new_entries: Vec<LockEntry> = Vec::new();
73    let mut succeeded: Vec<String> = Vec::new();
74    let mut failed: Vec<(String, String)> = Vec::new();
75
76    for decl in &decls {
77        match sync_one(
78            &client,
79            decl,
80            &existing_lock,
81            &precompile_engine,
82            &metadata_engine,
83        ) {
84            Ok(entry) => {
85                succeeded.push(decl.name.clone());
86                new_entries.push(entry);
87            }
88            Err(e) => {
89                eprintln!("yosh-plugin: {}: {}", decl.name, e);
90                failed.push((decl.name.clone(), e));
91            }
92        }
93    }
94
95    // Prune: delete binaries for plugins removed from config
96    if prune {
97        for old in &existing_lock.plugin {
98            if !decls.iter().any(|d| d.name == old.name) {
99                let path = config::expand_tilde_path(&old.path);
100                if path.exists() {
101                    if let Err(e) = std::fs::remove_file(&path) {
102                        eprintln!("yosh-plugin: prune {}: {}", old.name, e);
103                    } else {
104                        eprintln!("yosh-plugin: pruned {}", old.name);
105                    }
106                }
107                // Also drop any stale cwasm + sidecar.
108                if let Some(cwasm) = &old.cwasm_path {
109                    let p = config::expand_tilde_path(cwasm);
110                    let _ = std::fs::remove_file(&p);
111                    let meta = p.with_extension("cwasm.meta");
112                    let _ = std::fs::remove_file(&meta);
113                }
114                // Best-effort: remove the now-empty per-plugin directory.
115                // Manager-managed layout co-locates wasm + cwasm under
116                // `<root>/<name>/`, so once both files are gone the dir
117                // is typically empty. `remove_dir` fails fast if not
118                // empty (e.g. user dropped a stray file there); we
119                // ignore the error in that case.
120                if let Some(parent) = path.parent() {
121                    let _ = std::fs::remove_dir(parent);
122                }
123                if let Some(cwasm) = &old.cwasm_path {
124                    let p = config::expand_tilde_path(cwasm);
125                    if let Some(parent) = p.parent() {
126                        let _ = std::fs::remove_dir(parent);
127                    }
128                }
129            }
130        }
131    }
132
133    let new_lock = LockFile {
134        plugin: new_entries,
135    };
136    save_lockfile(&lock_path, &new_lock)?;
137
138    Ok(SyncResult { succeeded, failed })
139}
140
141fn sync_one(
142    client: &GitHubClient,
143    decl: &PluginDecl,
144    existing_lock: &LockFile,
145    precompile_engine: &wasmtime::Engine,
146    metadata_engine: &wasmtime::Engine,
147) -> Result<LockEntry, String> {
148    let existing = existing_lock.plugin.iter().find(|e| e.name == decl.name);
149
150    match &decl.source {
151        PluginSource::GitHub { owner, repo } => {
152            let version = decl.version.as_deref().unwrap(); // validated in config
153            let asset_name = asset_filename(&decl.name, decl.asset.as_deref());
154            let dest_dir = plugin_dir().join(&decl.name);
155            let dest_path = dest_dir.join(&asset_name);
156
157            // Fast path: existing entry, same version, file present and
158            // checksum matches, AND we already have cwasm + metadata cached
159            // in the lock. If anything is missing fall through to the
160            // download / precompile / metadata path so we can repair.
161            if let Some(existing) = existing
162                && existing.version.as_deref() == Some(version)
163                && dest_path.exists()
164                && existing.cwasm_path.is_some()
165                && existing.required_capabilities.is_some()
166            {
167                match verify_checksum(&dest_path, &existing.sha256) {
168                    Ok(true) => {
169                        // cwasm sidecar might still be stale on disk
170                        // (e.g. prior `prune` removed it). Verify the
171                        // file is present; if not, re-precompile only
172                        // (skip download).
173                        let cwasm_present = existing
174                            .cwasm_path
175                            .as_deref()
176                            .map(config::expand_tilde_path)
177                            .map(|p| p.exists())
178                            .unwrap_or(false);
179                        if cwasm_present {
180                            return Ok(existing.clone());
181                        }
182                        // Fall through to re-run precompile + metadata.
183                    }
184                    Ok(false) => {
185                        eprintln!(
186                            "yosh-plugin: {}: local checksum mismatch, re-downloading",
187                            decl.name
188                        );
189                    }
190                    Err(e) => {
191                        eprintln!("yosh-plugin: {}: verify failed: {}", decl.name, e);
192                    }
193                }
194            }
195
196            // Download (only if file is missing or stale).
197            let need_download = !dest_path.exists()
198                || existing
199                    .map(|e| e.version.as_deref() != Some(version))
200                    .unwrap_or(true);
201            let upstream_sha256 = if need_download {
202                let url = client.find_asset_url(owner, repo, version, &asset_name)?;
203                std::fs::create_dir_all(&dest_dir)
204                    .map_err(|e| format!("create dir {}: {}", dest_dir.display(), e))?;
205                client.download(&url, &dest_path)?;
206                let sha = sha256_file(&dest_path)?;
207
208                // Re-download integrity check vs prior lock entry.
209                if let Some(existing) = existing
210                    && existing.version.as_deref() == Some(version)
211                    && let Some(prev_upstream) = existing.upstream_sha256.as_deref()
212                    && sha != prev_upstream
213                {
214                    let _ = std::fs::remove_file(&dest_path);
215                    return Err(format!(
216                        "re-downloaded asset has different checksum \
217                         (expected {}, got {}). \
218                         The upstream release asset may have been replaced.",
219                        prev_upstream, sha
220                    ));
221                }
222                sha
223            } else {
224                sha256_file(&dest_path)?
225            };
226
227            // Precompile + metadata extraction. The wasm bytes are the same
228            // input; we read them once and pass to both.
229            let wasm_bytes = std::fs::read(&dest_path)
230                .map_err(|e| format!("read {}: {}", dest_path.display(), e))?;
231
232            let metadata = metadata_extract::extract(metadata_engine, &wasm_bytes)
233                .map_err(|e| format!("metadata extract: {}", e))?;
234
235            let cache_dir = cache_dir_for(&decl.name);
236            let pre = precompile::precompile(&dest_path, &cache_dir, precompile_engine)
237                .map_err(|e| format!("precompile: {}", e))?;
238            let cwasm_rel = format!(
239                "~/.yosh/plugins/{}/{}.cwasm",
240                decl.name,
241                asset_stem(&asset_name)
242            );
243            // Use the literal precompile output path for the lock entry
244            // (which encodes the absolute path) so the host can find it
245            // verbatim. If HOME is set, use the ~-prefixed form for
246            // portability.
247            let cwasm_path_str = tildify(&pre.cwasm_path).unwrap_or(cwasm_rel);
248
249            // sha256 == upstream_sha256 in v0.2.0+ since we no longer
250            // re-sign. Keep both fields populated for compatibility.
251            Ok(LockEntry {
252                name: decl.name.clone(),
253                path: format!("~/.yosh/plugins/{}/{}", decl.name, asset_name),
254                enabled: decl.enabled,
255                capabilities: decl.capabilities.clone(),
256                sha256: upstream_sha256.clone(),
257                upstream_sha256: Some(upstream_sha256),
258                source: format!("github:{}/{}", owner, repo),
259                version: Some(version.to_string()),
260                cwasm_path: Some(cwasm_path_str),
261                wasmtime_version: Some(pre.cache_key.wasmtime_version.clone()),
262                target_triple: Some(pre.cache_key.target_triple.clone()),
263                engine_config_hash: Some(pre.cache_key.engine_config_hash.clone()),
264                required_capabilities: Some(metadata.required_capabilities),
265                implemented_hooks: Some(metadata.implemented_hooks),
266            })
267        }
268        PluginSource::Local { path } => {
269            let resolved = config::expand_tilde_path(path);
270            if !resolved.exists() {
271                return Err(format!("file not found: {}", resolved.display()));
272            }
273            let sha256 = sha256_file(&resolved)?;
274
275            // Local plugins also benefit from precompile + metadata caching.
276            let wasm_bytes = std::fs::read(&resolved)
277                .map_err(|e| format!("read {}: {}", resolved.display(), e))?;
278
279            let metadata_result = metadata_extract::extract(metadata_engine, &wasm_bytes);
280            let cache_dir = cache_dir_for(&decl.name);
281            let pre_result = precompile::precompile(&resolved, &cache_dir, precompile_engine);
282
283            // Local-plugin tolerance: if precompile or metadata fails (e.g.
284            // the user pointed at a non-component file), we record the entry
285            // without the cached fields so the host can still try to load
286            // it the slow path. Tests exercise the no-metadata case with
287            // throwaway "fake binary" content.
288            let (cwasm_fields, meta_fields): (Option<PrecompileOutput>, Option<ExtractedMetadata>) =
289                match (pre_result, metadata_result) {
290                    (Ok(pre), Ok(meta)) => (Some(pre), Some(meta)),
291                    (Ok(pre), Err(_)) => (Some(pre), None),
292                    (Err(_), Ok(meta)) => (None, Some(meta)),
293                    (Err(_), Err(_)) => (None, None),
294                };
295
296            let cwasm_path = cwasm_fields.as_ref().and_then(|p| tildify(&p.cwasm_path));
297            let wasmtime_version = cwasm_fields
298                .as_ref()
299                .map(|p| p.cache_key.wasmtime_version.clone());
300            let target_triple = cwasm_fields
301                .as_ref()
302                .map(|p| p.cache_key.target_triple.clone());
303            let engine_config_hash = cwasm_fields
304                .as_ref()
305                .map(|p| p.cache_key.engine_config_hash.clone());
306            let required_capabilities = meta_fields
307                .as_ref()
308                .map(|m| m.required_capabilities.clone());
309            let implemented_hooks = meta_fields.as_ref().map(|m| m.implemented_hooks.clone());
310
311            Ok(LockEntry {
312                name: decl.name.clone(),
313                path: path.clone(),
314                enabled: decl.enabled,
315                capabilities: decl.capabilities.clone(),
316                sha256,
317                upstream_sha256: None,
318                source: format!("local:{}", path),
319                version: None,
320                cwasm_path,
321                wasmtime_version,
322                target_triple,
323                engine_config_hash,
324                required_capabilities,
325                implemented_hooks,
326            })
327        }
328    }
329}
330
331/// Extract `<stem>` from `<stem>.wasm`, or fall back to the whole name.
332fn asset_stem(asset_name: &str) -> &str {
333    asset_name.strip_suffix(".wasm").unwrap_or(asset_name)
334}
335
336/// Best-effort `~/...` rewrite for paths under `$HOME`. Returns `None`
337/// when the path is not under HOME or HOME is unset; callers fall back
338/// to the absolute string.
339fn tildify(p: &std::path::Path) -> Option<String> {
340    let home = std::env::var("HOME").ok()?;
341    let s = p.to_string_lossy();
342    s.strip_prefix(&home).map(|rest| format!("~{}", rest))
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348    use std::io::Write;
349
350    #[test]
351    fn expand_tilde_via_config() {
352        let result = config::expand_tilde_path("~/.yosh/plugins/plugin.wasm");
353        assert!(!result.to_string_lossy().starts_with("~"));
354    }
355
356    #[test]
357    fn expand_tilde_absolute_path() {
358        let result = config::expand_tilde_path("/absolute/path");
359        assert_eq!(result, PathBuf::from("/absolute/path"));
360    }
361
362    #[test]
363    fn sync_one_local_plugin() {
364        let mut f = tempfile::NamedTempFile::new().unwrap();
365        f.write_all(b"fake binary content").unwrap();
366        let path = f.path().to_string_lossy().to_string();
367
368        let decl = PluginDecl {
369            name: "local-test".into(),
370            source: PluginSource::Local { path: path.clone() },
371            version: None,
372            enabled: true,
373            capabilities: Some(vec!["io".into()]),
374            asset: None,
375        };
376        let client = GitHubClient::new();
377        let empty_lock = LockFile { plugin: vec![] };
378        let pre_engine = precompile::make_engine().unwrap();
379        let meta_engine = precompile::make_engine().unwrap();
380        let entry = sync_one(&client, &decl, &empty_lock, &pre_engine, &meta_engine).unwrap();
381        assert_eq!(entry.name, "local-test");
382        assert_eq!(entry.path, path);
383        assert!(!entry.sha256.is_empty());
384        assert!(entry.version.is_none());
385        // "fake binary content" is not a real component; precompile +
386        // metadata extraction both fail and we fall through with all
387        // cwasm/metadata fields unset. The lock entry is still recorded.
388        assert!(entry.cwasm_path.is_none());
389        assert!(entry.required_capabilities.is_none());
390    }
391
392    #[test]
393    fn sync_one_local_plugin_missing_file() {
394        let decl = PluginDecl {
395            name: "missing".into(),
396            source: PluginSource::Local {
397                path: "/nonexistent/plugin.wasm".into(),
398            },
399            version: None,
400            enabled: true,
401            capabilities: None,
402            asset: None,
403        };
404        let client = GitHubClient::new();
405        let empty_lock = LockFile { plugin: vec![] };
406        let pre_engine = precompile::make_engine().unwrap();
407        let meta_engine = precompile::make_engine().unwrap();
408        let result = sync_one(&client, &decl, &empty_lock, &pre_engine, &meta_engine);
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn asset_stem_strips_wasm_suffix() {
414        assert_eq!(asset_stem("plugin.wasm"), "plugin");
415        assert_eq!(asset_stem("my-plugin.wasm"), "my-plugin");
416        assert_eq!(asset_stem("noext"), "noext");
417    }
418
419    #[test]
420    fn tildify_under_home() {
421        let home = std::env::var("HOME").unwrap_or_default();
422        if home.is_empty() {
423            return;
424        }
425        let p = std::path::PathBuf::from(&home).join("foo/bar.wasm");
426        assert_eq!(tildify(&p), Some("~/foo/bar.wasm".to_string()));
427    }
428
429    #[test]
430    fn tildify_outside_home_returns_none() {
431        let p = std::path::PathBuf::from("/tmp/foo");
432        assert_eq!(tildify(&p), None);
433    }
434}