use anyhow::{bail, Context, Result};
use log::{debug, info};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::Path;
const LOCKFILE_NAME: &str = "plugins.lock";
const LOCKFILE_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginLockfile {
pub version: u32,
#[serde(default)]
pub plugins: BTreeMap<String, LockedPlugin>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LockedPlugin {
pub reference: String,
pub version: String,
pub digest: String,
pub sdk_version: String,
pub core_version: String,
pub lib_version: String,
pub platform: String,
pub filename: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file_hash: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub git_commit: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_timestamp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<PluginSignatureInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PluginSignatureInfo {
pub verified: bool,
pub issuer: String,
pub subject: String,
}
impl PluginLockfile {
pub fn new() -> Self {
Self {
version: LOCKFILE_VERSION,
plugins: BTreeMap::new(),
}
}
pub fn read(dir: &Path) -> Result<Option<Self>> {
let path = dir.join(LOCKFILE_NAME);
if !path.exists() {
return Ok(None);
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let lockfile: Self = toml::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
if lockfile.version != LOCKFILE_VERSION {
bail!(
"unsupported lockfile version {} (expected {})",
lockfile.version,
LOCKFILE_VERSION
);
}
debug!("Read lockfile with {} entries", lockfile.plugins.len());
Ok(Some(lockfile))
}
pub fn write(&self, dir: &Path) -> Result<()> {
let path = dir.join(LOCKFILE_NAME);
let content = toml::to_string_pretty(self).context("failed to serialize lockfile")?;
let tmp_path = dir.join(".plugins.lock.tmp");
std::fs::write(&tmp_path, &content)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
std::fs::rename(&tmp_path, &path)
.with_context(|| format!("failed to rename lockfile to {}", path.display()))?;
info!("Updated {}", path.display());
Ok(())
}
pub fn get(&self, reference: &str) -> Option<&LockedPlugin> {
self.plugins.get(reference)
}
pub fn insert(&mut self, reference: String, entry: LockedPlugin) {
let conflicting_key = self
.plugins
.iter()
.find(|(k, v)| v.filename == entry.filename && *k != &reference)
.map(|(k, _)| k.clone());
if let Some(old_key) = conflicting_key {
log::debug!(
"Replacing lockfile entry '{}' (same filename '{}')",
old_key,
entry.filename
);
self.plugins.remove(&old_key);
}
self.plugins.insert(reference, entry);
}
pub fn remove(&mut self, reference: &str) -> Option<LockedPlugin> {
self.plugins.remove(reference)
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &LockedPlugin)> {
self.plugins.iter()
}
pub fn keys(&self) -> impl Iterator<Item = &String> {
self.plugins.keys()
}
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
pub fn verify_file_integrity(
&self,
plugins_dir: &Path,
) -> std::collections::HashMap<String, FileIntegrityStatus> {
let mut results = std::collections::HashMap::new();
for entry in self.plugins.values() {
let file_path = plugins_dir.join(&entry.filename);
let status = if !file_path.exists() {
FileIntegrityStatus::Missing
} else if let Some(expected_hash) = &entry.file_hash {
match compute_file_hash(&file_path) {
Ok(actual_hash) if actual_hash == *expected_hash => FileIntegrityStatus::Ok,
Ok(actual_hash) => FileIntegrityStatus::Tampered {
expected: expected_hash.clone(),
actual: actual_hash,
},
Err(e) => FileIntegrityStatus::Error(format!("{e}")),
}
} else {
FileIntegrityStatus::NoHash
};
results.insert(entry.filename.clone(), status);
}
results
}
}
#[derive(Debug, Clone, PartialEq)]
pub enum FileIntegrityStatus {
Ok,
Tampered { expected: String, actual: String },
Missing,
NoHash,
Error(String),
}
pub fn compute_file_hash(path: &Path) -> Result<String> {
use sha2::{Digest, Sha256};
let data = std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?;
let hash = Sha256::digest(&data);
Ok(format!("{hash:x}"))
}
impl Default for PluginLockfile {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn sample_entry() -> LockedPlugin {
LockedPlugin {
reference: "ghcr.io/drasi-project/source/postgres@sha256:abc123".to_string(),
version: "0.1.8".to_string(),
digest: "sha256:abc123".to_string(),
sdk_version: "0.3.1".to_string(),
core_version: "0.3.3".to_string(),
lib_version: "0.3.8".to_string(),
platform: "linux/amd64".to_string(),
filename: "libdrasi_source_postgres.so".to_string(),
file_hash: None,
git_commit: None,
build_timestamp: None,
signature: None,
}
}
#[test]
fn test_lockfile_roundtrip() {
let dir = TempDir::new().unwrap();
let mut lockfile = PluginLockfile::new();
lockfile.insert("source/postgres".to_string(), sample_entry());
lockfile.insert(
"reaction/log".to_string(),
LockedPlugin {
reference: "ghcr.io/drasi-project/reaction/log@sha256:def456".to_string(),
version: "0.1.7".to_string(),
digest: "sha256:def456".to_string(),
sdk_version: "0.3.1".to_string(),
core_version: "0.3.3".to_string(),
lib_version: "0.3.8".to_string(),
platform: "linux/amd64".to_string(),
filename: "libdrasi_reaction_log.so".to_string(),
file_hash: None,
git_commit: None,
build_timestamp: None,
signature: None,
},
);
lockfile.write(dir.path()).unwrap();
let loaded = PluginLockfile::read(dir.path()).unwrap().unwrap();
assert_eq!(loaded.version, 1);
assert_eq!(loaded.plugins.len(), 2);
assert_eq!(loaded.get("source/postgres").unwrap(), &sample_entry());
}
#[test]
fn test_lockfile_missing_returns_none() {
let dir = TempDir::new().unwrap();
assert!(PluginLockfile::read(dir.path()).unwrap().is_none());
}
#[test]
fn test_lockfile_toml_format() {
let mut lockfile = PluginLockfile::new();
lockfile.insert("source/postgres".to_string(), sample_entry());
let content = toml::to_string_pretty(&lockfile).unwrap();
assert!(content.contains("version = 1"));
assert!(content.contains("[plugins.\"source/postgres\"]"));
assert!(content.contains("sha256:abc123"));
}
#[test]
fn test_lockfile_remove() {
let mut lockfile = PluginLockfile::new();
lockfile.insert("source/postgres".to_string(), sample_entry());
assert!(lockfile.get("source/postgres").is_some());
lockfile.remove("source/postgres");
assert!(lockfile.get("source/postgres").is_none());
}
#[test]
fn test_lockfile_deduplicates_by_filename() {
let mut lockfile = PluginLockfile::new();
lockfile.insert(
"file:///path/to/libdrasi_source_postgres.so".to_string(),
LockedPlugin {
reference: "file:///path/to/libdrasi_source_postgres.so".to_string(),
version: "0.1.8".to_string(),
digest: String::new(),
sdk_version: "0.3.1".to_string(),
core_version: "0.3.3".to_string(),
lib_version: "0.3.8".to_string(),
platform: "x86_64-unknown-linux-gnu".to_string(),
filename: "libdrasi_source_postgres.so".to_string(),
file_hash: None,
git_commit: None,
build_timestamp: None,
signature: None,
},
);
assert_eq!(lockfile.plugins.len(), 1);
lockfile.insert("source/postgres".to_string(), sample_entry());
assert_eq!(lockfile.plugins.len(), 1);
assert!(lockfile.get("source/postgres").is_some());
assert!(lockfile
.get("file:///path/to/libdrasi_source_postgres.so")
.is_none());
}
#[test]
fn test_compute_file_hash() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test_plugin.so");
std::fs::write(&file_path, b"hello world").unwrap();
let hash = compute_file_hash(&file_path).unwrap();
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9" );
}
#[test]
fn test_compute_file_hash_missing_file() {
let dir = TempDir::new().unwrap();
let result = compute_file_hash(&dir.path().join("nonexistent.so"));
assert!(result.is_err());
}
#[test]
fn test_verify_file_integrity_ok() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("libdrasi_source_postgres.so");
std::fs::write(&file_path, b"plugin binary content").unwrap();
let hash = compute_file_hash(&file_path).unwrap();
let mut lockfile = PluginLockfile::new();
let mut entry = sample_entry();
entry.file_hash = Some(hash);
lockfile.insert("source/postgres".to_string(), entry);
let results = lockfile.verify_file_integrity(dir.path());
assert_eq!(
results.get("libdrasi_source_postgres.so"),
Some(&FileIntegrityStatus::Ok)
);
}
#[test]
fn test_verify_file_integrity_tampered() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("libdrasi_source_postgres.so");
std::fs::write(&file_path, b"original content").unwrap();
let original_hash = compute_file_hash(&file_path).unwrap();
let mut lockfile = PluginLockfile::new();
let mut entry = sample_entry();
entry.file_hash = Some(original_hash.clone());
lockfile.insert("source/postgres".to_string(), entry);
std::fs::write(&file_path, b"tampered content").unwrap();
let results = lockfile.verify_file_integrity(dir.path());
match results.get("libdrasi_source_postgres.so") {
Some(FileIntegrityStatus::Tampered { expected, actual }) => {
assert_eq!(expected, &original_hash);
assert_ne!(expected, actual);
}
other => panic!("Expected Tampered, got {other:?}"),
}
}
#[test]
fn test_verify_file_integrity_missing() {
let dir = TempDir::new().unwrap();
let mut lockfile = PluginLockfile::new();
let mut entry = sample_entry();
entry.file_hash = Some("abc123".to_string());
lockfile.insert("source/postgres".to_string(), entry);
let results = lockfile.verify_file_integrity(dir.path());
assert_eq!(
results.get("libdrasi_source_postgres.so"),
Some(&FileIntegrityStatus::Missing)
);
}
#[test]
fn test_verify_file_integrity_no_hash() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("libdrasi_source_postgres.so");
std::fs::write(&file_path, b"plugin content").unwrap();
let mut lockfile = PluginLockfile::new();
lockfile.insert("source/postgres".to_string(), sample_entry());
let results = lockfile.verify_file_integrity(dir.path());
assert_eq!(
results.get("libdrasi_source_postgres.so"),
Some(&FileIntegrityStatus::NoHash)
);
}
#[test]
fn test_file_hash_roundtrip_in_lockfile() {
let dir = TempDir::new().unwrap();
let mut lockfile = PluginLockfile::new();
let mut entry = sample_entry();
entry.file_hash = Some("abcdef1234567890".to_string());
lockfile.insert("source/postgres".to_string(), entry);
lockfile.write(dir.path()).unwrap();
let loaded = PluginLockfile::read(dir.path()).unwrap().unwrap();
let loaded_entry = loaded.get("source/postgres").unwrap();
assert_eq!(loaded_entry.file_hash.as_deref(), Some("abcdef1234567890"));
}
#[test]
fn test_file_hash_none_not_serialized() {
let mut lockfile = PluginLockfile::new();
lockfile.insert("source/postgres".to_string(), sample_entry());
let content = toml::to_string_pretty(&lockfile).unwrap();
assert!(!content.contains("file_hash"));
}
#[test]
fn test_file_hash_some_is_serialized() {
let mut lockfile = PluginLockfile::new();
let mut entry = sample_entry();
entry.file_hash = Some("deadbeef".to_string());
lockfile.insert("source/postgres".to_string(), entry);
let content = toml::to_string_pretty(&lockfile).unwrap();
assert!(content.contains("file_hash = \"deadbeef\""));
}
}