1use bnto_core::metadata::InputCardinality;
19use bnto_core::processor::{NodeInput, NodeOutput};
20use bnto_core::{
21 BntoError, NodeCategory, NodeMetadata, NodeProcessor, ProcessContext, ProgressReporter,
22};
23
24pub use crate::args::flatten_conditional_args;
25use crate::args::resolve_input_placeholders;
26use crate::file_mode::process_file_mode;
27use crate::parameters::build_parameters;
28use crate::stdout_mode::process_stdout_mode;
29use crate::validate::{self, DEFAULT_MAX_OUTPUT_MB, DEFAULT_TIMEOUT_SECS};
30
31#[cfg(test)]
33use crate::file_mode::OUTPUT_DIR_PLACEHOLDER;
34#[cfg(test)]
35use crate::output_files::{collect_output_files, mime_from_extension};
36#[cfg(test)]
37use crate::stdout_mode::make_output_filename;
38#[cfg(test)]
39use bnto_core::processor::FileData;
40
41type Params = serde_json::Map<String, serde_json::Value>;
43
44pub struct ShellCommand;
46
47impl ShellCommand {
48 pub fn new() -> Self {
49 Self
50 }
51}
52
53impl Default for ShellCommand {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl NodeProcessor for ShellCommand {
60 fn name(&self) -> &str {
61 "shell-command"
62 }
63
64 fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
65 let mut errors = Vec::new();
66
67 match params.get("command").and_then(serde_json::Value::as_str) {
68 None => errors.push("'command' parameter is required".to_string()),
69 Some(cmd) => {
70 if let Err(e) = validate::validate_command(cmd) {
71 errors.push(e);
72 }
73 }
74 }
75
76 if let Some(timeout) = params.get("timeout").and_then(serde_json::Value::as_u64)
77 && timeout == 0
78 {
79 errors.push("'timeout' must be greater than 0".to_string());
80 }
81
82 errors
83 }
84
85 fn process(
86 &self,
87 input: NodeInput,
88 progress: &ProgressReporter,
89 ctx: &dyn ProcessContext,
90 ) -> Result<NodeOutput, BntoError> {
91 let params = &input.params;
92 let command = required_command(params)?;
93 let mut args = flattened_args(params);
94 let _timeout = u64_param(params, "timeout", DEFAULT_TIMEOUT_SECS);
96 let _env = sanitized_env(params);
97 validate::validate_command(command).map_err(BntoError::InvalidInput)?;
99 resolve_input_placeholders(&mut args, params);
102 let max_output_mb = u64_param(params, "maxOutputSize", DEFAULT_MAX_OUTPUT_MB);
103 match params.get("outputMode").and_then(serde_json::Value::as_str) {
104 Some("file") => process_file_mode(command, &args, &input, progress, ctx, max_output_mb),
105 _ => process_stdout_mode(command, &args, &input, progress, ctx, max_output_mb),
106 }
107 }
108
109 fn metadata(&self) -> NodeMetadata {
110 NodeMetadata {
111 node_type: "shell-command".to_string(),
112 name: "Shell Command".to_string(),
113 description: "Execute external CLI tools with security validation.".to_string(),
114 category: NodeCategory::System,
115 accepts: vec![],
116 platforms: vec![
117 "cli".to_string(),
118 "server".to_string(),
119 "desktop".to_string(),
120 ],
121 parameters: build_parameters(),
122 input_cardinality: InputCardinality::Source,
123 requires: vec![],
124 }
125 }
126}
127
128fn required_command(params: &Params) -> Result<&str, BntoError> {
130 params
131 .get("command")
132 .and_then(serde_json::Value::as_str)
133 .ok_or_else(|| BntoError::InvalidInput("'command' parameter is required".to_string()))
134}
135
136fn flattened_args(params: &Params) -> Vec<String> {
138 params
139 .get("args")
140 .and_then(serde_json::Value::as_array)
141 .map(|arr| flatten_conditional_args(arr))
142 .unwrap_or_default()
143}
144
145fn u64_param(params: &Params, key: &str, default: u64) -> u64 {
147 params
148 .get(key)
149 .and_then(serde_json::Value::as_u64)
150 .unwrap_or(default)
151}
152
153fn sanitized_env(params: &Params) -> Params {
155 params
156 .get("env")
157 .and_then(serde_json::Value::as_object)
158 .map(validate::sanitize_env)
159 .unwrap_or_default()
160}
161
162#[cfg(test)]
163mod tests {
164 use super::*;
165 use bnto_core::NoopContext;
166
167 #[test]
170 fn test_processor_name() {
171 let processor = ShellCommand::new();
172 assert_eq!(processor.name(), "shell-command");
173 }
174
175 #[test]
176 fn test_metadata_category_system() {
177 let processor = ShellCommand::new();
178 let meta = processor.metadata();
179 assert_eq!(meta.category, NodeCategory::System);
180 }
181
182 #[test]
183 fn test_metadata_platforms_native_only() {
184 let processor = ShellCommand::new();
185 let meta = processor.metadata();
186 assert_eq!(meta.platforms, vec!["cli", "server", "desktop"]);
187 assert!(!meta.platforms.contains(&"browser".to_string()));
188 }
189
190 #[test]
191 fn test_metadata_parameters_complete() {
192 let processor = ShellCommand::new();
193 let meta = processor.metadata();
194 let param_names: Vec<&str> = meta.parameters.iter().map(|p| p.name.as_str()).collect();
195 assert!(param_names.contains(&"command"));
196 assert!(param_names.contains(&"args"));
197 assert!(param_names.contains(&"outputMode"));
198 assert!(param_names.contains(&"timeout"));
199 assert!(param_names.contains(&"maxOutputSize"));
200 assert!(param_names.contains(&"env"));
201 }
202
203 #[test]
204 fn test_metadata_no_requires() {
205 let processor = ShellCommand::new();
206 let meta = processor.metadata();
207 assert!(
208 meta.requires.is_empty(),
209 "shell-command has no inherent deps"
210 );
211 }
212
213 #[test]
216 fn test_validate_empty_command_fails() {
217 let processor = ShellCommand::new();
218 let mut params = serde_json::Map::new();
219 params.insert("command".into(), serde_json::Value::String("".into()));
220 let errors = processor.validate(¶ms);
221 assert!(!errors.is_empty());
222 }
223
224 #[test]
225 fn test_validate_missing_command_fails() {
226 let processor = ShellCommand::new();
227 let params = serde_json::Map::new();
228 let errors = processor.validate(¶ms);
229 assert!(!errors.is_empty());
230 assert!(errors[0].contains("required"));
231 }
232
233 #[test]
234 fn test_validate_present_command_passes() {
235 let processor = ShellCommand::new();
236 let mut params = serde_json::Map::new();
237 params.insert("command".into(), serde_json::Value::String("echo".into()));
238 let errors = processor.validate(¶ms);
239 assert!(errors.is_empty());
240 }
241
242 #[test]
243 fn test_validate_shell_command_rejected() {
244 let processor = ShellCommand::new();
245 let mut params = serde_json::Map::new();
246 params.insert("command".into(), serde_json::Value::String("bash".into()));
247 let errors = processor.validate(¶ms);
248 assert!(!errors.is_empty());
249 assert!(errors[0].contains("shell interpreter"));
250 }
251
252 #[test]
253 fn test_validate_zero_timeout_rejected() {
254 let processor = ShellCommand::new();
255 let mut params = serde_json::Map::new();
256 params.insert("command".into(), serde_json::Value::String("echo".into()));
257 params.insert(
258 "timeout".into(),
259 serde_json::Value::Number(serde_json::Number::from(0)),
260 );
261 let errors = processor.validate(¶ms);
262 assert!(!errors.is_empty());
263 assert!(errors[0].contains("greater than 0"));
264 }
265
266 #[test]
269 fn test_noop_context_returns_error() {
270 let processor = ShellCommand::new();
271 let progress = ProgressReporter::new_noop();
272 let input = NodeInput {
273 data: FileData::Bytes(vec![]),
274 filename: "test.txt".to_string(),
275 mime_type: None,
276 params: {
277 let mut m = serde_json::Map::new();
278 m.insert("command".into(), serde_json::Value::String("echo".into()));
279 m.insert(
280 "args".into(),
281 serde_json::Value::Array(vec![serde_json::Value::String("hello".into())]),
282 );
283 m
284 },
285 };
286 let result = processor.process(input, &progress, &NoopContext);
287 assert!(result.is_err());
288 let err = result.err().expect("should be error");
289 assert!(err.to_string().contains("not available in browser"));
290 }
291
292 #[test]
293 fn test_process_rejects_missing_command() {
294 let processor = ShellCommand::new();
295 let progress = ProgressReporter::new_noop();
296 let input = NodeInput {
297 data: FileData::Bytes(vec![]),
298 filename: "test.txt".to_string(),
299 mime_type: None,
300 params: serde_json::Map::new(),
301 };
302 let result = processor.process(input, &progress, &NoopContext);
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn test_process_rejects_shell_command() {
308 let processor = ShellCommand::new();
309 let progress = ProgressReporter::new_noop();
310 let input = NodeInput {
311 data: FileData::Bytes(vec![]),
312 filename: "test.txt".to_string(),
313 mime_type: None,
314 params: {
315 let mut m = serde_json::Map::new();
316 m.insert("command".into(), serde_json::Value::String("bash".into()));
317 m.insert(
318 "args".into(),
319 serde_json::Value::Array(vec![
320 serde_json::Value::String("-c".into()),
321 serde_json::Value::String("echo pwned".into()),
322 ]),
323 );
324 m
325 },
326 };
327 let result = processor.process(input, &progress, &NoopContext);
328 assert!(result.is_err());
329 let err = result.err().expect("should be error");
330 assert!(err.to_string().contains("shell interpreter"));
331 }
332
333 #[test]
334 fn test_default_timeout_in_metadata() {
335 let processor = ShellCommand::new();
336 let meta = processor.metadata();
337 let timeout_param = meta
338 .parameters
339 .iter()
340 .find(|p| p.name == "timeout")
341 .expect("timeout param should exist");
342 assert_eq!(
343 timeout_param.default,
344 Some(serde_json::Value::Number(serde_json::Number::from(300u64)))
345 );
346 }
347
348 #[test]
349 fn test_default_max_output_size_in_metadata() {
350 let processor = ShellCommand::new();
351 let meta = processor.metadata();
352 let param = meta
353 .parameters
354 .iter()
355 .find(|p| p.name == "maxOutputSize")
356 .expect("maxOutputSize param should exist");
357 assert_eq!(
358 param.default,
359 Some(serde_json::Value::Number(serde_json::Number::from(
360 DEFAULT_MAX_OUTPUT_MB
361 )))
362 );
363 assert_eq!(param.suffix, Some("MB".to_string()));
364 }
365
366 #[test]
367 fn test_env_param_not_surfaceable() {
368 let processor = ShellCommand::new();
369 let meta = processor.metadata();
370 let env_param = meta
371 .parameters
372 .iter()
373 .find(|p| p.name == "env")
374 .expect("env param should exist");
375 assert!(!env_param.surfaceable, "env should be internal-only");
376 }
377
378 #[test]
379 fn test_output_mode_default_is_stdout() {
380 let processor = ShellCommand::new();
381 let meta = processor.metadata();
382 let param = meta
383 .parameters
384 .iter()
385 .find(|p| p.name == "outputMode")
386 .expect("outputMode param should exist");
387 assert_eq!(
388 param.default,
389 Some(serde_json::Value::String("stdout".to_string()))
390 );
391 }
392
393 #[test]
396 fn test_mime_from_extension_video() {
397 assert_eq!(mime_from_extension("video.mp4"), "video/mp4");
398 assert_eq!(mime_from_extension("video.webm"), "video/webm");
399 assert_eq!(mime_from_extension("video.mkv"), "video/x-matroska");
400 }
401
402 #[test]
403 fn test_mime_from_extension_audio() {
404 assert_eq!(mime_from_extension("audio.mp3"), "audio/mpeg");
405 assert_eq!(mime_from_extension("audio.m4a"), "audio/mp4");
406 assert_eq!(mime_from_extension("audio.wav"), "audio/wav");
407 assert_eq!(mime_from_extension("audio.flac"), "audio/flac");
408 }
409
410 #[test]
411 fn test_mime_from_extension_unknown() {
412 assert_eq!(mime_from_extension("file.xyz"), "application/octet-stream");
413 assert_eq!(mime_from_extension("noext"), "application/octet-stream");
414 }
415
416 #[test]
417 fn test_make_output_filename_empty_input() {
418 assert_eq!(make_output_filename("echo", ""), "echo-output");
419 }
420
421 #[test]
422 fn test_make_output_filename_with_input() {
423 assert_eq!(
424 make_output_filename("ffmpeg", "video.mp4"),
425 "video-ffmpeg-output"
426 );
427 }
428
429 #[test]
430 fn test_collect_output_files_returns_path_variant() {
431 let temp = tempfile::tempdir().unwrap();
432 let dir = temp.path().to_path_buf();
433 std::fs::write(dir.join("video.mp4"), vec![0u8; 100]).unwrap();
434 std::fs::write(dir.join("info.json"), b"{}").unwrap();
435
436 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
437 assert_eq!(files.len(), 2);
438
439 let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
440 assert!(names.contains(&"video.mp4"));
441 assert!(names.contains(&"info.json"));
442
443 let mp4 = files.iter().find(|f| f.filename == "video.mp4").unwrap();
445 assert_eq!(mp4.mime_type, "video/mp4");
446 let json = files.iter().find(|f| f.filename == "info.json").unwrap();
447 assert_eq!(json.mime_type, "application/json");
448
449 for file in &files {
451 assert!(
452 matches!(&file.data, FileData::Path(_)),
453 "collect_output_files should return FileData::Path, got Bytes for {}",
454 file.filename,
455 );
456 }
457 }
458
459 #[test]
460 fn test_collect_output_files_preserves_subdirectory_in_filename() {
461 let temp = tempfile::tempdir().unwrap();
462 let dir = temp.path().to_path_buf();
463 std::fs::create_dir_all(dir.join("subdir")).unwrap();
464 std::fs::write(dir.join("file.txt"), b"data").unwrap();
465 std::fs::write(dir.join("subdir").join("video.mp4"), vec![0u8; 50]).unwrap();
466
467 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
468 assert_eq!(files.len(), 2);
469
470 let names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
471 assert!(names.contains(&"file.txt"));
472 assert!(names.contains(&"subdir/video.mp4"));
473 }
474
475 #[test]
476 fn test_collect_output_files_preserves_deeply_nested_path() {
477 let temp = tempfile::tempdir().unwrap();
478 let dir = temp.path().to_path_buf();
479 std::fs::create_dir_all(dir.join("a").join("b")).unwrap();
480 std::fs::write(dir.join("a").join("b").join("deep.mp4"), vec![0u8; 10]).unwrap();
481
482 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
483 assert_eq!(files.len(), 1);
484 assert_eq!(files[0].filename, "a/b/deep.mp4");
485 }
486
487 #[test]
488 fn test_collect_output_files_multiple_subdirs_preserve_structure() {
489 let temp = tempfile::tempdir().unwrap();
490 let dir = temp.path().to_path_buf();
491 std::fs::create_dir_all(dir.join("Alpha Legion")).unwrap();
492 std::fs::create_dir_all(dir.join("Suboden Khan")).unwrap();
493 std::fs::write(dir.join("Alpha Legion").join("part1.mp4"), b"vid1").unwrap();
494 std::fs::write(dir.join("Suboden Khan").join("part2.mp4"), b"vid2").unwrap();
495
496 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
497 assert_eq!(files.len(), 2);
498
499 let mut names: Vec<&str> = files.iter().map(|f| f.filename.as_str()).collect();
500 names.sort();
501 assert_eq!(
502 names,
503 vec!["Alpha Legion/part1.mp4", "Suboden Khan/part2.mp4"]
504 );
505 }
506
507 #[test]
508 fn test_collect_output_files_empty_dir() {
509 let temp = tempfile::tempdir().unwrap();
510 let dir = temp.path().to_path_buf();
511
512 let files = collect_output_files(&dir, DEFAULT_MAX_OUTPUT_MB).unwrap();
513 assert!(files.is_empty());
514 }
515
516 #[test]
517 fn test_collect_output_files_rejects_oversized_file() {
518 let temp = tempfile::tempdir().unwrap();
519 let dir = temp.path().to_path_buf();
520 std::fs::write(dir.join("big.mp4"), vec![0u8; 2 * 1024 * 1024]).unwrap();
522
523 let result = collect_output_files(&dir, 1);
524 match result {
525 Err(e) => {
526 let msg = e.to_string();
527 assert!(msg.contains("big.mp4"), "Error should name the file");
528 assert!(msg.contains("1 MB limit"), "Error should state the limit");
529 }
530 Ok(_) => panic!("Expected error for oversized file"),
531 }
532 }
533
534 #[test]
535 fn test_collect_output_files_accepts_file_under_limit() {
536 let temp = tempfile::tempdir().unwrap();
537 let dir = temp.path().to_path_buf();
538 std::fs::write(dir.join("small.mp4"), vec![0u8; 512 * 1024]).unwrap();
540
541 let files = collect_output_files(&dir, 1).unwrap();
542 assert_eq!(files.len(), 1);
543 assert_eq!(files[0].filename, "small.mp4");
544 }
545
546 #[test]
547 fn test_output_dir_placeholder_substitution() {
548 let args = [
549 "-o".to_string(),
550 "{{output_dir}}/%(title)s.%(ext)s".to_string(),
551 "--verbose".to_string(),
552 ];
553 let dir_str = "/tmp/bnto-output";
554 let resolved: Vec<String> = args
555 .iter()
556 .map(|a| a.replace(OUTPUT_DIR_PLACEHOLDER, dir_str))
557 .collect();
558 assert_eq!(resolved[0], "-o");
559 assert_eq!(resolved[1], "/tmp/bnto-output/%(title)s.%(ext)s");
560 assert_eq!(resolved[2], "--verbose");
561 }
562
563 #[test]
566 fn test_resolve_url_appended_when_no_placeholder() {
567 let mut args = vec!["--no-playlist".to_string(), "-o".to_string()];
568 let mut params = serde_json::Map::new();
569 params.insert("url".into(), "https://example.com/video".into());
570 resolve_input_placeholders(&mut args, ¶ms);
571 assert_eq!(args.len(), 3);
572 assert_eq!(args[2], "https://example.com/video");
573 }
574
575 #[test]
576 fn test_resolve_url_placeholder_substituted() {
577 let mut args = vec!["--download".to_string(), "{{url}}".to_string()];
578 let mut params = serde_json::Map::new();
579 params.insert("url".into(), "https://example.com/video".into());
580 resolve_input_placeholders(&mut args, ¶ms);
581 assert_eq!(args.len(), 2);
582 assert_eq!(args[1], "https://example.com/video");
583 }
584
585 #[test]
586 fn test_resolve_input_placeholder_substituted() {
587 let mut args = vec!["process".to_string(), "{{input}}".to_string()];
588 let mut params = serde_json::Map::new();
589 params.insert("url".into(), "https://example.com/data".into());
590 resolve_input_placeholders(&mut args, ¶ms);
591 assert_eq!(args.len(), 2);
592 assert_eq!(args[1], "https://example.com/data");
593 }
594
595 #[test]
596 fn test_resolve_no_url_no_change() {
597 let mut args = vec!["--help".to_string()];
598 let params = serde_json::Map::new();
599 resolve_input_placeholders(&mut args, ¶ms);
600 assert_eq!(args.len(), 1);
601 assert_eq!(args[0], "--help");
602 }
603
604 #[test]
605 fn test_resolve_url_not_appended_when_placeholder_exists() {
606 let mut args = vec!["{{url}}".to_string(), "--verbose".to_string()];
607 let mut params = serde_json::Map::new();
608 params.insert("url".into(), "https://example.com".into());
609 resolve_input_placeholders(&mut args, ¶ms);
610 assert_eq!(args.len(), 2);
612 assert_eq!(args[0], "https://example.com");
613 }
614
615 #[test]
618 fn test_flatten_flat_strings_included() {
619 let args = vec![
620 serde_json::Value::String("--no-playlist".into()),
621 serde_json::Value::String("--newline".into()),
622 ];
623 assert_eq!(
624 flatten_conditional_args(&args),
625 vec!["--no-playlist", "--newline"]
626 );
627 }
628
629 #[test]
630 fn test_flatten_empty_strings_dropped() {
631 let args = vec![
632 serde_json::Value::String("--always".into()),
633 serde_json::Value::String("".into()),
634 serde_json::Value::String("--also-always".into()),
635 ];
636 assert_eq!(
637 flatten_conditional_args(&args),
638 vec!["--always", "--also-always"]
639 );
640 }
641
642 #[test]
643 fn test_flatten_nested_array_all_non_empty_flattened() {
644 let args = vec![
645 serde_json::Value::String("--always".into()),
646 serde_json::Value::Array(vec![
647 serde_json::Value::String("--cookies-from-browser".into()),
648 serde_json::Value::String("chrome".into()),
649 ]),
650 ];
651 assert_eq!(
652 flatten_conditional_args(&args),
653 vec!["--always", "--cookies-from-browser", "chrome"]
654 );
655 }
656
657 #[test]
658 fn test_flatten_nested_array_any_empty_drops_group() {
659 let args = vec![
660 serde_json::Value::String("--always".into()),
661 serde_json::Value::Array(vec![
662 serde_json::Value::String("--cookies-from-browser".into()),
663 serde_json::Value::String("".into()),
664 ]),
665 ];
666 assert_eq!(flatten_conditional_args(&args), vec!["--always"]);
667 }
668
669 #[test]
670 fn test_flatten_nested_array_with_non_string_drops_group() {
671 let args = vec![serde_json::Value::Array(vec![
672 serde_json::Value::String("--flag".into()),
673 serde_json::Value::Number(serde_json::Number::from(42)),
674 ])];
675 assert!(
676 flatten_conditional_args(&args).is_empty(),
677 "Non-string element means group.len() != strings.len()"
678 );
679 }
680
681 #[test]
682 fn test_flatten_non_string_types_dropped() {
683 let args = vec![
684 serde_json::Value::String("--keep".into()),
685 serde_json::Value::Number(serde_json::Number::from(42)),
686 serde_json::Value::Bool(true),
687 serde_json::Value::Null,
688 ];
689 assert_eq!(flatten_conditional_args(&args), vec!["--keep"]);
690 }
691
692 #[test]
693 fn test_flatten_empty_array_produces_nothing() {
694 let args = vec![serde_json::Value::Array(vec![])];
695 assert!(flatten_conditional_args(&args).is_empty());
698 }
699
700 #[test]
701 fn test_flatten_mixed_conditional_scenario() {
702 let args = vec![
704 serde_json::Value::String("--no-playlist".into()),
705 serde_json::Value::String("--newline".into()),
706 serde_json::Value::Array(vec![
707 serde_json::Value::String("--cookies-from-browser".into()),
708 serde_json::Value::String("chrome".into()),
709 ]),
710 serde_json::Value::Array(vec![
711 serde_json::Value::String("-S".into()),
712 serde_json::Value::String("res:720".into()),
713 ]),
714 serde_json::Value::String("".into()), serde_json::Value::String("".into()), ];
717 assert_eq!(
718 flatten_conditional_args(&args),
719 vec![
720 "--no-playlist",
721 "--newline",
722 "--cookies-from-browser",
723 "chrome",
724 "-S",
725 "res:720",
726 ]
727 );
728 }
729
730 #[test]
731 fn test_flatten_all_groups_disabled() {
732 let args = vec![
734 serde_json::Value::String("--no-playlist".into()),
735 serde_json::Value::Array(vec![
736 serde_json::Value::String("--cookies-from-browser".into()),
737 serde_json::Value::String("".into()),
738 ]),
739 serde_json::Value::Array(vec![
740 serde_json::Value::String("-S".into()),
741 serde_json::Value::String("".into()),
742 ]),
743 serde_json::Value::String("".into()),
744 serde_json::Value::String("".into()),
745 ];
746 assert_eq!(flatten_conditional_args(&args), vec!["--no-playlist"]);
747 }
748}