use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PluginSourceType {
File,
Http,
Oci,
}
#[derive(Debug, Clone)]
pub struct FetchedPlugin {
pub path: PathBuf,
pub filename: String,
pub source_type: PluginSourceType,
pub source_uri: String,
}
pub fn parse_source_type(reference: &str) -> PluginSourceType {
if reference.starts_with("file://") {
PluginSourceType::File
} else if reference.starts_with("http://") || reference.starts_with("https://") {
PluginSourceType::Http
} else {
PluginSourceType::Oci
}
}
pub async fn fetch_from_file(uri: &str, dest_dir: &Path) -> Result<FetchedPlugin> {
let path_str = uri.strip_prefix("file://").context("Invalid file:// URI")?;
let source_path = PathBuf::from(path_str);
if !source_path.exists() {
bail!("Plugin file not found: {}", source_path.display());
}
if !source_path.is_file() {
bail!("Not a file: {}", source_path.display());
}
let filename = source_path
.file_name()
.context("Cannot determine filename")?
.to_string_lossy()
.to_string();
tokio::fs::create_dir_all(dest_dir).await?;
let dest_path = dest_dir.join(&filename);
tokio::fs::copy(&source_path, &dest_path)
.await
.with_context(|| {
format!(
"Failed to copy {} to {}",
source_path.display(),
dest_path.display()
)
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
tokio::fs::set_permissions(&dest_path, perms).await?;
}
let metadata_source = source_path.with_extension("metadata.json");
if metadata_source.exists() {
let metadata_dest = dest_path.with_extension("metadata.json");
let _ = tokio::fs::copy(&metadata_source, &metadata_dest).await;
}
log::info!("Copied plugin from {} to {}", uri, dest_path.display());
Ok(FetchedPlugin {
path: dest_path,
filename,
source_type: PluginSourceType::File,
source_uri: uri.to_string(),
})
}
pub async fn fetch_from_http(url: &str, dest_dir: &Path) -> Result<FetchedPlugin> {
let parsed = url::Url::parse(url).context("Invalid HTTP URL")?;
let filename = parsed
.path_segments()
.and_then(|mut s| s.next_back())
.filter(|s| !s.is_empty())
.context("Cannot determine filename from URL")?
.to_string();
tokio::fs::create_dir_all(dest_dir).await?;
let dest_path = dest_dir.join(&filename);
log::info!("Downloading plugin from {}...", url);
let response = reqwest::get(url)
.await
.with_context(|| format!("HTTP request failed for {url}"))?;
if !response.status().is_success() {
bail!(
"HTTP {} for {}: {}",
response.status().as_u16(),
url,
response.status().canonical_reason().unwrap_or("Unknown")
);
}
let bytes = response
.bytes()
.await
.context("Failed to read response body")?;
tokio::fs::write(&dest_path, &bytes)
.await
.with_context(|| format!("Failed to write to {}", dest_path.display()))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o755);
tokio::fs::set_permissions(&dest_path, perms).await?;
}
let size_mb = bytes.len() as f64 / (1024.0 * 1024.0);
log::info!(
"Downloaded plugin ({:.1} MB) to {}",
size_mb,
dest_path.display()
);
Ok(FetchedPlugin {
path: dest_path,
filename,
source_type: PluginSourceType::Http,
source_uri: url.to_string(),
})
}
#[derive(Debug, Clone, Default)]
pub struct PluginBinaryMetadata {
pub plugin_version: String,
pub sdk_version: String,
pub core_version: String,
pub lib_version: String,
pub target_triple: String,
pub git_commit: String,
pub build_timestamp: String,
}
pub fn read_plugin_metadata(path: &Path) -> Option<PluginBinaryMetadata> {
use drasi_plugin_sdk::ffi::metadata::PluginMetadata;
let lib = match unsafe { libloading::Library::new(path) } {
Ok(lib) => lib,
Err(e) => {
log::warn!("Could not load plugin to read metadata: {e}");
return None;
}
};
let meta_fn = match unsafe {
lib.get::<unsafe extern "C" fn() -> *const PluginMetadata>(b"drasi_plugin_metadata")
} {
Ok(f) => f,
Err(_) => {
log::warn!("Plugin does not export drasi_plugin_metadata");
return None;
}
};
let meta_ptr = unsafe { meta_fn() };
if meta_ptr.is_null() {
log::warn!("drasi_plugin_metadata returned null");
return None;
}
let meta = unsafe { &*meta_ptr };
Some(PluginBinaryMetadata {
plugin_version: unsafe { meta.plugin_version.to_string() },
sdk_version: unsafe { meta.sdk_version.to_string() },
core_version: unsafe { meta.core_version.to_string() },
lib_version: unsafe { meta.lib_version.to_string() },
target_triple: unsafe { meta.target_triple.to_string() },
git_commit: unsafe { meta.git_commit.to_string() },
build_timestamp: unsafe { meta.build_timestamp.to_string() },
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_source_type_file() {
assert_eq!(
parse_source_type("file:///path/to/plugin.so"),
PluginSourceType::File
);
}
#[test]
fn test_parse_source_type_http() {
assert_eq!(
parse_source_type("https://example.com/plugin.so"),
PluginSourceType::Http
);
assert_eq!(
parse_source_type("http://example.com/plugin.so"),
PluginSourceType::Http
);
}
#[test]
fn test_parse_source_type_oci() {
assert_eq!(
parse_source_type("source/postgres:0.1.8"),
PluginSourceType::Oci
);
assert_eq!(
parse_source_type("ghcr.io/drasi-project/source/postgres:0.1.8"),
PluginSourceType::Oci
);
assert_eq!(parse_source_type("source/postgres"), PluginSourceType::Oci);
}
#[tokio::test]
async fn test_fetch_from_file_not_found() {
let dir = tempfile::tempdir().unwrap();
let result = fetch_from_file("file:///nonexistent/plugin.so", dir.path()).await;
assert!(result.is_err());
assert!(
result.unwrap_err().to_string().contains("not found"),
"Should report file not found"
);
}
#[tokio::test]
async fn test_fetch_from_file_success() {
let src_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let src_file = src_dir.path().join("libdrasi_source_test.so");
tokio::fs::write(&src_file, b"fake-plugin-binary")
.await
.unwrap();
let uri = format!("file://{}", src_file.display());
let result = fetch_from_file(&uri, dest_dir.path()).await.unwrap();
assert_eq!(result.filename, "libdrasi_source_test.so");
assert_eq!(result.source_type, PluginSourceType::File);
assert!(result.path.exists());
assert_eq!(
tokio::fs::read(&result.path).await.unwrap(),
b"fake-plugin-binary"
);
}
#[tokio::test]
async fn test_fetch_from_file_copies_metadata_json() {
let src_dir = tempfile::tempdir().unwrap();
let dest_dir = tempfile::tempdir().unwrap();
let src_file = src_dir.path().join("libdrasi_source_test.so");
tokio::fs::write(&src_file, b"fake-plugin").await.unwrap();
let meta_file = src_dir.path().join("libdrasi_source_test.metadata.json");
tokio::fs::write(&meta_file, b"{\"kind\":\"test\"}")
.await
.unwrap();
let uri = format!("file://{}", src_file.display());
let result = fetch_from_file(&uri, dest_dir.path()).await.unwrap();
let dest_meta = dest_dir.path().join("libdrasi_source_test.metadata.json");
assert!(dest_meta.exists(), "Sidecar metadata.json should be copied");
}
#[tokio::test]
async fn test_fetch_from_http_invalid_url() {
let dir = tempfile::tempdir().unwrap();
let result = fetch_from_http("not-a-url", dir.path()).await;
assert!(result.is_err());
}
}