Skip to main content

devboy_storage/
plugin_manifest.rs

1//! Sidecar manifest + plugin discovery for `SecretSource`
2//! plugins per [ADR-021] §10.
3//!
4//! ## On-disk layout
5//!
6//! Each plugin lives in `~/.devboy/plugins/secrets/`:
7//!
8//! ```text
9//! ~/.devboy/plugins/secrets/
10//! ├── devboy-source-doppler.toml      ← sidecar manifest
11//! └── devboy-source-doppler           ← executable
12//! ```
13//!
14//! The sidecar manifest declares the executable name, version,
15//! checksum (SHA-256 hex), and the env vars the plugin is
16//! allowed to read. The host enforces these *before* spawning
17//! the binary:
18//!
19//! - **Checksum verification** prevents a swapped-out plugin
20//!   from running silently. The `[checksum]` section pins the
21//!   bytes the manifest was authored against.
22//! - **Allowed env-var list** is the only env the plugin
23//!   inherits. Everything else is scrubbed before exec — a
24//!   malicious plugin that tries to read `$AWS_SECRET_KEY` to
25//!   exfiltrate it sees an empty env.
26//!
27//! ## What this module does **not** do
28//!
29//! Spawn the plugin or wire its stdio to the protocol from
30//! `plugin_protocol.rs`. That's the plugin client's job
31//! (P15.2). This module is purely declarative loading and
32//! verification.
33//!
34//! [ADR-021]: https://github.com/meteora-pro/devboy-tools/blob/main/docs/architecture/adr/ADR-021-secret-source-router.md
35
36use std::fs;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use thiserror::Error;
42
43// =============================================================================
44// Sidecar manifest
45// =============================================================================
46
47/// Filename pattern: `devboy-source-<name>.toml`.
48pub const MANIFEST_PREFIX: &str = "devboy-source-";
49pub const MANIFEST_SUFFIX: &str = ".toml";
50
51/// Default plugin discovery directory:
52/// `$HOME/.devboy/plugins/secrets/`. Scanned by
53/// [`discover_plugins_default`].
54pub fn default_discovery_dir() -> Option<PathBuf> {
55    dirs::home_dir().map(|h| h.join(".devboy").join("plugins").join("secrets"))
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(deny_unknown_fields)]
60pub struct PluginManifest {
61    /// Plugin name as the host knows it (e.g. `"doppler"`).
62    /// Matches the `<name>` in the manifest filename.
63    pub name: String,
64    /// Plugin version, advisory only — logged and surfaced in
65    /// `doctor` output.
66    pub version: String,
67    /// Path to the executable, relative to the manifest's
68    /// directory unless absolute. The discovery layer
69    /// canonicalises it before returning.
70    pub executable: PathBuf,
71    /// Env vars the plugin is allowed to inherit from the
72    /// host. Anything not on this list is stripped.
73    #[serde(default)]
74    pub allowed_env_vars: Vec<String>,
75    /// SHA-256 of the executable bytes, lower-case hex. The
76    /// host refuses to spawn if the on-disk checksum doesn't
77    /// match.
78    pub checksum_sha256: String,
79}
80
81#[derive(Debug, Error)]
82pub enum ManifestError {
83    #[error(
84        "manifest filename `{filename}` doesn't follow the `devboy-source-<name>.toml` convention"
85    )]
86    BadFilename { filename: String },
87    #[error("manifest `{path}` declares name `{declared}` but the filename says `{from_filename}`")]
88    NameMismatch {
89        path: PathBuf,
90        declared: String,
91        from_filename: String,
92    },
93    #[error("could not read manifest at {path}: {source}")]
94    Read {
95        path: PathBuf,
96        #[source]
97        source: std::io::Error,
98    },
99    #[error("manifest at {path} is malformed: {source}")]
100    Parse {
101        path: PathBuf,
102        #[source]
103        source: toml::de::Error,
104    },
105    #[error("executable `{path}` referenced by manifest does not exist")]
106    ExecutableMissing { path: PathBuf },
107    #[error("could not read executable bytes at {path}: {source}")]
108    ChecksumIo {
109        path: PathBuf,
110        #[source]
111        source: std::io::Error,
112    },
113    #[error("checksum mismatch for `{path}`: manifest declares {declared}, on-disk is {actual}")]
114    ChecksumMismatch {
115        path: PathBuf,
116        declared: String,
117        actual: String,
118    },
119}
120
121impl PluginManifest {
122    /// Parse a manifest from a TOML string. Cross-checks that
123    /// the declared `name` matches the filename convention.
124    pub fn from_toml_str(body: &str, source_path: &Path) -> Result<Self, ManifestError> {
125        let m: PluginManifest = toml::from_str(body).map_err(|e| ManifestError::Parse {
126            path: source_path.to_path_buf(),
127            source: e,
128        })?;
129
130        let from_filename = name_from_filename(source_path)?;
131        if m.name != from_filename {
132            return Err(ManifestError::NameMismatch {
133                path: source_path.to_path_buf(),
134                declared: m.name,
135                from_filename,
136            });
137        }
138        Ok(m)
139    }
140
141    /// Load + parse + name-cross-check a manifest from disk.
142    pub fn load_from(path: &Path) -> Result<Self, ManifestError> {
143        let body = fs::read_to_string(path).map_err(|e| ManifestError::Read {
144            path: path.to_path_buf(),
145            source: e,
146        })?;
147        Self::from_toml_str(&body, path)
148    }
149
150    /// Resolve the executable path relative to the manifest's
151    /// directory, then verify the checksum on disk. Returns
152    /// the canonicalised absolute path or an error.
153    pub fn verify_executable(&self, manifest_dir: &Path) -> Result<PathBuf, ManifestError> {
154        let abs_exec = if self.executable.is_absolute() {
155            self.executable.clone()
156        } else {
157            manifest_dir.join(&self.executable)
158        };
159        if !abs_exec.exists() {
160            return Err(ManifestError::ExecutableMissing { path: abs_exec });
161        }
162        let bytes = fs::read(&abs_exec).map_err(|e| ManifestError::ChecksumIo {
163            path: abs_exec.clone(),
164            source: e,
165        })?;
166        let actual = sha256_hex(&bytes);
167        if !checksum_eq(&actual, &self.checksum_sha256) {
168            return Err(ManifestError::ChecksumMismatch {
169                path: abs_exec,
170                declared: self.checksum_sha256.clone(),
171                actual,
172            });
173        }
174        Ok(abs_exec)
175    }
176}
177
178fn name_from_filename(path: &Path) -> Result<String, ManifestError> {
179    let filename =
180        path.file_name()
181            .and_then(|s| s.to_str())
182            .ok_or_else(|| ManifestError::BadFilename {
183                filename: path.display().to_string(),
184            })?;
185    let stripped = filename
186        .strip_prefix(MANIFEST_PREFIX)
187        .and_then(|s| s.strip_suffix(MANIFEST_SUFFIX))
188        .ok_or_else(|| ManifestError::BadFilename {
189            filename: filename.to_owned(),
190        })?;
191    if stripped.is_empty() {
192        return Err(ManifestError::BadFilename {
193            filename: filename.to_owned(),
194        });
195    }
196    Ok(stripped.to_owned())
197}
198
199fn sha256_hex(bytes: &[u8]) -> String {
200    let mut hasher = Sha256::new();
201    hasher.update(bytes);
202    hex::encode(hasher.finalize())
203}
204
205fn checksum_eq(actual: &str, declared: &str) -> bool {
206    actual.eq_ignore_ascii_case(declared)
207}
208
209// =============================================================================
210// Discovery
211// =============================================================================
212
213/// Plugin that survived discovery — manifest parsed cleanly
214/// and the executable matches the declared checksum. Ready to
215/// hand to the plugin client.
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct DiscoveredPlugin {
218    pub manifest: PluginManifest,
219    /// Canonicalised absolute path to the verified executable.
220    pub executable_path: PathBuf,
221    /// The directory the manifest was loaded from.
222    pub manifest_dir: PathBuf,
223}
224
225/// Per-manifest outcome. Discovery doesn't bubble the first
226/// error — a single bad plugin shouldn't hide the others.
227#[derive(Debug)]
228pub enum DiscoveryOutcome {
229    Ok(DiscoveredPlugin),
230    Err {
231        manifest_path: PathBuf,
232        error: ManifestError,
233    },
234}
235
236/// Scan `dir` for `devboy-source-*.toml` manifests and load
237/// and verify each. Non-matching files are silently ignored.
238/// Errors are collected per-manifest in the returned outcomes
239/// rather than aborting the whole scan.
240pub fn discover_plugins(dir: &Path) -> Vec<DiscoveryOutcome> {
241    let Ok(read) = fs::read_dir(dir) else {
242        return Vec::new();
243    };
244    let mut out = Vec::new();
245    for entry in read.flatten() {
246        let path = entry.path();
247        let Some(filename) = path.file_name().and_then(|s| s.to_str()) else {
248            continue;
249        };
250        if !filename.starts_with(MANIFEST_PREFIX) || !filename.ends_with(MANIFEST_SUFFIX) {
251            continue;
252        }
253        let manifest_dir = path.parent().unwrap_or(dir).to_path_buf();
254        match PluginManifest::load_from(&path) {
255            Ok(manifest) => match manifest.verify_executable(&manifest_dir) {
256                Ok(exec) => out.push(DiscoveryOutcome::Ok(DiscoveredPlugin {
257                    manifest,
258                    executable_path: exec,
259                    manifest_dir,
260                })),
261                Err(e) => out.push(DiscoveryOutcome::Err {
262                    manifest_path: path,
263                    error: e,
264                }),
265            },
266            Err(e) => out.push(DiscoveryOutcome::Err {
267                manifest_path: path,
268                error: e,
269            }),
270        }
271    }
272    out
273}
274
275/// Convenience over [`discover_plugins`] + the platform's
276/// default discovery directory. Returns an empty Vec if the
277/// directory doesn't exist (no plugins installed).
278pub fn discover_plugins_default() -> Vec<DiscoveryOutcome> {
279    match default_discovery_dir() {
280        Some(dir) => discover_plugins(&dir),
281        None => Vec::new(),
282    }
283}
284
285// =============================================================================
286// Tests
287// =============================================================================
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use std::fs::File;
293    use std::io::Write;
294    use tempfile::TempDir;
295
296    fn write_plugin(dir: &Path, name: &str, body: &[u8]) -> (PathBuf, PathBuf) {
297        let exec_path = dir.join(format!("devboy-source-{name}"));
298        let mut f = File::create(&exec_path).unwrap();
299        f.write_all(body).unwrap();
300        let checksum = sha256_hex(body);
301
302        let manifest_path = dir.join(format!("devboy-source-{name}.toml"));
303        let manifest = format!(
304            r#"
305name = "{name}"
306version = "0.1.0"
307executable = "devboy-source-{name}"
308allowed_env_vars = ["HOME", "PATH"]
309checksum_sha256 = "{checksum}"
310"#,
311        );
312        fs::write(&manifest_path, manifest).unwrap();
313        (manifest_path, exec_path)
314    }
315
316    // -- Manifest parsing -------------------------------------
317
318    #[test]
319    fn manifest_parses_minimal_fields() {
320        let dir = TempDir::new().unwrap();
321        let (manifest_path, _) = write_plugin(dir.path(), "doppler", b"fake-binary");
322        let m = PluginManifest::load_from(&manifest_path).unwrap();
323        assert_eq!(m.name, "doppler");
324        assert_eq!(m.version, "0.1.0");
325        assert_eq!(m.allowed_env_vars, vec!["HOME", "PATH"]);
326    }
327
328    #[test]
329    fn manifest_rejects_name_filename_mismatch() {
330        let dir = TempDir::new().unwrap();
331        // Manifest says name="vault" but filename says "doppler".
332        let manifest_path = dir.path().join("devboy-source-doppler.toml");
333        fs::write(
334            &manifest_path,
335            r#"
336name = "vault"
337version = "0.1.0"
338executable = "x"
339checksum_sha256 = "00"
340"#,
341        )
342        .unwrap();
343        let err = PluginManifest::load_from(&manifest_path).unwrap_err();
344        assert!(matches!(err, ManifestError::NameMismatch { .. }));
345    }
346
347    #[test]
348    fn manifest_rejects_filename_without_proper_prefix() {
349        let dir = TempDir::new().unwrap();
350        let p = dir.path().join("not-a-plugin.toml");
351        fs::write(
352            &p,
353            "name=\"x\"\nversion=\"0\"\nexecutable=\"x\"\nchecksum_sha256=\"00\"",
354        )
355        .unwrap();
356        let err = PluginManifest::load_from(&p).unwrap_err();
357        assert!(matches!(err, ManifestError::BadFilename { .. }));
358    }
359
360    #[test]
361    fn manifest_rejects_unknown_fields() {
362        let dir = TempDir::new().unwrap();
363        let p = dir.path().join("devboy-source-x.toml");
364        fs::write(
365            &p,
366            r#"
367name = "x"
368version = "0"
369executable = "y"
370checksum_sha256 = "00"
371unknown_field = true
372"#,
373        )
374        .unwrap();
375        let err = PluginManifest::load_from(&p).unwrap_err();
376        assert!(matches!(err, ManifestError::Parse { .. }));
377    }
378
379    // -- Checksum verification --------------------------------
380
381    #[test]
382    fn verify_executable_passes_for_matching_checksum() {
383        let dir = TempDir::new().unwrap();
384        let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
385        let m = PluginManifest::load_from(&manifest_path).unwrap();
386        let resolved = m.verify_executable(dir.path()).unwrap();
387        assert_eq!(resolved, exec_path);
388    }
389
390    #[test]
391    fn verify_executable_fails_when_bytes_change() {
392        let dir = TempDir::new().unwrap();
393        let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
394        // Tamper with the executable.
395        fs::write(&exec_path, b"goodbye-world").unwrap();
396        let m = PluginManifest::load_from(&manifest_path).unwrap();
397        let err = m.verify_executable(dir.path()).unwrap_err();
398        assert!(matches!(err, ManifestError::ChecksumMismatch { .. }));
399    }
400
401    #[test]
402    fn verify_executable_fails_when_binary_missing() {
403        let dir = TempDir::new().unwrap();
404        let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
405        fs::remove_file(&exec_path).unwrap();
406        let m = PluginManifest::load_from(&manifest_path).unwrap();
407        let err = m.verify_executable(dir.path()).unwrap_err();
408        assert!(matches!(err, ManifestError::ExecutableMissing { .. }));
409    }
410
411    #[test]
412    fn checksum_comparison_is_case_insensitive() {
413        let a = "ABCDEF";
414        let b = "abcdef";
415        assert!(checksum_eq(a, b));
416    }
417
418    // -- Discovery --------------------------------------------
419
420    #[test]
421    fn discovery_returns_each_valid_plugin() {
422        let dir = TempDir::new().unwrap();
423        let _ = write_plugin(dir.path(), "doppler", b"a");
424        let _ = write_plugin(dir.path(), "vault", b"b");
425        let outcomes = discover_plugins(dir.path());
426        let oks: Vec<&DiscoveredPlugin> = outcomes
427            .iter()
428            .filter_map(|o| match o {
429                DiscoveryOutcome::Ok(p) => Some(p),
430                _ => None,
431            })
432            .collect();
433        assert_eq!(oks.len(), 2);
434    }
435
436    #[test]
437    fn discovery_isolates_per_manifest_errors() {
438        let dir = TempDir::new().unwrap();
439        let _ = write_plugin(dir.path(), "good", b"a");
440        // Corrupt the bad plugin's executable so checksum
441        // verification fails.
442        let (_, bad_exec) = write_plugin(dir.path(), "bad", b"a");
443        fs::write(&bad_exec, b"tampered").unwrap();
444        let outcomes = discover_plugins(dir.path());
445        let oks = outcomes
446            .iter()
447            .filter(|o| matches!(o, DiscoveryOutcome::Ok(_)))
448            .count();
449        let errs = outcomes
450            .iter()
451            .filter(|o| matches!(o, DiscoveryOutcome::Err { .. }))
452            .count();
453        assert_eq!(oks, 1);
454        assert_eq!(errs, 1);
455    }
456
457    #[test]
458    fn discovery_ignores_unrelated_files() {
459        let dir = TempDir::new().unwrap();
460        let _ = write_plugin(dir.path(), "doppler", b"a");
461        fs::write(dir.path().join("README.md"), b"unrelated").unwrap();
462        fs::write(dir.path().join("notes.txt"), b"unrelated").unwrap();
463        let outcomes = discover_plugins(dir.path());
464        assert_eq!(outcomes.len(), 1);
465    }
466
467    #[test]
468    fn discovery_returns_empty_for_missing_dir() {
469        let dir = TempDir::new().unwrap();
470        let nonexistent = dir.path().join("nope");
471        let outcomes = discover_plugins(&nonexistent);
472        assert!(outcomes.is_empty());
473    }
474
475    #[test]
476    fn default_discovery_dir_resolves_under_dot_devboy() {
477        let dir = default_discovery_dir().expect("home dir resolvable in test env");
478        let suffix: PathBuf = [".devboy", "plugins", "secrets"].iter().collect();
479        assert!(
480            dir.ends_with(&suffix),
481            "expected default dir to end with {}, got {}",
482            suffix.display(),
483            dir.display()
484        );
485    }
486}