Skip to main content

klasp_core/
plugin_disable.rs

1//! Per-user plugin disable list for klasp.
2//!
3//! Location: `$KLASP_DISABLED_PLUGINS_FILE` env override, or
4//! `$HOME/.config/klasp/disabled-plugins.toml`.
5//!
6//! Format:
7//! ```toml
8//! disabled = ["my-linter", "another-plugin"]
9//! ```
10//!
11//! The disable list is a klasp-side concept — it does not affect the plugin
12//! wire format (`PluginGateInput` / `PluginGateOutput`).
13
14use std::collections::HashSet;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19/// Environment variable that overrides the default disable list path.
20/// Essential for test isolation.
21pub const KLASP_DISABLED_PLUGINS_FILE_ENV: &str = "KLASP_DISABLED_PLUGINS_FILE";
22
23/// TOML envelope for the disable list file.
24#[derive(Debug, Default, Serialize, Deserialize)]
25struct DisableList {
26    #[serde(default)]
27    disabled: Vec<String>,
28}
29
30/// Resolve the disable list path: env override or `~/.config/klasp/disabled-plugins.toml`.
31pub fn resolve_disable_list_path() -> PathBuf {
32    if let Ok(p) = std::env::var(KLASP_DISABLED_PLUGINS_FILE_ENV) {
33        return PathBuf::from(p);
34    }
35    let home = std::env::var("HOME")
36        .map(PathBuf::from)
37        .unwrap_or_else(|_| PathBuf::from("."));
38    home.join(".config")
39        .join("klasp")
40        .join("disabled-plugins.toml")
41}
42
43/// Validate that `name` is a well-formed plugin name. Same shape as the
44/// `klasp-plugin-<name>` binary lookup: ASCII letters, digits, `_`, `-`.
45/// Rejects path separators, shell metachars, control chars, and empty names.
46pub fn validate_plugin_name(name: &str) -> Result<(), String> {
47    if name.is_empty() {
48        return Err("plugin name is empty".to_string());
49    }
50    if !name
51        .chars()
52        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
53    {
54        return Err(format!(
55            "plugin name `{name}` contains invalid characters; allowed: ASCII letters, digits, `-`, `_`"
56        ));
57    }
58    Ok(())
59}
60
61/// Load the set of disabled plugin names from `path` (or default path if
62/// `None`). Returns an empty set if the file does not exist.
63///
64/// On TOML parse failure (user hand-edited and produced invalid syntax) this
65/// degrades silently to an empty set after writing a warning to stderr — the
66/// gate must continue running. To loudly reject malformed input, use the
67/// stricter `add()` path which refuses to overwrite a malformed file.
68pub fn load(path: Option<&Path>) -> HashSet<String> {
69    let resolved: PathBuf;
70    let p = match path {
71        Some(p) => p,
72        None => {
73            resolved = resolve_disable_list_path();
74            &resolved
75        }
76    };
77
78    let raw = match std::fs::read_to_string(p) {
79        Ok(s) => s,
80        Err(_) => return HashSet::new(),
81    };
82
83    match toml::from_str::<DisableList>(&raw) {
84        Ok(list) => list.disabled.into_iter().collect(),
85        Err(e) => {
86            eprintln!(
87                "warning: disable list at {} is malformed ({e}); ignoring (run `klasp plugins disable` to overwrite)",
88                p.display()
89            );
90            HashSet::new()
91        }
92    }
93}
94
95/// Add `name` to the disable list at `path` (or default path if `None`).
96///
97/// Creates the parent directory and file if they do not exist. No-ops
98/// (with `Ok(())`) if `name` is already disabled.
99/// Writes atomically: writes to a sibling `.tmp` file, then renames.
100///
101/// Refuses to overwrite a malformed file: if the existing list is invalid
102/// TOML, returns `Err` so the user can fix or delete it manually rather than
103/// losing the previously-disabled entries.
104///
105/// Note: not concurrency-safe — concurrent `add()` calls may lose writes.
106/// v0.3 limitation; documented in `docs/plugin-protocol.md` §Disable list.
107pub fn add(name: &str, path: Option<&Path>) -> Result<(), String> {
108    validate_plugin_name(name)?;
109
110    let resolved: PathBuf;
111    let p: &Path = match path {
112        Some(p) => p,
113        None => {
114            resolved = resolve_disable_list_path();
115            &resolved
116        }
117    };
118
119    if let Some(parent) = p.parent() {
120        std::fs::create_dir_all(parent)
121            .map_err(|e| format!("create dir {}: {e}", parent.display()))?;
122    }
123
124    let raw = std::fs::read_to_string(p).unwrap_or_default();
125    let mut list: DisableList = if raw.trim().is_empty() {
126        DisableList::default()
127    } else {
128        toml::from_str(&raw).map_err(|e| {
129            format!(
130                "disable list at {} is malformed: {e}; refusing to overwrite — fix or delete the file and retry",
131                p.display()
132            )
133        })?
134    };
135
136    if list.disabled.iter().any(|n| n == name) {
137        return Ok(());
138    }
139    list.disabled.push(name.to_string());
140
141    let serialized =
142        toml::to_string_pretty(&list).map_err(|e| format!("serialize disable list: {e}"))?;
143
144    let tmp = p.with_extension("toml.tmp");
145    std::fs::write(&tmp, &serialized)
146        .map_err(|e| format!("write temp file {}: {e}", tmp.display()))?;
147    std::fs::rename(&tmp, p)
148        .map_err(|e| format!("rename {} → {}: {e}", tmp.display(), p.display()))?;
149
150    Ok(())
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use tempfile::TempDir;
157
158    fn tmp_path(dir: &TempDir) -> PathBuf {
159        dir.path().join("disabled-plugins.toml")
160    }
161
162    #[test]
163    fn load_returns_empty_when_file_missing() {
164        let dir = TempDir::new().unwrap();
165        let path = tmp_path(&dir);
166        let set = load(Some(&path));
167        assert!(set.is_empty());
168    }
169
170    #[test]
171    fn add_creates_file_and_loads_back() {
172        let dir = TempDir::new().unwrap();
173        let path = tmp_path(&dir);
174        add("my-linter", Some(&path)).unwrap();
175        let set = load(Some(&path));
176        assert!(set.contains("my-linter"));
177    }
178
179    #[test]
180    fn add_is_idempotent() {
181        let dir = TempDir::new().unwrap();
182        let path = tmp_path(&dir);
183        add("my-linter", Some(&path)).unwrap();
184        add("my-linter", Some(&path)).unwrap();
185        let set = load(Some(&path));
186        assert_eq!(set.len(), 1);
187    }
188
189    #[test]
190    fn add_creates_parent_dir() {
191        let dir = TempDir::new().unwrap();
192        let path = dir
193            .path()
194            .join("nested")
195            .join("dir")
196            .join("disabled-plugins.toml");
197        add("my-linter", Some(&path)).unwrap();
198        assert!(path.exists());
199    }
200
201    #[test]
202    fn add_refuses_to_overwrite_malformed_file() {
203        let dir = TempDir::new().unwrap();
204        let path = tmp_path(&dir);
205        std::fs::write(&path, "this is not valid toml = = =").unwrap();
206        let result = add("my-linter", Some(&path));
207        assert!(result.is_err(), "expected Err on malformed file");
208        let msg = result.unwrap_err();
209        assert!(
210            msg.contains("malformed") && msg.contains("refusing to overwrite"),
211            "expected refusal message, got: {msg}"
212        );
213        // File contents preserved.
214        let preserved = std::fs::read_to_string(&path).unwrap();
215        assert_eq!(preserved, "this is not valid toml = = =");
216    }
217
218    #[test]
219    fn load_returns_empty_on_malformed_file() {
220        let dir = TempDir::new().unwrap();
221        let path = tmp_path(&dir);
222        std::fs::write(&path, "this is not valid toml = = =").unwrap();
223        let set = load(Some(&path));
224        assert!(set.is_empty());
225    }
226
227    #[test]
228    fn validate_rejects_bad_names() {
229        assert!(validate_plugin_name("").is_err());
230        assert!(validate_plugin_name("../etc/passwd").is_err());
231        assert!(validate_plugin_name("name with space").is_err());
232        assert!(validate_plugin_name("name\nwith\nnewline").is_err());
233        assert!(validate_plugin_name("name;rm-rf").is_err());
234    }
235
236    #[test]
237    fn validate_accepts_valid_names() {
238        assert!(validate_plugin_name("my-linter").is_ok());
239        assert!(validate_plugin_name("my_linter_v2").is_ok());
240        assert!(validate_plugin_name("Linter123").is_ok());
241    }
242
243    #[test]
244    fn add_rejects_invalid_name() {
245        let dir = TempDir::new().unwrap();
246        let path = tmp_path(&dir);
247        let result = add("../etc/passwd", Some(&path));
248        assert!(result.is_err());
249        assert!(!path.exists(), "must not create file for invalid name");
250    }
251}