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 file as it currently sits on disk. On macOS this includes
22    /// the locally-applied ad-hoc signature, so the value is machine-specific.
23    /// Used by `verify_checksum` to detect local tampering between syncs.
24    pub sha256: String,
25    /// SHA-256 of the asset as served by the upstream source (pre-resign on
26    /// macOS). Stable across machines for the same release artifact, so it
27    /// detects silent upstream replacement at re-download time. `None` for
28    /// local plugins or lock entries written before this field existed.
29    #[serde(default, skip_serializing_if = "Option::is_none")]
30    pub upstream_sha256: Option<String>,
31    pub source: String,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub version: Option<String>,
34}
35
36fn default_true() -> bool {
37    true
38}
39
40pub fn load_lockfile(path: &Path) -> Result<LockFile, String> {
41    if !path.exists() {
42        return Ok(LockFile { plugin: Vec::new() });
43    }
44    let content =
45        std::fs::read_to_string(path).map_err(|e| format!("{}: {}", path.display(), e))?;
46    toml::from_str(&content).map_err(|e| format!("{}: {}", path.display(), e))
47}
48
49pub fn save_lockfile(path: &Path, lockfile: &LockFile) -> Result<(), String> {
50    let content =
51        toml::to_string_pretty(lockfile).map_err(|e| format!("serialize lock file: {}", e))?;
52    let parent = path
53        .parent()
54        .ok_or_else(|| format!("{}: no parent directory", path.display()))?;
55    std::fs::create_dir_all(parent).map_err(|e| format!("{}: {}", parent.display(), e))?;
56    let mut tmp =
57        NamedTempFile::new_in(parent).map_err(|e| format!("{}: {}", path.display(), e))?;
58    tmp.write_all(content.as_bytes())
59        .map_err(|e| format!("{}: {}", path.display(), e))?;
60    tmp.persist(path)
61        .map_err(|e| format!("{}: {}", path.display(), e))?;
62    Ok(())
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68
69    fn sample_entry() -> LockEntry {
70        LockEntry {
71            name: "git-status".into(),
72            path: "~/.yosh/plugins/git-status/libgit_status.dylib".into(),
73            enabled: true,
74            capabilities: Some(vec!["variables:read".into(), "io".into()]),
75            sha256: "abc123".into(),
76            upstream_sha256: Some("upstream123".into()),
77            source: "github:user/repo".into(),
78            version: Some("1.2.3".into()),
79        }
80    }
81
82    #[test]
83    fn round_trip() {
84        let dir = tempfile::tempdir().unwrap();
85        let lock_path = dir.path().join("plugins.lock");
86        let original = LockFile {
87            plugin: vec![sample_entry()],
88        };
89        save_lockfile(&lock_path, &original).unwrap();
90        let loaded = load_lockfile(&lock_path).unwrap();
91        assert_eq!(original, loaded);
92    }
93
94    #[test]
95    fn load_nonexistent_returns_empty() {
96        let lf = load_lockfile(Path::new("/nonexistent/plugins.lock")).unwrap();
97        assert!(lf.plugin.is_empty());
98    }
99
100    #[test]
101    fn save_creates_parent_dirs() {
102        let dir = tempfile::tempdir().unwrap();
103        let lock_path = dir.path().join("sub/dir/plugins.lock");
104        let lf = LockFile {
105            plugin: vec![sample_entry()],
106        };
107        save_lockfile(&lock_path, &lf).unwrap();
108        assert!(lock_path.exists());
109    }
110
111    #[test]
112    fn local_entry_without_version() {
113        let entry = LockEntry {
114            name: "local-tool".into(),
115            path: "~/.yosh/plugins/liblocal.dylib".into(),
116            enabled: true,
117            capabilities: Some(vec!["io".into()]),
118            sha256: "def456".into(),
119            upstream_sha256: None,
120            source: "local:~/.yosh/plugins/liblocal.dylib".into(),
121            version: None,
122        };
123        let dir = tempfile::tempdir().unwrap();
124        let lock_path = dir.path().join("plugins.lock");
125        let original = LockFile {
126            plugin: vec![entry],
127        };
128        save_lockfile(&lock_path, &original).unwrap();
129        let loaded = load_lockfile(&lock_path).unwrap();
130        assert_eq!(original, loaded);
131        assert!(loaded.plugin[0].version.is_none());
132    }
133
134    #[test]
135    fn partial_write_only_successful_entries() {
136        let dir = tempfile::tempdir().unwrap();
137        let lock_path = dir.path().join("plugins.lock");
138        let lf = LockFile {
139            plugin: vec![sample_entry()],
140        };
141        save_lockfile(&lock_path, &lf).unwrap();
142        let loaded = load_lockfile(&lock_path).unwrap();
143        assert_eq!(loaded.plugin.len(), 1);
144    }
145
146    #[test]
147    fn legacy_lockfile_without_upstream_sha256_loads() {
148        let dir = tempfile::tempdir().unwrap();
149        let lock_path = dir.path().join("plugins.lock");
150        // Lock file written by an older yosh-plugin-manager that did not know
151        // about upstream_sha256. Loader must accept it and default the field
152        // to None.
153        let legacy = "\
154[[plugin]]
155name = \"old\"
156path = \"~/.yosh/plugins/old/libold.dylib\"
157enabled = true
158sha256 = \"deadbeef\"
159source = \"github:u/r\"
160version = \"0.1.0\"
161";
162        std::fs::write(&lock_path, legacy).unwrap();
163        let loaded = load_lockfile(&lock_path).unwrap();
164        assert_eq!(loaded.plugin.len(), 1);
165        assert!(loaded.plugin[0].upstream_sha256.is_none());
166    }
167
168    #[test]
169    fn empty_lockfile() {
170        let dir = tempfile::tempdir().unwrap();
171        let lock_path = dir.path().join("plugins.lock");
172        let lf = LockFile { plugin: vec![] };
173        save_lockfile(&lock_path, &lf).unwrap();
174        let loaded = load_lockfile(&lock_path).unwrap();
175        assert!(loaded.plugin.is_empty());
176    }
177}