1use std::env;
40
41use crate::args_parser_queries::{parse_query_option, validate_query_options};
42use crate::docker_recovery_options::{
43 parse_docker_recovery_option, validate_docker_recovery_options,
44};
45use crate::isolation::get_default_docker_image;
46
47pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
49
50pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
52
53pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
55
56const 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}$";
58
59pub fn is_valid_uuid(s: &str) -> bool {
61 regex::Regex::new(UUID_REGEX)
62 .map(|re| re.is_match(&s.to_lowercase()))
63 .unwrap_or(false)
64}
65
66pub fn generate_uuid() -> String {
68 uuid::Uuid::new_v4().to_string()
69}
70
71#[derive(Debug, Clone)]
73pub struct WrapperOptions {
74 pub isolated: Option<String>,
76 pub attached: bool,
78 pub detached: bool,
80 pub session: Option<String>,
82 pub session_id: Option<String>,
84 pub image: Option<String>,
86 pub volumes: Vec<String>,
88 pub mounts: Vec<String>,
90 pub env: Vec<String>,
92 pub privileged: bool,
94 pub network: Option<String>,
96 pub networks: Vec<String>,
98 pub network_aliases: Vec<String>,
100 pub on_kill_resume: Option<u32>,
102 pub recovery_command: Option<String>,
104 pub on_kill_resume_delay: Option<String>,
106 pub endpoint: Option<String>,
108 pub user: bool,
110 pub user_name: Option<String>,
112 pub keep_user: bool,
114 pub keep_alive: bool,
116 pub auto_remove_docker_container: bool,
118 pub always_cleanup_container: bool,
120 pub keep_container: bool,
122 pub keep_container_on_fail: bool,
124 pub shell: String,
126 pub use_command_stream: bool,
128 pub status: Option<String>,
130 pub list: bool,
132 pub running: bool,
134 pub upload_log: Option<String>,
136 pub output_format: Option<String>,
138 pub stop: Option<String>,
140 pub terminate: Option<String>,
142 pub attach: Option<String>,
144 pub read_only: bool,
146 pub resume: Option<String>,
148 pub resume_all: bool,
150 pub cleanup: bool,
152 pub cleanup_dry_run: bool,
154}
155
156impl Default for WrapperOptions {
157 fn default() -> Self {
158 WrapperOptions {
159 isolated: None,
160 attached: false,
161 detached: false,
162 session: None,
163 session_id: None,
164 image: None,
165 volumes: Vec::new(),
166 mounts: Vec::new(),
167 env: Vec::new(),
168 privileged: false,
169 network: None,
170 networks: Vec::new(),
171 network_aliases: Vec::new(),
172 on_kill_resume: None,
173 recovery_command: None,
174 on_kill_resume_delay: None,
175 endpoint: None,
176 user: false,
177 user_name: None,
178 keep_user: false,
179 keep_alive: false,
180 auto_remove_docker_container: false,
181 always_cleanup_container: false,
182 keep_container: false,
183 keep_container_on_fail: false,
184 shell: "auto".to_string(),
185 use_command_stream: false,
186 status: None,
187 list: false,
188 running: false,
189 upload_log: None,
190 output_format: None,
191 stop: None,
192 terminate: None,
193 attach: None,
194 read_only: false,
195 resume: None,
196 resume_all: false,
197 cleanup: false,
198 cleanup_dry_run: false,
199 }
200 }
201}
202
203#[derive(Debug)]
205pub struct ParsedArgs {
206 pub wrapper_options: WrapperOptions,
208 pub command: String,
211 pub raw_command: Vec<String>,
213}
214
215pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
217 let mut wrapper_options = WrapperOptions::default();
218 let mut command_args: Vec<String> = Vec::new();
219
220 let separator_index = args.iter().position(|a| a == "--");
222
223 if let Some(sep_idx) = separator_index {
224 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
226 command_args = args[sep_idx + 1..].to_vec();
227 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
228 } else {
229 let mut i = 0;
231 while i < args.len() {
232 let arg = &args[i];
233 if arg.starts_with('-') {
234 match parse_option(args, i, &mut wrapper_options)? {
235 0 => {
236 return Err(format!("Unknown wrapper option: {}", arg));
237 }
238 consumed => {
239 i += consumed;
240 }
241 }
242 } else {
243 command_args = args[i..].to_vec();
245 break;
246 }
247 }
248 }
249
250 validate_options(&mut wrapper_options)?;
252
253 Ok(ParsedArgs {
254 wrapper_options,
255 command: crate::isolation::build_command_string(&command_args),
256 raw_command: command_args,
257 })
258}
259
260fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
262 let mut i = 0;
263 while i < args.len() {
264 match parse_option(args, i, options)? {
265 0 => {
266 if args[i].starts_with('-') {
267 return Err(format!("Unknown wrapper option: {}", args[i]));
268 }
269 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
270 eprintln!("Unknown wrapper option: {}", args[i]);
271 }
272 i += 1;
273 }
274 consumed => {
275 i += consumed;
276 }
277 }
278 }
279 Ok(())
280}
281
282fn parse_option(
285 args: &[String],
286 index: usize,
287 options: &mut WrapperOptions,
288) -> Result<usize, String> {
289 let arg = &args[index];
290
291 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
293 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
294 options.isolated = Some(args[index + 1].to_lowercase());
295 return Ok(2);
296 } else {
297 return Err(format!(
298 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
299 arg
300 ));
301 }
302 }
303
304 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
306 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
307 return Ok(1);
308 }
309
310 if arg == "--attached" || arg == "-a" {
312 options.attached = true;
313 return Ok(1);
314 }
315
316 if arg == "--detached" || arg == "-d" {
318 options.detached = true;
319 return Ok(1);
320 }
321
322 if arg == "--session" || arg == "-s" {
324 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
325 options.session = Some(args[index + 1].clone());
326 return Ok(2);
327 } else {
328 return Err(format!("Option {} requires a session name argument", arg));
329 }
330 }
331
332 if arg.starts_with("--session=") {
334 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
335 return Ok(1);
336 }
337
338 if arg == "--image" {
340 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
341 options.image = Some(args[index + 1].clone());
342 return Ok(2);
343 } else {
344 return Err(format!("Option {} requires an image name argument", arg));
345 }
346 }
347
348 if arg.starts_with("--image=") {
350 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
351 return Ok(1);
352 }
353
354 if arg == "--volume" || arg == "-v" {
356 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
357 options.volumes.push(args[index + 1].clone());
358 return Ok(2);
359 } else {
360 return Err(format!(
361 "Option {} requires a volume argument (host:container[:mode])",
362 arg
363 ));
364 }
365 }
366
367 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
369 options
370 .volumes
371 .push(arg[arg.find('=').unwrap() + 1..].to_string());
372 return Ok(1);
373 }
374
375 if arg == "--mount" {
377 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
378 options.mounts.push(args[index + 1].clone());
379 return Ok(2);
380 } else {
381 return Err(format!("Option {} requires a mount spec argument", arg));
382 }
383 }
384
385 if let Some(value) = arg.strip_prefix("--mount=") {
387 options.mounts.push(value.to_string());
388 return Ok(1);
389 }
390
391 if arg == "--env" || arg == "-e" {
393 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
394 options.env.push(args[index + 1].clone());
395 return Ok(2);
396 } else {
397 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
398 }
399 }
400
401 if arg.starts_with("--env=") || arg.starts_with("-e=") {
403 options
404 .env
405 .push(arg[arg.find('=').unwrap() + 1..].to_string());
406 return Ok(1);
407 }
408
409 if arg == "--privileged" {
411 options.privileged = true;
412 return Ok(1);
413 }
414
415 if arg == "--network" {
417 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
418 let network = args[index + 1].clone();
419 options.networks.push(network.clone());
420 if options.network.is_none() {
421 options.network = Some(network);
422 }
423 return Ok(2);
424 }
425 return Err(format!("Option {} requires a network name argument", arg));
426 }
427
428 if let Some(value) = arg.strip_prefix("--network=") {
430 let network = value.to_string();
431 options.networks.push(network.clone());
432 if options.network.is_none() {
433 options.network = Some(network);
434 }
435 return Ok(1);
436 }
437
438 if arg == "--network-alias" {
440 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
441 options.network_aliases.push(args[index + 1].clone());
442 return Ok(2);
443 }
444 return Err(format!("Option {} requires an alias argument", arg));
445 }
446
447 if let Some(value) = arg.strip_prefix("--network-alias=") {
449 options.network_aliases.push(value.to_string());
450 return Ok(1);
451 }
452
453 if arg == "--endpoint" {
455 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
456 options.endpoint = Some(args[index + 1].clone());
457 return Ok(2);
458 } else {
459 return Err(format!("Option {} requires an endpoint argument", arg));
460 }
461 }
462
463 if arg.starts_with("--endpoint=") {
465 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
466 return Ok(1);
467 }
468
469 if arg == "--isolated-user" || arg == "-u" {
471 options.user = true;
472 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
474 let next_arg = &args[index + 1];
475 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
477 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
478 options.user_name = Some(next_arg.clone());
479 return Ok(2);
480 }
481 }
482 return Ok(1);
483 }
484
485 if arg.starts_with("--isolated-user=") {
487 options.user = true;
488 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
489 return Ok(1);
490 }
491
492 if arg == "--keep-user" {
494 options.keep_user = true;
495 return Ok(1);
496 }
497
498 if arg == "--keep-alive" || arg == "-k" {
500 options.keep_alive = true;
501 return Ok(1);
502 }
503
504 if arg == "--auto-remove-docker-container" {
506 options.auto_remove_docker_container = true;
507 return Ok(1);
508 }
509
510 if arg == "--always-cleanup-container" {
512 options.always_cleanup_container = true;
513 return Ok(1);
514 }
515
516 if arg == "--keep-container" {
518 options.keep_container = true;
519 return Ok(1);
520 }
521
522 if arg == "--keep-container-on-fail" {
524 options.keep_container_on_fail = true;
525 return Ok(1);
526 }
527
528 if arg == "--shell" {
530 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
531 options.shell = args[index + 1].to_lowercase();
532 return Ok(2);
533 } else {
534 return Err(format!(
535 "Option {} requires a shell argument (auto, bash, zsh, sh)",
536 arg
537 ));
538 }
539 }
540
541 if arg.starts_with("--shell=") {
543 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
544 return Ok(1);
545 }
546
547 if arg == "--use-command-stream" {
549 options.use_command_stream = true;
550 return Ok(1);
551 }
552
553 if arg == "--session-id" || arg == "--session-name" {
555 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
556 options.session_id = Some(args[index + 1].clone());
557 return Ok(2);
558 } else {
559 return Err(format!("Option {} requires a UUID argument", arg));
560 }
561 }
562
563 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
565 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
566 return Ok(1);
567 }
568
569 let recovery_consumed = parse_docker_recovery_option(args, index, options)?;
571 if recovery_consumed > 0 {
572 return Ok(recovery_consumed);
573 }
574
575 let query_consumed = parse_query_option(args, index, options)?;
577 if query_consumed > 0 {
578 return Ok(query_consumed);
579 }
580
581 Ok(0)
583}
584
585pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
587 if options.attached && options.detached {
589 return Err(
590 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
591 .to_string(),
592 );
593 }
594
595 if let Some(ref backend) = options.isolated {
597 if !VALID_BACKENDS.contains(&backend.as_str()) {
598 return Err(format!(
599 "Invalid isolation backend: \"{}\". Valid options are: {}",
600 backend,
601 VALID_BACKENDS.join(", ")
602 ));
603 }
604
605 if backend == "docker" && options.image.is_none() {
607 options.image = Some(get_default_docker_image());
608 }
609
610 if backend == "ssh" && options.endpoint.is_none() {
612 return Err(
613 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
614 .to_string(),
615 );
616 }
617 }
618
619 if options.session.is_some() && options.isolated.is_none() {
621 return Err("--session option is only valid with --isolated".to_string());
622 }
623
624 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
626 return Err("--image option is only valid with --isolated docker".to_string());
627 }
628
629 let is_docker = options.isolated.as_deref() == Some("docker");
631 if !options.volumes.is_empty() && !is_docker {
632 return Err("--volume option is only valid with --isolated docker".to_string());
633 }
634 if !options.mounts.is_empty() && !is_docker {
635 return Err("--mount option is only valid with --isolated docker".to_string());
636 }
637 if !options.env.is_empty() && !is_docker {
638 return Err("--env option is only valid with --isolated docker".to_string());
639 }
640 if options.privileged && !is_docker {
641 return Err("--privileged option is only valid with --isolated docker".to_string());
642 }
643 if options.network.is_some() && !is_docker {
644 return Err("--network option is only valid with --isolated docker".to_string());
645 }
646 if !options.network_aliases.is_empty() && !is_docker {
647 return Err("--network-alias option is only valid with --isolated docker".to_string());
648 }
649
650 validate_docker_recovery_options(options)?;
651
652 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
654 return Err("--endpoint option is only valid with --isolated ssh".to_string());
655 }
656
657 if options.keep_alive && options.isolated.is_none() {
659 return Err("--keep-alive option is only valid with --isolated".to_string());
660 }
661
662 let cleanup_flags = [
664 (
665 "--auto-remove-docker-container",
666 options.auto_remove_docker_container,
667 ),
668 (
669 "--always-cleanup-container",
670 options.always_cleanup_container,
671 ),
672 ("--keep-container", options.keep_container),
673 ("--keep-container-on-fail", options.keep_container_on_fail),
674 ];
675 for (flag, enabled) in cleanup_flags {
676 if enabled && !is_docker {
677 return Err(format!(
678 "{} option is only valid with --isolated docker",
679 flag
680 ));
681 }
682 }
683
684 let selected_cleanup_policies = [
685 options.auto_remove_docker_container || options.always_cleanup_container,
686 options.keep_container,
687 options.keep_container_on_fail,
688 ]
689 .into_iter()
690 .filter(|enabled| *enabled)
691 .count();
692 if selected_cleanup_policies > 1 {
693 return Err(
694 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
695 .to_string(),
696 );
697 }
698
699 if options.user {
701 if options.isolated.as_deref() == Some("docker") {
703 return Err(
704 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
705 .to_string(),
706 );
707 }
708 if let Some(ref username) = options.user_name {
710 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
711 if !username_regex.is_match(username) {
712 return Err(format!(
713 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
714 username
715 ));
716 }
717 if username.len() > 32 {
718 return Err(format!(
719 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
720 username
721 ));
722 }
723 }
724 }
725
726 if options.keep_user && !options.user {
728 return Err("--keep-user option is only valid with --isolated-user".to_string());
729 }
730
731 validate_query_options(options)?;
733
734 if !VALID_SHELLS.contains(&options.shell.as_str()) {
736 return Err(format!(
737 "Invalid shell: \"{}\". Valid options are: {}",
738 options.shell,
739 VALID_SHELLS.join(", ")
740 ));
741 }
742
743 if let Some(ref session_id) = options.session_id {
745 if !is_valid_uuid(session_id) {
746 return Err(format!(
747 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
748 session_id
749 ));
750 }
751 }
752
753 Ok(())
754}
755
756pub fn generate_session_name(prefix: Option<&str>) -> String {
758 use std::cell::RefCell;
759 use std::time::{SystemTime, UNIX_EPOCH};
760
761 thread_local! {
762 static STATE: RefCell<u64> = RefCell::new(
763 SystemTime::now()
764 .duration_since(UNIX_EPOCH)
765 .unwrap()
766 .as_nanos() as u64
767 );
768 }
769
770 fn next_random() -> u64 {
771 STATE.with(|state| {
772 let mut s = state.borrow_mut();
773 *s ^= *s << 13;
774 *s ^= *s >> 7;
775 *s ^= *s << 17;
776 *s
777 })
778 }
779
780 let prefix = prefix.unwrap_or("start");
781 let timestamp = chrono::Utc::now().timestamp_millis();
782 let random: String = (0..6)
783 .map(|_| {
784 let idx = (next_random() % 36) as u8;
785 if idx < 10 {
786 (b'0' + idx) as char
787 } else {
788 (b'a' + idx - 10) as char
789 }
790 })
791 .collect();
792 format!("{}-{}-{}", prefix, timestamp, random)
793}
794
795pub fn has_isolation(options: &WrapperOptions) -> bool {
797 options.isolated.is_some()
798}
799
800pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
803 if options.detached {
804 "detached"
805 } else {
806 "attached"
808 }
809}
810
811#[cfg(test)]
812#[path = "args_parser_cases.rs"]
813mod tests;