Skip to main content

yosh_plugin_manager/
lockfile.rs

1use std::io::Write;
2use std::path::Path;
3
4use serde::{Deserialize, Serialize};
5use tempfile::NamedTempFile;
6
7#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
8pub struct LockFile {
9    #[serde(default)]
10    pub plugin: Vec<LockEntry>,
11}
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct LockEntry {
15    pub name: String,
16    pub path: String,
17    #[serde(default = "default_true")]
18    pub enabled: bool,
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub capabilities: Option<Vec<String>>,
21    /// SHA-256 of the on-disk `.wasm` file. With the v0.2.0 component
22    /// model migration the file is no longer a Mach-O / ELF binary, so
23    /// this hash is identical to `upstream_sha256` (no local re-signing
24    /// step). Both fields are kept for round-trip compatibility with
25    /// older lockfiles.
26    pub sha256: String,
27    /// SHA-256 of the asset as served by the upstream source. Stable
28    /// across machines; detects silent upstream replacement at
29    /// re-download time. `None` for local plugins or lock entries
30    /// written before this field existed.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub upstream_sha256: Option<String>,
33    pub source: String,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub version: Option<String>,
36    /// Path to the precompiled `.cwasm` next to the source `.wasm`.
37    /// Used by the host's cache validator to skip re-precompiling at
38    /// startup. `None` when precompile failed during sync (host falls
39    /// back to in-memory `Component::new` in that case).
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub cwasm_path: Option<String>,
42    /// Wasmtime version that produced the `cwasm_path` artifact. Part
43    /// of the four-tuple cache key; the host rejects the cwasm if its
44    /// own pin differs.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub wasmtime_version: Option<String>,
47    /// Target triple the cwasm was precompiled for. Cwasm files are
48    /// not portable across triples, so the host rejects mismatches.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub target_triple: Option<String>,
51    /// Hex-encoded SHA-256 of the engine config fingerprint. Lets the
52    /// host detect when its `wasmtime::Config` changed since the cwasm
53    /// was written.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub engine_config_hash: Option<String>,
56    /// Plugin-self-reported capability strings extracted at sync time.
57    /// Cached so `yosh-plugin list` can show capabilities without
58    /// instantiating each plugin.
59    #[serde(default, skip_serializing_if = "Option::is_none")]
60    pub required_capabilities: Option<Vec<String>>,
61    /// Plugin-self-reported hook names extracted at sync time. Same
62    /// caching rationale as `required_capabilities`.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub implemented_hooks: Option<Vec<String>>,
65}
66
67fn default_true() -> bool {
68    true
69}
70
71pub fn load_lockfile(path: &Path) -> Result<LockFile, String> {
72    if !path.exists() {
73        return Ok(LockFile { plugin: Vec::new() });
74    }
75    let content =
76        std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
77    toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
78}
79
80pub fn save_lockfile(path: &Path, lockfile: &LockFile) -> Result<(), String> {
81    let content =
82        toml::to_string_pretty(lockfile).map_err(|e| format!("serialize lock file: {}", e))?;
83    let parent = path
84        .parent()
85        .ok_or_else(|| format!("{}: no parent directory", path.display()))?;
86    std::fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
87    let mut tmp =
88        NamedTempFile::new_in(parent).map_err(|e| format!("{}: {}", path.display(), e))?;
89    tmp.write_all(content.as_bytes())
90        .map_err(|e| format!("{}: {}", path.display(), e))?;
91    tmp.persist(path)
92        .map_err(|e| format!("{}: {}", path.display(), e))?;
93    Ok(())
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    fn sample_entry() -> LockEntry {
101        LockEntry {
102            name: "git-status".into(),
103            path: "~/.yosh/plugins/git-status/git_status.wasm".into(),
104            enabled: true,
105            capabilities: Some(vec!["variables:read".into(), "io".into()]),
106            sha256: "abc123".into(),
107            upstream_sha256: Some("upstream123".into()),
108            source: "github:user/repo".into(),
109            version: Some("1.2.3".into()),
110            cwasm_path: None,
111            wasmtime_version: None,
112            target_triple: None,
113            engine_config_hash: None,
114            required_capabilities: None,
115            implemented_hooks: None,
116        }
117    }
118
119    #[test]
120    fn round_trip() {
121        let dir = tempfile::tempdir().unwrap();
122        let lock_path = dir.path().join("plugins.lock");
123        let original = LockFile {
124            plugin: vec![sample_entry()],
125        };
126        save_lockfile(&lock_path, &original).unwrap();
127        let loaded = load_lockfile(&lock_path).unwrap();
128        assert_eq!(original, loaded);
129    }
130
131    #[test]
132    fn load_nonexistent_returns_empty() {
133        let lf = load_lockfile(Path::new("/nonexistent/plugins.lock")).unwrap();
134        assert!(lf.plugin.is_empty());
135    }
136
137    #[test]
138    fn save_creates_parent_dirs() {
139        let dir = tempfile::tempdir().unwrap();
140        let lock_path = dir.path().join("sub/dir/plugins.lock");
141        let lf = LockFile {
142            plugin: vec![sample_entry()],
143        };
144        save_lockfile(&lock_path, &lf).unwrap();
145        assert!(lock_path.exists());
146    }
147
148    #[test]
149    fn local_entry_without_version() {
150        let entry = LockEntry {
151            name: "local-tool".into(),
152            path: "~/.yosh/plugins/local.wasm".into(),
153            enabled: true,
154            capabilities: Some(vec!["io".into()]),
155            sha256: "def456".into(),
156            upstream_sha256: None,
157            source: "local:~/.yosh/plugins/local.wasm".into(),
158            version: None,
159            cwasm_path: None,
160            wasmtime_version: None,
161            target_triple: None,
162            engine_config_hash: None,
163            required_capabilities: None,
164            implemented_hooks: None,
165        };
166        let dir = tempfile::tempdir().unwrap();
167        let lock_path = dir.path().join("plugins.lock");
168        let original = LockFile {
169            plugin: vec![entry],
170        };
171        save_lockfile(&lock_path, &original).unwrap();
172        let loaded = load_lockfile(&lock_path).unwrap();
173        assert_eq!(original, loaded);
174        assert!(loaded.plugin[0].version.is_none());
175    }
176
177    #[test]
178    fn partial_write_only_successful_entries() {
179        let dir = tempfile::tempdir().unwrap();
180        let lock_path = dir.path().join("plugins.lock");
181        let lf = LockFile {
182            plugin: vec![sample_entry()],
183        };
184        save_lockfile(&lock_path, &lf).unwrap();
185        let loaded = load_lockfile(&lock_path).unwrap();
186        assert_eq!(loaded.plugin.len(), 1);
187    }
188
189    #[test]
190    fn legacy_lockfile_without_upstream_sha256_loads() {
191        let dir = tempfile::tempdir().unwrap();
192        let lock_path = dir.path().join("plugins.lock");
193        // Lock file written by an older yosh-plugin-manager that did not know
194        // about upstream_sha256. Loader must accept it and default the field
195        // to None.
196        let legacy = "\
197[[plugin]]
198name = \"old\"
199path = \"~/.yosh/plugins/old/old.wasm\"
200enabled = true
201sha256 = \"deadbeef\"
202source = \"github:u/r\"
203version = \"0.1.0\"
204";
205        std::fs::write(&lock_path, legacy).unwrap();
206        let loaded = load_lockfile(&lock_path).unwrap();
207        assert_eq!(loaded.plugin.len(), 1);
208        assert!(loaded.plugin[0].upstream_sha256.is_none());
209    }
210
211    #[test]
212    fn empty_lockfile() {
213        let dir = tempfile::tempdir().unwrap();
214        let lock_path = dir.path().join("plugins.lock");
215        let lf = LockFile { plugin: vec![] };
216        save_lockfile(&lock_path, &lf).unwrap();
217        let loaded = load_lockfile(&lock_path).unwrap();
218        assert!(loaded.plugin.is_empty());
219    }
220}