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
27fn 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)>, }
47
48pub 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 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 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 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 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(); 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 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 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 }
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 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 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 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 let cwasm_path_str = tildify(&pre.cwasm_path).unwrap_or(cwasm_rel);
248
249 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 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 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
331fn asset_stem(asset_name: &str) -> &str {
333 asset_name.strip_suffix(".wasm").unwrap_or(asset_name)
334}
335
336fn 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 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}