1use std::fs;
37use std::path::{Path, PathBuf};
38
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use thiserror::Error;
42
43pub const MANIFEST_PREFIX: &str = "devboy-source-";
49pub const MANIFEST_SUFFIX: &str = ".toml";
50
51pub 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 pub name: String,
64 pub version: String,
67 pub executable: PathBuf,
71 #[serde(default)]
74 pub allowed_env_vars: Vec<String>,
75 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 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
217pub struct DiscoveredPlugin {
218 pub manifest: PluginManifest,
219 pub executable_path: PathBuf,
221 pub manifest_dir: PathBuf,
223}
224
225#[derive(Debug)]
228pub enum DiscoveryOutcome {
229 Ok(DiscoveredPlugin),
230 Err {
231 manifest_path: PathBuf,
232 error: ManifestError,
233 },
234}
235
236pub 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
275pub 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#[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 #[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 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 #[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 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 #[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 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}