1use std::env;
35
36use crate::isolation::get_default_docker_image;
37
38pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
40
41pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
43
44pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
46
47const UUID_REGEX: &str = r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$";
49
50pub fn is_valid_uuid(s: &str) -> bool {
52 regex::Regex::new(UUID_REGEX)
53 .map(|re| re.is_match(&s.to_lowercase()))
54 .unwrap_or(false)
55}
56
57pub fn generate_uuid() -> String {
59 uuid::Uuid::new_v4().to_string()
60}
61
62#[derive(Debug, Clone)]
64pub struct WrapperOptions {
65 pub isolated: Option<String>,
67 pub attached: bool,
69 pub detached: bool,
71 pub session: Option<String>,
73 pub session_id: Option<String>,
75 pub image: Option<String>,
77 pub volumes: Vec<String>,
79 pub mounts: Vec<String>,
81 pub env: Vec<String>,
83 pub privileged: bool,
85 pub network: Option<String>,
87 pub networks: Vec<String>,
89 pub network_aliases: Vec<String>,
91 pub endpoint: Option<String>,
93 pub user: bool,
95 pub user_name: Option<String>,
97 pub keep_user: bool,
99 pub keep_alive: bool,
101 pub auto_remove_docker_container: bool,
103 pub always_cleanup_container: bool,
105 pub keep_container: bool,
107 pub keep_container_on_fail: bool,
109 pub shell: String,
111 pub use_command_stream: bool,
113 pub status: Option<String>,
115 pub list: bool,
117 pub upload_log: Option<String>,
119 pub output_format: Option<String>,
121 pub stop: Option<String>,
123 pub terminate: Option<String>,
125 pub cleanup: bool,
127 pub cleanup_dry_run: bool,
129}
130
131impl Default for WrapperOptions {
132 fn default() -> Self {
133 WrapperOptions {
134 isolated: None,
135 attached: false,
136 detached: false,
137 session: None,
138 session_id: None,
139 image: None,
140 volumes: Vec::new(),
141 mounts: Vec::new(),
142 env: Vec::new(),
143 privileged: false,
144 network: None,
145 networks: Vec::new(),
146 network_aliases: Vec::new(),
147 endpoint: None,
148 user: false,
149 user_name: None,
150 keep_user: false,
151 keep_alive: false,
152 auto_remove_docker_container: false,
153 always_cleanup_container: false,
154 keep_container: false,
155 keep_container_on_fail: false,
156 shell: "auto".to_string(),
157 use_command_stream: false,
158 status: None,
159 list: false,
160 upload_log: None,
161 output_format: None,
162 stop: None,
163 terminate: None,
164 cleanup: false,
165 cleanup_dry_run: false,
166 }
167 }
168}
169
170#[derive(Debug)]
172pub struct ParsedArgs {
173 pub wrapper_options: WrapperOptions,
175 pub command: String,
177 pub raw_command: Vec<String>,
179}
180
181pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
183 let mut wrapper_options = WrapperOptions::default();
184 let mut command_args: Vec<String> = Vec::new();
185
186 let separator_index = args.iter().position(|a| a == "--");
188
189 if let Some(sep_idx) = separator_index {
190 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
192 command_args = args[sep_idx + 1..].to_vec();
193 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
194 } else {
195 let mut i = 0;
197 while i < args.len() {
198 let arg = &args[i];
199 if arg.starts_with('-') {
200 match parse_option(args, i, &mut wrapper_options)? {
201 0 => {
202 return Err(format!("Unknown wrapper option: {}", arg));
203 }
204 consumed => {
205 i += consumed;
206 }
207 }
208 } else {
209 command_args = args[i..].to_vec();
211 break;
212 }
213 }
214 }
215
216 validate_options(&mut wrapper_options)?;
218
219 Ok(ParsedArgs {
220 wrapper_options,
221 command: command_args.join(" "),
222 raw_command: command_args,
223 })
224}
225
226fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
228 let mut i = 0;
229 while i < args.len() {
230 match parse_option(args, i, options)? {
231 0 => {
232 if args[i].starts_with('-') {
233 return Err(format!("Unknown wrapper option: {}", args[i]));
234 }
235 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
236 eprintln!("Unknown wrapper option: {}", args[i]);
237 }
238 i += 1;
239 }
240 consumed => {
241 i += consumed;
242 }
243 }
244 }
245 Ok(())
246}
247
248fn parse_option(
251 args: &[String],
252 index: usize,
253 options: &mut WrapperOptions,
254) -> Result<usize, String> {
255 let arg = &args[index];
256
257 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
259 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
260 options.isolated = Some(args[index + 1].to_lowercase());
261 return Ok(2);
262 } else {
263 return Err(format!(
264 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
265 arg
266 ));
267 }
268 }
269
270 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
272 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
273 return Ok(1);
274 }
275
276 if arg == "--attached" || arg == "-a" {
278 options.attached = true;
279 return Ok(1);
280 }
281
282 if arg == "--detached" || arg == "-d" {
284 options.detached = true;
285 return Ok(1);
286 }
287
288 if arg == "--session" || arg == "-s" {
290 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
291 options.session = Some(args[index + 1].clone());
292 return Ok(2);
293 } else {
294 return Err(format!("Option {} requires a session name argument", arg));
295 }
296 }
297
298 if arg.starts_with("--session=") {
300 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
301 return Ok(1);
302 }
303
304 if arg == "--image" {
306 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
307 options.image = Some(args[index + 1].clone());
308 return Ok(2);
309 } else {
310 return Err(format!("Option {} requires an image name argument", arg));
311 }
312 }
313
314 if arg.starts_with("--image=") {
316 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
317 return Ok(1);
318 }
319
320 if arg == "--volume" || arg == "-v" {
322 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
323 options.volumes.push(args[index + 1].clone());
324 return Ok(2);
325 } else {
326 return Err(format!(
327 "Option {} requires a volume argument (host:container[:mode])",
328 arg
329 ));
330 }
331 }
332
333 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
335 options
336 .volumes
337 .push(arg[arg.find('=').unwrap() + 1..].to_string());
338 return Ok(1);
339 }
340
341 if arg == "--mount" {
343 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
344 options.mounts.push(args[index + 1].clone());
345 return Ok(2);
346 } else {
347 return Err(format!("Option {} requires a mount spec argument", arg));
348 }
349 }
350
351 if let Some(value) = arg.strip_prefix("--mount=") {
353 options.mounts.push(value.to_string());
354 return Ok(1);
355 }
356
357 if arg == "--env" || arg == "-e" {
359 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
360 options.env.push(args[index + 1].clone());
361 return Ok(2);
362 } else {
363 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
364 }
365 }
366
367 if arg.starts_with("--env=") || arg.starts_with("-e=") {
369 options
370 .env
371 .push(arg[arg.find('=').unwrap() + 1..].to_string());
372 return Ok(1);
373 }
374
375 if arg == "--privileged" {
377 options.privileged = true;
378 return Ok(1);
379 }
380
381 if arg == "--network" {
383 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
384 let network = args[index + 1].clone();
385 options.networks.push(network.clone());
386 if options.network.is_none() {
387 options.network = Some(network);
388 }
389 return Ok(2);
390 }
391 return Err(format!("Option {} requires a network name argument", arg));
392 }
393
394 if let Some(value) = arg.strip_prefix("--network=") {
396 let network = value.to_string();
397 options.networks.push(network.clone());
398 if options.network.is_none() {
399 options.network = Some(network);
400 }
401 return Ok(1);
402 }
403
404 if arg == "--network-alias" {
406 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
407 options.network_aliases.push(args[index + 1].clone());
408 return Ok(2);
409 }
410 return Err(format!("Option {} requires an alias argument", arg));
411 }
412
413 if let Some(value) = arg.strip_prefix("--network-alias=") {
415 options.network_aliases.push(value.to_string());
416 return Ok(1);
417 }
418
419 if arg == "--endpoint" {
421 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
422 options.endpoint = Some(args[index + 1].clone());
423 return Ok(2);
424 } else {
425 return Err(format!("Option {} requires an endpoint argument", arg));
426 }
427 }
428
429 if arg.starts_with("--endpoint=") {
431 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
432 return Ok(1);
433 }
434
435 if arg == "--isolated-user" || arg == "-u" {
437 options.user = true;
438 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
440 let next_arg = &args[index + 1];
441 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
443 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
444 options.user_name = Some(next_arg.clone());
445 return Ok(2);
446 }
447 }
448 return Ok(1);
449 }
450
451 if arg.starts_with("--isolated-user=") {
453 options.user = true;
454 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
455 return Ok(1);
456 }
457
458 if arg == "--keep-user" {
460 options.keep_user = true;
461 return Ok(1);
462 }
463
464 if arg == "--keep-alive" || arg == "-k" {
466 options.keep_alive = true;
467 return Ok(1);
468 }
469
470 if arg == "--auto-remove-docker-container" {
472 options.auto_remove_docker_container = true;
473 return Ok(1);
474 }
475
476 if arg == "--always-cleanup-container" {
478 options.always_cleanup_container = true;
479 return Ok(1);
480 }
481
482 if arg == "--keep-container" {
484 options.keep_container = true;
485 return Ok(1);
486 }
487
488 if arg == "--keep-container-on-fail" {
490 options.keep_container_on_fail = true;
491 return Ok(1);
492 }
493
494 if arg == "--shell" {
496 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
497 options.shell = args[index + 1].to_lowercase();
498 return Ok(2);
499 } else {
500 return Err(format!(
501 "Option {} requires a shell argument (auto, bash, zsh, sh)",
502 arg
503 ));
504 }
505 }
506
507 if arg.starts_with("--shell=") {
509 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
510 return Ok(1);
511 }
512
513 if arg == "--use-command-stream" {
515 options.use_command_stream = true;
516 return Ok(1);
517 }
518
519 if arg == "--session-id" || arg == "--session-name" {
521 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
522 options.session_id = Some(args[index + 1].clone());
523 return Ok(2);
524 } else {
525 return Err(format!("Option {} requires a UUID argument", arg));
526 }
527 }
528
529 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
531 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
532 return Ok(1);
533 }
534
535 if arg == "--status" {
537 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
538 options.status = Some(args[index + 1].clone());
539 return Ok(2);
540 } else {
541 return Err(format!(
542 "Option {} requires a UUID or session name argument",
543 arg
544 ));
545 }
546 }
547
548 if let Some(value) = arg.strip_prefix("--status=") {
550 if value.is_empty() {
551 return Err("Option --status requires a UUID or session name argument".to_string());
552 }
553 options.status = Some(value.to_string());
554 return Ok(1);
555 }
556
557 if arg == "--upload-log" {
559 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
560 options.upload_log = Some(args[index + 1].clone());
561 return Ok(2);
562 } else {
563 return Err(format!(
564 "Option {} requires a UUID or session name argument",
565 arg
566 ));
567 }
568 }
569
570 if let Some(value) = arg.strip_prefix("--upload-log=") {
572 if value.is_empty() {
573 return Err("Option --upload-log requires a UUID or session name argument".to_string());
574 }
575 options.upload_log = Some(value.to_string());
576 return Ok(1);
577 }
578
579 if arg == "--stop" {
581 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
582 options.stop = Some(args[index + 1].clone());
583 return Ok(2);
584 } else {
585 return Err(format!(
586 "Option {} requires a UUID or session name argument",
587 arg
588 ));
589 }
590 }
591
592 if let Some(value) = arg.strip_prefix("--stop=") {
594 if value.is_empty() {
595 return Err("Option --stop requires a UUID or session name argument".to_string());
596 }
597 options.stop = Some(value.to_string());
598 return Ok(1);
599 }
600
601 if arg == "--terminate" {
603 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
604 options.terminate = Some(args[index + 1].clone());
605 return Ok(2);
606 } else {
607 return Err(format!(
608 "Option {} requires a UUID or session name argument",
609 arg
610 ));
611 }
612 }
613
614 if let Some(value) = arg.strip_prefix("--terminate=") {
616 if value.is_empty() {
617 return Err("Option --terminate requires a UUID or session name argument".to_string());
618 }
619 options.terminate = Some(value.to_string());
620 return Ok(1);
621 }
622
623 if arg == "--list" {
625 options.list = true;
626 return Ok(1);
627 }
628
629 if arg == "--output-format" {
631 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
632 options.output_format = Some(args[index + 1].to_lowercase());
633 return Ok(2);
634 } else {
635 return Err(format!("Option {} requires a format argument", arg));
636 }
637 }
638
639 if arg.starts_with("--output-format=") {
641 options.output_format = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
642 return Ok(1);
643 }
644
645 if arg == "--cleanup" {
647 options.cleanup = true;
648 return Ok(1);
649 }
650
651 if arg == "--cleanup-dry-run" {
653 options.cleanup = true;
654 options.cleanup_dry_run = true;
655 return Ok(1);
656 }
657
658 Ok(0)
660}
661
662pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
664 if options.attached && options.detached {
666 return Err(
667 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
668 .to_string(),
669 );
670 }
671
672 if let Some(ref backend) = options.isolated {
674 if !VALID_BACKENDS.contains(&backend.as_str()) {
675 return Err(format!(
676 "Invalid isolation backend: \"{}\". Valid options are: {}",
677 backend,
678 VALID_BACKENDS.join(", ")
679 ));
680 }
681
682 if backend == "docker" && options.image.is_none() {
684 options.image = Some(get_default_docker_image());
685 }
686
687 if backend == "ssh" && options.endpoint.is_none() {
689 return Err(
690 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
691 .to_string(),
692 );
693 }
694 }
695
696 if options.session.is_some() && options.isolated.is_none() {
698 return Err("--session option is only valid with --isolated".to_string());
699 }
700
701 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
703 return Err("--image option is only valid with --isolated docker".to_string());
704 }
705
706 let is_docker = options.isolated.as_deref() == Some("docker");
708 if !options.volumes.is_empty() && !is_docker {
709 return Err("--volume option is only valid with --isolated docker".to_string());
710 }
711 if !options.mounts.is_empty() && !is_docker {
712 return Err("--mount option is only valid with --isolated docker".to_string());
713 }
714 if !options.env.is_empty() && !is_docker {
715 return Err("--env option is only valid with --isolated docker".to_string());
716 }
717 if options.privileged && !is_docker {
718 return Err("--privileged option is only valid with --isolated docker".to_string());
719 }
720 if options.network.is_some() && !is_docker {
721 return Err("--network option is only valid with --isolated docker".to_string());
722 }
723 if !options.network_aliases.is_empty() && !is_docker {
724 return Err("--network-alias option is only valid with --isolated docker".to_string());
725 }
726
727 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
729 return Err("--endpoint option is only valid with --isolated ssh".to_string());
730 }
731
732 if options.keep_alive && options.isolated.is_none() {
734 return Err("--keep-alive option is only valid with --isolated".to_string());
735 }
736
737 let cleanup_flags = [
739 (
740 "--auto-remove-docker-container",
741 options.auto_remove_docker_container,
742 ),
743 (
744 "--always-cleanup-container",
745 options.always_cleanup_container,
746 ),
747 ("--keep-container", options.keep_container),
748 ("--keep-container-on-fail", options.keep_container_on_fail),
749 ];
750 for (flag, enabled) in cleanup_flags {
751 if enabled && !is_docker {
752 return Err(format!(
753 "{} option is only valid with --isolated docker",
754 flag
755 ));
756 }
757 }
758
759 let selected_cleanup_policies = [
760 options.auto_remove_docker_container || options.always_cleanup_container,
761 options.keep_container,
762 options.keep_container_on_fail,
763 ]
764 .into_iter()
765 .filter(|enabled| *enabled)
766 .count();
767 if selected_cleanup_policies > 1 {
768 return Err(
769 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
770 .to_string(),
771 );
772 }
773
774 if options.user {
776 if options.isolated.as_deref() == Some("docker") {
778 return Err(
779 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
780 .to_string(),
781 );
782 }
783 if let Some(ref username) = options.user_name {
785 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
786 if !username_regex.is_match(username) {
787 return Err(format!(
788 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
789 username
790 ));
791 }
792 if username.len() > 32 {
793 return Err(format!(
794 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
795 username
796 ));
797 }
798 }
799 }
800
801 if options.keep_user && !options.user {
803 return Err("--keep-user option is only valid with --isolated-user".to_string());
804 }
805
806 if let Some(ref format) = options.output_format {
808 if !VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
809 return Err(format!(
810 "Invalid output format: \"{}\". Valid options are: {}",
811 format,
812 VALID_OUTPUT_FORMATS.join(", ")
813 ));
814 }
815 }
816
817 let query_modes = [
819 options.status.is_some(),
820 options.list,
821 options.upload_log.is_some(),
822 options.stop.is_some(),
823 options.terminate.is_some(),
824 options.cleanup,
825 ]
826 .into_iter()
827 .filter(|enabled| *enabled)
828 .count();
829
830 if query_modes > 1 {
831 return Err(
832 "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation"
833 .to_string(),
834 );
835 }
836
837 if options.output_format.is_some() && options.status.is_none() && !options.list {
839 return Err("--output-format option is only valid with --status or --list".to_string());
840 }
841
842 if !VALID_SHELLS.contains(&options.shell.as_str()) {
844 return Err(format!(
845 "Invalid shell: \"{}\". Valid options are: {}",
846 options.shell,
847 VALID_SHELLS.join(", ")
848 ));
849 }
850
851 if let Some(ref session_id) = options.session_id {
853 if !is_valid_uuid(session_id) {
854 return Err(format!(
855 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
856 session_id
857 ));
858 }
859 }
860
861 Ok(())
862}
863
864pub fn generate_session_name(prefix: Option<&str>) -> String {
866 use std::cell::RefCell;
867 use std::time::{SystemTime, UNIX_EPOCH};
868
869 thread_local! {
870 static STATE: RefCell<u64> = RefCell::new(
871 SystemTime::now()
872 .duration_since(UNIX_EPOCH)
873 .unwrap()
874 .as_nanos() as u64
875 );
876 }
877
878 fn next_random() -> u64 {
879 STATE.with(|state| {
880 let mut s = state.borrow_mut();
881 *s ^= *s << 13;
882 *s ^= *s >> 7;
883 *s ^= *s << 17;
884 *s
885 })
886 }
887
888 let prefix = prefix.unwrap_or("start");
889 let timestamp = chrono::Utc::now().timestamp_millis();
890 let random: String = (0..6)
891 .map(|_| {
892 let idx = (next_random() % 36) as u8;
893 if idx < 10 {
894 (b'0' + idx) as char
895 } else {
896 (b'a' + idx - 10) as char
897 }
898 })
899 .collect();
900 format!("{}-{}-{}", prefix, timestamp, random)
901}
902
903pub fn has_isolation(options: &WrapperOptions) -> bool {
905 options.isolated.is_some()
906}
907
908pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
911 if options.detached {
912 "detached"
913 } else {
914 "attached"
916 }
917}
918
919#[cfg(test)]
920#[path = "args_parser_cases.rs"]
921mod tests;