use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use thiserror::Error;
pub const MANIFEST_PREFIX: &str = "devboy-source-";
pub const MANIFEST_SUFFIX: &str = ".toml";
pub fn default_discovery_dir() -> Option<PathBuf> {
dirs::home_dir().map(|h| h.join(".devboy").join("plugins").join("secrets"))
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
pub name: String,
pub version: String,
pub executable: PathBuf,
#[serde(default)]
pub allowed_env_vars: Vec<String>,
pub checksum_sha256: String,
}
#[derive(Debug, Error)]
pub enum ManifestError {
#[error(
"manifest filename `{filename}` doesn't follow the `devboy-source-<name>.toml` convention"
)]
BadFilename { filename: String },
#[error("manifest `{path}` declares name `{declared}` but the filename says `{from_filename}`")]
NameMismatch {
path: PathBuf,
declared: String,
from_filename: String,
},
#[error("could not read manifest at {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("manifest at {path} is malformed: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("executable `{path}` referenced by manifest does not exist")]
ExecutableMissing { path: PathBuf },
#[error("could not read executable bytes at {path}: {source}")]
ChecksumIo {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("checksum mismatch for `{path}`: manifest declares {declared}, on-disk is {actual}")]
ChecksumMismatch {
path: PathBuf,
declared: String,
actual: String,
},
}
impl PluginManifest {
pub fn from_toml_str(body: &str, source_path: &Path) -> Result<Self, ManifestError> {
let m: PluginManifest = toml::from_str(body).map_err(|e| ManifestError::Parse {
path: source_path.to_path_buf(),
source: e,
})?;
let from_filename = name_from_filename(source_path)?;
if m.name != from_filename {
return Err(ManifestError::NameMismatch {
path: source_path.to_path_buf(),
declared: m.name,
from_filename,
});
}
Ok(m)
}
pub fn load_from(path: &Path) -> Result<Self, ManifestError> {
let body = fs::read_to_string(path).map_err(|e| ManifestError::Read {
path: path.to_path_buf(),
source: e,
})?;
Self::from_toml_str(&body, path)
}
pub fn verify_executable(&self, manifest_dir: &Path) -> Result<PathBuf, ManifestError> {
let abs_exec = if self.executable.is_absolute() {
self.executable.clone()
} else {
manifest_dir.join(&self.executable)
};
if !abs_exec.exists() {
return Err(ManifestError::ExecutableMissing { path: abs_exec });
}
let bytes = fs::read(&abs_exec).map_err(|e| ManifestError::ChecksumIo {
path: abs_exec.clone(),
source: e,
})?;
let actual = sha256_hex(&bytes);
if !checksum_eq(&actual, &self.checksum_sha256) {
return Err(ManifestError::ChecksumMismatch {
path: abs_exec,
declared: self.checksum_sha256.clone(),
actual,
});
}
Ok(abs_exec)
}
}
fn name_from_filename(path: &Path) -> Result<String, ManifestError> {
let filename =
path.file_name()
.and_then(|s| s.to_str())
.ok_or_else(|| ManifestError::BadFilename {
filename: path.display().to_string(),
})?;
let stripped = filename
.strip_prefix(MANIFEST_PREFIX)
.and_then(|s| s.strip_suffix(MANIFEST_SUFFIX))
.ok_or_else(|| ManifestError::BadFilename {
filename: filename.to_owned(),
})?;
if stripped.is_empty() {
return Err(ManifestError::BadFilename {
filename: filename.to_owned(),
});
}
Ok(stripped.to_owned())
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
hex::encode(hasher.finalize())
}
fn checksum_eq(actual: &str, declared: &str) -> bool {
actual.eq_ignore_ascii_case(declared)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiscoveredPlugin {
pub manifest: PluginManifest,
pub executable_path: PathBuf,
pub manifest_dir: PathBuf,
}
#[derive(Debug)]
pub enum DiscoveryOutcome {
Ok(DiscoveredPlugin),
Err {
manifest_path: PathBuf,
error: ManifestError,
},
}
pub fn discover_plugins(dir: &Path) -> Vec<DiscoveryOutcome> {
let Ok(read) = fs::read_dir(dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in read.flatten() {
let path = entry.path();
let Some(filename) = path.file_name().and_then(|s| s.to_str()) else {
continue;
};
if !filename.starts_with(MANIFEST_PREFIX) || !filename.ends_with(MANIFEST_SUFFIX) {
continue;
}
let manifest_dir = path.parent().unwrap_or(dir).to_path_buf();
match PluginManifest::load_from(&path) {
Ok(manifest) => match manifest.verify_executable(&manifest_dir) {
Ok(exec) => out.push(DiscoveryOutcome::Ok(DiscoveredPlugin {
manifest,
executable_path: exec,
manifest_dir,
})),
Err(e) => out.push(DiscoveryOutcome::Err {
manifest_path: path,
error: e,
}),
},
Err(e) => out.push(DiscoveryOutcome::Err {
manifest_path: path,
error: e,
}),
}
}
out
}
pub fn discover_plugins_default() -> Vec<DiscoveryOutcome> {
match default_discovery_dir() {
Some(dir) => discover_plugins(&dir),
None => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn write_plugin(dir: &Path, name: &str, body: &[u8]) -> (PathBuf, PathBuf) {
let exec_path = dir.join(format!("devboy-source-{name}"));
let mut f = File::create(&exec_path).unwrap();
f.write_all(body).unwrap();
let checksum = sha256_hex(body);
let manifest_path = dir.join(format!("devboy-source-{name}.toml"));
let manifest = format!(
r#"
name = "{name}"
version = "0.1.0"
executable = "devboy-source-{name}"
allowed_env_vars = ["HOME", "PATH"]
checksum_sha256 = "{checksum}"
"#,
);
fs::write(&manifest_path, manifest).unwrap();
(manifest_path, exec_path)
}
#[test]
fn manifest_parses_minimal_fields() {
let dir = TempDir::new().unwrap();
let (manifest_path, _) = write_plugin(dir.path(), "doppler", b"fake-binary");
let m = PluginManifest::load_from(&manifest_path).unwrap();
assert_eq!(m.name, "doppler");
assert_eq!(m.version, "0.1.0");
assert_eq!(m.allowed_env_vars, vec!["HOME", "PATH"]);
}
#[test]
fn manifest_rejects_name_filename_mismatch() {
let dir = TempDir::new().unwrap();
let manifest_path = dir.path().join("devboy-source-doppler.toml");
fs::write(
&manifest_path,
r#"
name = "vault"
version = "0.1.0"
executable = "x"
checksum_sha256 = "00"
"#,
)
.unwrap();
let err = PluginManifest::load_from(&manifest_path).unwrap_err();
assert!(matches!(err, ManifestError::NameMismatch { .. }));
}
#[test]
fn manifest_rejects_filename_without_proper_prefix() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("not-a-plugin.toml");
fs::write(
&p,
"name=\"x\"\nversion=\"0\"\nexecutable=\"x\"\nchecksum_sha256=\"00\"",
)
.unwrap();
let err = PluginManifest::load_from(&p).unwrap_err();
assert!(matches!(err, ManifestError::BadFilename { .. }));
}
#[test]
fn manifest_rejects_unknown_fields() {
let dir = TempDir::new().unwrap();
let p = dir.path().join("devboy-source-x.toml");
fs::write(
&p,
r#"
name = "x"
version = "0"
executable = "y"
checksum_sha256 = "00"
unknown_field = true
"#,
)
.unwrap();
let err = PluginManifest::load_from(&p).unwrap_err();
assert!(matches!(err, ManifestError::Parse { .. }));
}
#[test]
fn verify_executable_passes_for_matching_checksum() {
let dir = TempDir::new().unwrap();
let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
let m = PluginManifest::load_from(&manifest_path).unwrap();
let resolved = m.verify_executable(dir.path()).unwrap();
assert_eq!(resolved, exec_path);
}
#[test]
fn verify_executable_fails_when_bytes_change() {
let dir = TempDir::new().unwrap();
let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
fs::write(&exec_path, b"goodbye-world").unwrap();
let m = PluginManifest::load_from(&manifest_path).unwrap();
let err = m.verify_executable(dir.path()).unwrap_err();
assert!(matches!(err, ManifestError::ChecksumMismatch { .. }));
}
#[test]
fn verify_executable_fails_when_binary_missing() {
let dir = TempDir::new().unwrap();
let (manifest_path, exec_path) = write_plugin(dir.path(), "doppler", b"hello-world");
fs::remove_file(&exec_path).unwrap();
let m = PluginManifest::load_from(&manifest_path).unwrap();
let err = m.verify_executable(dir.path()).unwrap_err();
assert!(matches!(err, ManifestError::ExecutableMissing { .. }));
}
#[test]
fn checksum_comparison_is_case_insensitive() {
let a = "ABCDEF";
let b = "abcdef";
assert!(checksum_eq(a, b));
}
#[test]
fn discovery_returns_each_valid_plugin() {
let dir = TempDir::new().unwrap();
let _ = write_plugin(dir.path(), "doppler", b"a");
let _ = write_plugin(dir.path(), "vault", b"b");
let outcomes = discover_plugins(dir.path());
let oks: Vec<&DiscoveredPlugin> = outcomes
.iter()
.filter_map(|o| match o {
DiscoveryOutcome::Ok(p) => Some(p),
_ => None,
})
.collect();
assert_eq!(oks.len(), 2);
}
#[test]
fn discovery_isolates_per_manifest_errors() {
let dir = TempDir::new().unwrap();
let _ = write_plugin(dir.path(), "good", b"a");
let (_, bad_exec) = write_plugin(dir.path(), "bad", b"a");
fs::write(&bad_exec, b"tampered").unwrap();
let outcomes = discover_plugins(dir.path());
let oks = outcomes
.iter()
.filter(|o| matches!(o, DiscoveryOutcome::Ok(_)))
.count();
let errs = outcomes
.iter()
.filter(|o| matches!(o, DiscoveryOutcome::Err { .. }))
.count();
assert_eq!(oks, 1);
assert_eq!(errs, 1);
}
#[test]
fn discovery_ignores_unrelated_files() {
let dir = TempDir::new().unwrap();
let _ = write_plugin(dir.path(), "doppler", b"a");
fs::write(dir.path().join("README.md"), b"unrelated").unwrap();
fs::write(dir.path().join("notes.txt"), b"unrelated").unwrap();
let outcomes = discover_plugins(dir.path());
assert_eq!(outcomes.len(), 1);
}
#[test]
fn discovery_returns_empty_for_missing_dir() {
let dir = TempDir::new().unwrap();
let nonexistent = dir.path().join("nope");
let outcomes = discover_plugins(&nonexistent);
assert!(outcomes.is_empty());
}
#[test]
fn default_discovery_dir_resolves_under_dot_devboy() {
let dir = default_discovery_dir().expect("home dir resolvable in test env");
let suffix: PathBuf = [".devboy", "plugins", "secrets"].iter().collect();
assert!(
dir.ends_with(&suffix),
"expected default dir to end with {}, got {}",
suffix.display(),
dir.display()
);
}
}