use crate::errors::BridgeError;
use r2x_logger as logger;
use r2x_manifest::runtime::{build_runtime_bindings, PluginRole, RuntimeBindings};
use r2x_manifest::types::Plugin;
use std::fs;
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};
use std::time::Duration;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ArtifactBundle {
root: PathBuf,
entrypoint: PathBuf,
}
impl ArtifactBundle {
pub fn new(
root: impl Into<PathBuf>,
entrypoint: impl Into<PathBuf>,
) -> Result<Self, BridgeError> {
let entrypoint = entrypoint.into();
validate_relative_entrypoint(&entrypoint)?;
Ok(Self {
root: root.into(),
entrypoint,
})
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn relative_entrypoint(&self) -> &Path {
&self.entrypoint
}
pub fn entrypoint_path(&self) -> PathBuf {
self.root.join(&self.entrypoint)
}
}
fn validate_relative_entrypoint(entrypoint: &Path) -> Result<(), BridgeError> {
if entrypoint.as_os_str().is_empty() {
return Err(BridgeError::InvalidArtifact(
"entrypoint cannot be empty".to_string(),
));
}
let mut has_file_name = false;
for component in entrypoint.components() {
match component {
Component::Normal(_) => has_file_name = true,
Component::CurDir => {
return Err(BridgeError::InvalidArtifact(format!(
"entrypoint cannot contain '.' components: {}",
entrypoint.display()
)));
}
Component::ParentDir => {
return Err(BridgeError::InvalidArtifact(format!(
"entrypoint cannot traverse its bundle root: {}",
entrypoint.display()
)));
}
Component::RootDir | Component::Prefix(_) => {
return Err(BridgeError::InvalidArtifact(format!(
"entrypoint must be relative to its bundle root: {}",
entrypoint.display()
)));
}
}
}
if !has_file_name {
return Err(BridgeError::InvalidArtifact(
"entrypoint must name a file within its bundle".to_string(),
));
}
Ok(())
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ArtifactOutputKind {
System,
Json,
Empty,
}
#[derive(Debug)]
pub struct PluginArtifactInvocationResult {
pub output_kind: ArtifactOutputKind,
pub timings: Option<PluginInvocationTimings>,
}
#[derive(Debug)]
pub struct PluginInvocationTimings {
pub python_invocation: Duration,
pub serialization: Duration,
}
pub struct PluginInvocationResult {
pub output: String,
pub timings: Option<PluginInvocationTimings>,
}
impl crate::python_bridge::Bridge {
pub fn invoke_plugin(
&self,
target: &str,
config_json: &str,
stdin_json: Option<&str>,
plugin_metadata: Option<&Plugin>,
) -> Result<PluginInvocationResult, BridgeError> {
let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
if let Some(bindings) = runtime_bindings.as_ref() {
if bindings.role == PluginRole::Upgrader {
logger::debug("Routing to upgrader plugin handler");
return self.invoke_upgrader_plugin(
target,
config_json,
Some(bindings),
plugin_metadata,
);
}
}
self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings.as_ref())
}
pub fn invoke_plugin_with_bindings(
&self,
target: &str,
config_json: &str,
stdin_json: Option<&str>,
runtime_bindings: Option<&RuntimeBindings>,
) -> Result<PluginInvocationResult, BridgeError> {
if let Some(bindings) = runtime_bindings {
if bindings.role == PluginRole::Upgrader {
logger::debug("Routing to upgrader plugin handler (runtime bindings)");
return self.invoke_upgrader_plugin(target, config_json, Some(bindings), None);
}
}
self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings)
}
pub fn save_system_artifact_as_zip(
&self,
input: &ArtifactBundle,
output: &Path,
) -> Result<(), BridgeError> {
Self::save_system_artifact_as_zip_native(input, output)
}
pub fn invoke_plugin_with_artifacts(
&self,
target: &str,
config_json: &str,
input: Option<&ArtifactBundle>,
output: &ArtifactBundle,
plugin_metadata: Option<&Plugin>,
) -> Result<PluginArtifactInvocationResult, BridgeError> {
let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
self.invoke_plugin_with_artifact_bindings(
target,
config_json,
input,
output,
runtime_bindings.as_ref(),
)
}
pub fn invoke_plugin_with_artifact_bindings(
&self,
target: &str,
config_json: &str,
input: Option<&ArtifactBundle>,
output: &ArtifactBundle,
runtime_bindings: Option<&RuntimeBindings>,
) -> Result<PluginArtifactInvocationResult, BridgeError> {
if runtime_bindings.is_some_and(|bindings| bindings.role == PluginRole::Upgrader) {
return Err(BridgeError::UnsupportedArtifactMode(
"upgrader plugins are not yet supported because registered SYSTEM steps still serialize payloads through Rust".to_string(),
));
}
validate_output_bundle(input, output)?;
self.invoke_plugin_regular_with_artifacts(
target,
config_json,
input,
output,
runtime_bindings,
)
}
}
fn validate_output_bundle(
input: Option<&ArtifactBundle>,
output: &ArtifactBundle,
) -> Result<(), BridgeError> {
if input.is_some_and(|input| input.root() == output.root()) {
return Err(BridgeError::InvalidArtifact(
"input and output bundles must use different roots".to_string(),
));
}
let metadata = match fs::symlink_metadata(output.root()) {
Ok(metadata) => metadata,
Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(error.into()),
};
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(BridgeError::InvalidArtifact(format!(
"output bundle root must be a directory: {}",
output.root().display()
)));
}
if fs::read_dir(output.root())?.next().transpose()?.is_some() {
return Err(BridgeError::InvalidArtifact(format!(
"output bundle root must be empty: {}",
output.root().display()
)));
}
Ok(())
}
#[cfg(test)]
mod tests {
use crate::plugin_invoker::*;
use crate::python_bridge::Bridge;
use r2x_manifest::runtime::{PluginRole, RuntimeBindings};
use r2x_manifest::types::PluginType;
use std::error::Error;
use tempfile::tempdir;
#[test]
fn plugin_invocation_result_basics() {
let result = PluginInvocationResult {
output: String::new(),
timings: None,
};
assert!(result.output.is_empty());
}
#[test]
fn artifact_bundle_rejects_absolute_and_traversing_entrypoints() {
let absolute = ArtifactBundle::new("bundle", "/tmp/system.json");
assert!(absolute.is_err());
let traversal = ArtifactBundle::new("bundle", "../system.json");
assert!(traversal.is_err());
let bundle = ArtifactBundle::new("bundle", "nested/system.json");
assert!(bundle.is_ok());
let directory = ArtifactBundle::new("bundle", ".");
assert!(directory.is_err());
let current_directory = ArtifactBundle::new("bundle", "./system.json");
assert!(current_directory.is_err());
}
#[test]
fn artifact_mode_rejects_upgraders_until_their_payload_path_is_native(
) -> Result<(), BridgeError> {
let bridge = Bridge::for_tests();
let output = ArtifactBundle::new("bundle", "system.json")?;
let bindings = RuntimeBindings {
entry_module: "plugin".to_string(),
entry_name: "Upgrader".to_string(),
plugin_type: PluginType::Class,
role: PluginRole::Upgrader,
call_method: Some("run".to_string()),
config: None,
parameters: Vec::new(),
requires_store: false,
};
let error = bridge.invoke_plugin_with_artifact_bindings(
"plugin:Upgrader",
"{}",
None,
&output,
Some(&bindings),
);
assert!(matches!(
error,
Err(BridgeError::UnsupportedArtifactMode(_))
));
Ok(())
}
#[test]
fn artifact_mode_rejects_nonempty_output_bundles() -> Result<(), Box<dyn Error>> {
let temp = tempdir()?;
let output_root = temp.path().join("output");
std::fs::create_dir_all(&output_root)?;
std::fs::write(output_root.join("stale.h5"), "stale")?;
let output = ArtifactBundle::new(&output_root, "system.json")?;
let result = Bridge::for_tests().invoke_plugin_with_artifact_bindings(
"missing:plugin",
"{}",
None,
&output,
None,
);
assert!(matches!(result, Err(BridgeError::InvalidArtifact(_))));
assert!(output_root.join("stale.h5").exists());
Ok(())
}
}