1use crate::errors::BridgeError;
4use r2x_logger as logger;
5use r2x_manifest::runtime::{build_runtime_bindings, PluginRole, RuntimeBindings};
6use r2x_manifest::types::Plugin;
7use std::fs;
8use std::io::ErrorKind;
9use std::path::{Component, Path, PathBuf};
10use std::time::Duration;
11
12#[derive(Clone, Debug, PartialEq, Eq)]
17pub struct ArtifactBundle {
18 root: PathBuf,
19 entrypoint: PathBuf,
20}
21
22impl ArtifactBundle {
23 pub fn new(
25 root: impl Into<PathBuf>,
26 entrypoint: impl Into<PathBuf>,
27 ) -> Result<Self, BridgeError> {
28 let entrypoint = entrypoint.into();
29 validate_relative_entrypoint(&entrypoint)?;
30 Ok(Self {
31 root: root.into(),
32 entrypoint,
33 })
34 }
35
36 pub fn root(&self) -> &Path {
38 &self.root
39 }
40
41 pub fn relative_entrypoint(&self) -> &Path {
43 &self.entrypoint
44 }
45
46 pub fn entrypoint_path(&self) -> PathBuf {
48 self.root.join(&self.entrypoint)
49 }
50}
51
52fn validate_relative_entrypoint(entrypoint: &Path) -> Result<(), BridgeError> {
53 if entrypoint.as_os_str().is_empty() {
54 return Err(BridgeError::InvalidArtifact(
55 "entrypoint cannot be empty".to_string(),
56 ));
57 }
58
59 let mut has_file_name = false;
60 for component in entrypoint.components() {
61 match component {
62 Component::Normal(_) => has_file_name = true,
63 Component::CurDir => {
64 return Err(BridgeError::InvalidArtifact(format!(
65 "entrypoint cannot contain '.' components: {}",
66 entrypoint.display()
67 )));
68 }
69 Component::ParentDir => {
70 return Err(BridgeError::InvalidArtifact(format!(
71 "entrypoint cannot traverse its bundle root: {}",
72 entrypoint.display()
73 )));
74 }
75 Component::RootDir | Component::Prefix(_) => {
76 return Err(BridgeError::InvalidArtifact(format!(
77 "entrypoint must be relative to its bundle root: {}",
78 entrypoint.display()
79 )));
80 }
81 }
82 }
83 if !has_file_name {
84 return Err(BridgeError::InvalidArtifact(
85 "entrypoint must name a file within its bundle".to_string(),
86 ));
87 }
88 Ok(())
89}
90
91#[derive(Clone, Copy, Debug, PartialEq, Eq)]
93pub enum ArtifactOutputKind {
94 System,
96 Json,
98 Empty,
100}
101
102#[derive(Debug)]
104pub struct PluginArtifactInvocationResult {
105 pub output_kind: ArtifactOutputKind,
107 pub timings: Option<PluginInvocationTimings>,
109}
110
111#[derive(Debug)]
113pub struct PluginInvocationTimings {
114 pub python_invocation: Duration,
115 pub serialization: Duration,
116}
117
118pub struct PluginInvocationResult {
120 pub output: String,
122 pub timings: Option<PluginInvocationTimings>,
124}
125
126impl crate::python_bridge::Bridge {
127 pub fn invoke_plugin(
128 &self,
129 target: &str,
130 config_json: &str,
131 stdin_json: Option<&str>,
132 plugin_metadata: Option<&Plugin>,
133 ) -> Result<PluginInvocationResult, BridgeError> {
134 let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
135
136 if let Some(bindings) = runtime_bindings.as_ref() {
137 if bindings.role == PluginRole::Upgrader {
138 logger::debug("Routing to upgrader plugin handler");
139 return self.invoke_upgrader_plugin(
140 target,
141 config_json,
142 Some(bindings),
143 plugin_metadata,
144 );
145 }
146 }
147
148 self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings.as_ref())
149 }
150
151 pub fn invoke_plugin_with_bindings(
152 &self,
153 target: &str,
154 config_json: &str,
155 stdin_json: Option<&str>,
156 runtime_bindings: Option<&RuntimeBindings>,
157 ) -> Result<PluginInvocationResult, BridgeError> {
158 if let Some(bindings) = runtime_bindings {
159 if bindings.role == PluginRole::Upgrader {
160 logger::debug("Routing to upgrader plugin handler (runtime bindings)");
161 return self.invoke_upgrader_plugin(target, config_json, Some(bindings), None);
162 }
163 }
164
165 self.invoke_plugin_regular(target, config_json, stdin_json, runtime_bindings)
166 }
167
168 pub fn save_system_artifact_as_zip(
170 &self,
171 input: &ArtifactBundle,
172 output: &Path,
173 ) -> Result<(), BridgeError> {
174 Self::save_system_artifact_as_zip_native(input, output)
175 }
176
177 pub fn invoke_plugin_with_artifacts(
183 &self,
184 target: &str,
185 config_json: &str,
186 input: Option<&ArtifactBundle>,
187 output: &ArtifactBundle,
188 plugin_metadata: Option<&Plugin>,
189 ) -> Result<PluginArtifactInvocationResult, BridgeError> {
190 let runtime_bindings = plugin_metadata.map(build_runtime_bindings);
191 self.invoke_plugin_with_artifact_bindings(
192 target,
193 config_json,
194 input,
195 output,
196 runtime_bindings.as_ref(),
197 )
198 }
199
200 pub fn invoke_plugin_with_artifact_bindings(
202 &self,
203 target: &str,
204 config_json: &str,
205 input: Option<&ArtifactBundle>,
206 output: &ArtifactBundle,
207 runtime_bindings: Option<&RuntimeBindings>,
208 ) -> Result<PluginArtifactInvocationResult, BridgeError> {
209 if runtime_bindings.is_some_and(|bindings| bindings.role == PluginRole::Upgrader) {
210 return Err(BridgeError::UnsupportedArtifactMode(
211 "upgrader plugins are not yet supported because registered SYSTEM steps still serialize payloads through Rust".to_string(),
212 ));
213 }
214
215 validate_output_bundle(input, output)?;
216
217 self.invoke_plugin_regular_with_artifacts(
218 target,
219 config_json,
220 input,
221 output,
222 runtime_bindings,
223 )
224 }
225}
226
227fn validate_output_bundle(
228 input: Option<&ArtifactBundle>,
229 output: &ArtifactBundle,
230) -> Result<(), BridgeError> {
231 if input.is_some_and(|input| input.root() == output.root()) {
232 return Err(BridgeError::InvalidArtifact(
233 "input and output bundles must use different roots".to_string(),
234 ));
235 }
236
237 let metadata = match fs::symlink_metadata(output.root()) {
238 Ok(metadata) => metadata,
239 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
240 Err(error) => return Err(error.into()),
241 };
242 if metadata.file_type().is_symlink() || !metadata.is_dir() {
243 return Err(BridgeError::InvalidArtifact(format!(
244 "output bundle root must be a directory: {}",
245 output.root().display()
246 )));
247 }
248 if fs::read_dir(output.root())?.next().transpose()?.is_some() {
249 return Err(BridgeError::InvalidArtifact(format!(
250 "output bundle root must be empty: {}",
251 output.root().display()
252 )));
253 }
254
255 Ok(())
256}
257
258#[cfg(test)]
259mod tests {
260 use crate::plugin_invoker::*;
261 use crate::python_bridge::Bridge;
262 use r2x_manifest::runtime::{PluginRole, RuntimeBindings};
263 use r2x_manifest::types::PluginType;
264 use std::error::Error;
265 use tempfile::tempdir;
266
267 #[test]
268 fn plugin_invocation_result_basics() {
269 let result = PluginInvocationResult {
270 output: String::new(),
271 timings: None,
272 };
273 assert!(result.output.is_empty());
274 }
275
276 #[test]
277 fn artifact_bundle_rejects_absolute_and_traversing_entrypoints() {
278 let absolute = ArtifactBundle::new("bundle", "/tmp/system.json");
279 assert!(absolute.is_err());
280
281 let traversal = ArtifactBundle::new("bundle", "../system.json");
282 assert!(traversal.is_err());
283
284 let bundle = ArtifactBundle::new("bundle", "nested/system.json");
285 assert!(bundle.is_ok());
286
287 let directory = ArtifactBundle::new("bundle", ".");
288 assert!(directory.is_err());
289
290 let current_directory = ArtifactBundle::new("bundle", "./system.json");
291 assert!(current_directory.is_err());
292 }
293
294 #[test]
295 fn artifact_mode_rejects_upgraders_until_their_payload_path_is_native(
296 ) -> Result<(), BridgeError> {
297 let bridge = Bridge::for_tests();
298 let output = ArtifactBundle::new("bundle", "system.json")?;
299 let bindings = RuntimeBindings {
300 entry_module: "plugin".to_string(),
301 entry_name: "Upgrader".to_string(),
302 plugin_type: PluginType::Class,
303 role: PluginRole::Upgrader,
304 call_method: Some("run".to_string()),
305 config: None,
306 parameters: Vec::new(),
307 requires_store: false,
308 };
309
310 let error = bridge.invoke_plugin_with_artifact_bindings(
311 "plugin:Upgrader",
312 "{}",
313 None,
314 &output,
315 Some(&bindings),
316 );
317 assert!(matches!(
318 error,
319 Err(BridgeError::UnsupportedArtifactMode(_))
320 ));
321 Ok(())
322 }
323
324 #[test]
325 fn artifact_mode_rejects_nonempty_output_bundles() -> Result<(), Box<dyn Error>> {
326 let temp = tempdir()?;
327 let output_root = temp.path().join("output");
328 std::fs::create_dir_all(&output_root)?;
329 std::fs::write(output_root.join("stale.h5"), "stale")?;
330 let output = ArtifactBundle::new(&output_root, "system.json")?;
331
332 let result = Bridge::for_tests().invoke_plugin_with_artifact_bindings(
333 "missing:plugin",
334 "{}",
335 None,
336 &output,
337 None,
338 );
339
340 assert!(matches!(result, Err(BridgeError::InvalidArtifact(_))));
341 assert!(output_root.join("stale.h5").exists());
342 Ok(())
343 }
344}