1use std::env;
41
42use crate::args_parser_queries::{parse_query_option, validate_query_options};
43use crate::docker_recovery_options::{
44 parse_docker_recovery_option, validate_docker_recovery_options,
45};
46use crate::isolation::get_default_docker_image;
47
48pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
50
51pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
53
54pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
56
57const 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}$";
59
60pub fn is_valid_uuid(s: &str) -> bool {
62 regex::Regex::new(UUID_REGEX)
63 .map(|re| re.is_match(&s.to_lowercase()))
64 .unwrap_or(false)
65}
66
67pub fn generate_uuid() -> String {
69 uuid::Uuid::new_v4().to_string()
70}
71
72#[derive(Debug, Clone)]
74pub struct WrapperOptions {
75 pub isolated: Option<String>,
77 pub attached: bool,
79 pub detached: bool,
81 pub session: Option<String>,
83 pub session_id: Option<String>,
85 pub image: Option<String>,
87 pub volumes: Vec<String>,
89 pub mounts: Vec<String>,
91 pub env: Vec<String>,
93 pub labels: Vec<String>,
95 pub help: bool,
96 pub no_sanitize: bool,
97 pub remove_original: bool,
98 pub privileged: bool,
100 pub init: Option<bool>,
102 pub network: Option<String>,
104 pub networks: Vec<String>,
106 pub network_aliases: Vec<String>,
108 pub on_kill_resume: Option<u32>,
110 pub recovery_command: Option<String>,
112 pub on_kill_resume_delay: Option<String>,
114 pub cpu_penalty: bool,
115 pub cpu_penalty_config: Option<crate::cpu_penalty::Config>,
116 pub memory: Option<String>,
117 pub memory_swap: Option<String>,
118 pub memory_reservation: Option<String>,
119 pub cpus: Option<String>,
120 pub on_kill_resume_memory: Option<String>,
121 pub resource_limits: Vec<String>,
122 pub resolved_limits: serde_json::Value,
123 pub endpoint: Option<String>,
125 pub user: bool,
127 pub user_name: Option<String>,
129 pub keep_user: bool,
131 pub keep_alive: bool,
133 pub auto_remove_docker_container: bool,
135 pub always_cleanup_container: bool,
137 pub keep_container: bool,
139 pub keep_container_on_fail: bool,
141 pub shell: String,
143 pub use_command_stream: bool,
145 pub status: Option<String>,
147 pub list: bool,
149 pub running: bool,
151 pub upload_log: Option<String>,
153 pub output_format: Option<String>,
155 pub stop: Option<String>,
157 pub terminate: Option<String>,
159 pub attach: Option<String>,
161 pub read_only: bool,
163 pub resume: Option<String>,
165 pub resume_all: bool,
167 pub cleanup: bool,
169 pub cleanup_dry_run: bool,
171 pub cleanup_target: Option<String>,
173 pub alias: Option<String>,
175 pub rename: Option<String>,
177 pub new_name: Option<String>,
179 pub append_attempt: bool,
181 pub continues: Option<String>,
183 pub snapshot: crate::snapshot_resume_options::SnapshotResumeOptions,
185}
186
187impl Default for WrapperOptions {
188 fn default() -> Self {
189 WrapperOptions {
190 isolated: None,
191 attached: false,
192 detached: false,
193 session: None,
194 session_id: None,
195 image: None,
196 volumes: Vec::new(),
197 mounts: Vec::new(),
198 env: Vec::new(),
199 labels: Vec::new(),
200 help: false,
201 no_sanitize: false,
202 remove_original: false,
203 privileged: false,
204 init: None,
205 network: None,
206 networks: Vec::new(),
207 network_aliases: Vec::new(),
208 on_kill_resume: None,
209 recovery_command: None,
210 on_kill_resume_delay: None,
211 cpu_penalty: false,
212 cpu_penalty_config: None,
213 memory: None,
214 memory_swap: None,
215 memory_reservation: None,
216 cpus: None,
217 on_kill_resume_memory: None,
218 resource_limits: Vec::new(),
219 resolved_limits: serde_json::json!({}),
220 endpoint: None,
221 user: false,
222 user_name: None,
223 keep_user: false,
224 keep_alive: false,
225 auto_remove_docker_container: false,
226 always_cleanup_container: false,
227 keep_container: false,
228 keep_container_on_fail: false,
229 shell: "auto".to_string(),
230 use_command_stream: false,
231 status: None,
232 list: false,
233 running: false,
234 upload_log: None,
235 output_format: None,
236 stop: None,
237 terminate: None,
238 attach: None,
239 read_only: false,
240 resume: None,
241 resume_all: false,
242 cleanup: false,
243 cleanup_dry_run: false,
244 cleanup_target: None,
245 alias: None,
246 rename: None,
247 new_name: None,
248 append_attempt: false,
249 continues: None,
250 snapshot: Default::default(),
251 }
252 }
253}
254
255#[derive(Debug)]
257pub struct ParsedArgs {
258 pub wrapper_options: WrapperOptions,
260 pub command: String,
263 pub raw_command: Vec<String>,
265}
266
267pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
269 let mut wrapper_options = WrapperOptions::default();
270 let mut command_args: Vec<String> = Vec::new();
271
272 let separator_index = args.iter().position(|a| a == "--");
274
275 if let Some(sep_idx) = separator_index {
276 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
278 command_args = args[sep_idx + 1..].to_vec();
279 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
280 } else {
281 let mut i = 0;
283 while i < args.len() {
284 let arg = &args[i];
285 if arg.starts_with('-') {
286 match parse_option(args, i, &mut wrapper_options)? {
287 0 => {
288 return Err(format!("Unknown wrapper option: {}", arg));
289 }
290 consumed => {
291 i += consumed;
292 }
293 }
294 } else {
295 command_args = args[i..].to_vec();
297 break;
298 }
299 }
300 }
301
302 if !wrapper_options.help {
304 validate_options(&mut wrapper_options)?;
305 }
306
307 Ok(ParsedArgs {
308 wrapper_options,
309 command: crate::isolation::build_command_string(&command_args),
310 raw_command: command_args,
311 })
312}
313
314fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
316 let mut i = 0;
317 while i < args.len() {
318 match parse_option(args, i, options)? {
319 0 => {
320 if args[i].starts_with('-') {
321 return Err(format!("Unknown wrapper option: {}", args[i]));
322 }
323 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
324 eprintln!("Unknown wrapper option: {}", args[i]);
325 }
326 i += 1;
327 }
328 consumed => {
329 i += consumed;
330 }
331 }
332 }
333 Ok(())
334}
335
336fn parse_option(
339 args: &[String],
340 index: usize,
341 options: &mut WrapperOptions,
342) -> Result<usize, String> {
343 let arg = &args[index];
344
345 if arg == "--help" || arg == "-h" {
346 options.help = true;
347 return Ok(1);
348 }
349 if arg == "--no-sanitize" {
350 options.no_sanitize = true;
351 return Ok(1);
352 }
353 if arg == "--remove-original" {
354 options.remove_original = true;
355 return Ok(1);
356 }
357 if arg == "--label" || arg.starts_with("--label=") {
358 let label = if arg == "--label" {
359 args.get(index + 1).map(String::as_str).unwrap_or("")
360 } else {
361 &arg[8..]
362 };
363 let Some((key, _)) = label.split_once('=') else {
364 return Err("--label requires KEY=VALUE".to_string());
365 };
366 if key.is_empty() || key.chars().any(char::is_whitespace) {
367 return Err("--label requires a nonempty key without whitespace".to_string());
368 }
369 if key.starts_with("start-command.") {
370 return Err(
371 "start-command.* labels are reserved for execution attribution".to_string(),
372 );
373 }
374 options.labels.push(label.to_string());
375 return Ok(if arg == "--label" { 2 } else { 1 });
376 }
377
378 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
380 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
381 options.isolated = Some(args[index + 1].to_lowercase());
382 return Ok(2);
383 } else {
384 return Err(format!(
385 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
386 arg
387 ));
388 }
389 }
390
391 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
393 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
394 return Ok(1);
395 }
396
397 if arg == "--attached" || arg == "-a" {
399 options.attached = true;
400 return Ok(1);
401 }
402
403 if arg == "--detached" || arg == "-d" {
405 options.detached = true;
406 return Ok(1);
407 }
408
409 if arg == "--session" || arg == "-s" {
411 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
412 options.session = Some(args[index + 1].clone());
413 return Ok(2);
414 } else {
415 return Err(format!("Option {} requires a session name argument", arg));
416 }
417 }
418
419 if arg.starts_with("--session=") {
421 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
422 return Ok(1);
423 }
424
425 if arg == "--image" {
427 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
428 options.image = Some(args[index + 1].clone());
429 return Ok(2);
430 } else {
431 return Err(format!("Option {} requires an image name argument", arg));
432 }
433 }
434
435 if arg.starts_with("--image=") {
437 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
438 return Ok(1);
439 }
440
441 if arg == "--volume" || arg == "-v" {
443 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
444 options.volumes.push(args[index + 1].clone());
445 return Ok(2);
446 } else {
447 return Err(format!(
448 "Option {} requires a volume argument (host:container[:mode])",
449 arg
450 ));
451 }
452 }
453
454 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
456 options
457 .volumes
458 .push(arg[arg.find('=').unwrap() + 1..].to_string());
459 return Ok(1);
460 }
461
462 if arg == "--mount" {
464 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
465 options.mounts.push(args[index + 1].clone());
466 return Ok(2);
467 } else {
468 return Err(format!("Option {} requires a mount spec argument", arg));
469 }
470 }
471
472 if let Some(value) = arg.strip_prefix("--mount=") {
474 options.mounts.push(value.to_string());
475 return Ok(1);
476 }
477
478 if arg == "--env" || arg == "-e" {
480 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
481 options.env.push(args[index + 1].clone());
482 return Ok(2);
483 } else {
484 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
485 }
486 }
487
488 if arg.starts_with("--env=") || arg.starts_with("-e=") {
490 options
491 .env
492 .push(arg[arg.find('=').unwrap() + 1..].to_string());
493 return Ok(1);
494 }
495
496 if arg == "--privileged" {
498 options.privileged = true;
499 return Ok(1);
500 }
501
502 let init_consumed = crate::docker_init_option::parse(args, index, options);
504 if init_consumed > 0 {
505 return Ok(init_consumed);
506 }
507
508 if arg == "--network" {
510 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
511 let network = args[index + 1].clone();
512 options.networks.push(network.clone());
513 if options.network.is_none() {
514 options.network = Some(network);
515 }
516 return Ok(2);
517 }
518 return Err(format!("Option {} requires a network name argument", arg));
519 }
520
521 if let Some(value) = arg.strip_prefix("--network=") {
523 let network = value.to_string();
524 options.networks.push(network.clone());
525 if options.network.is_none() {
526 options.network = Some(network);
527 }
528 return Ok(1);
529 }
530
531 if arg == "--network-alias" {
533 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
534 options.network_aliases.push(args[index + 1].clone());
535 return Ok(2);
536 }
537 return Err(format!("Option {} requires an alias argument", arg));
538 }
539
540 if let Some(value) = arg.strip_prefix("--network-alias=") {
542 options.network_aliases.push(value.to_string());
543 return Ok(1);
544 }
545
546 if arg == "--endpoint" {
548 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
549 options.endpoint = Some(args[index + 1].clone());
550 return Ok(2);
551 } else {
552 return Err(format!("Option {} requires an endpoint argument", arg));
553 }
554 }
555
556 if arg.starts_with("--endpoint=") {
558 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
559 return Ok(1);
560 }
561
562 if arg == "--isolated-user" || arg == "-u" {
564 options.user = true;
565 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
567 let next_arg = &args[index + 1];
568 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
570 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
571 options.user_name = Some(next_arg.clone());
572 return Ok(2);
573 }
574 }
575 return Ok(1);
576 }
577
578 if arg.starts_with("--isolated-user=") {
580 options.user = true;
581 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
582 return Ok(1);
583 }
584
585 if arg == "--keep-user" {
587 options.keep_user = true;
588 return Ok(1);
589 }
590
591 if arg == "--keep-alive" || arg == "-k" {
593 options.keep_alive = true;
594 return Ok(1);
595 }
596
597 if arg == "--auto-remove-docker-container" {
599 options.auto_remove_docker_container = true;
600 return Ok(1);
601 }
602
603 if arg == "--always-cleanup-container" {
605 options.always_cleanup_container = true;
606 return Ok(1);
607 }
608
609 if arg == "--keep-container" {
611 options.keep_container = true;
612 return Ok(1);
613 }
614
615 if arg == "--keep-container-on-fail" {
617 options.keep_container_on_fail = true;
618 return Ok(1);
619 }
620
621 if arg == "--shell" {
623 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
624 options.shell = args[index + 1].to_lowercase();
625 return Ok(2);
626 } else {
627 return Err(format!(
628 "Option {} requires a shell argument (auto, bash, zsh, sh)",
629 arg
630 ));
631 }
632 }
633
634 if arg.starts_with("--shell=") {
636 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
637 return Ok(1);
638 }
639
640 if arg == "--use-command-stream" {
642 options.use_command_stream = true;
643 return Ok(1);
644 }
645
646 if arg == "--session-id" || arg == "--session-name" {
648 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
649 options.session_id = Some(args[index + 1].clone());
650 return Ok(2);
651 } else {
652 return Err(format!("Option {} requires a UUID argument", arg));
653 }
654 }
655
656 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
658 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
659 return Ok(1);
660 }
661
662 let cpu_consumed = crate::cpu_penalty::parse(args, index, options)?;
663 if cpu_consumed > 0 {
664 return Ok(cpu_consumed);
665 }
666 let resource_consumed = crate::docker_resource_options::parse(args, index, options)?;
667 if resource_consumed > 0 {
668 return Ok(resource_consumed);
669 }
670 let recovery_consumed = parse_docker_recovery_option(args, index, options)?;
672 if recovery_consumed > 0 {
673 return Ok(recovery_consumed);
674 }
675
676 let snapshot_consumed = crate::snapshot_resume_options::parse(args, index, options)?;
678 if snapshot_consumed > 0 {
679 return Ok(snapshot_consumed);
680 }
681
682 let query_consumed = parse_query_option(args, index, options)?;
684 if query_consumed > 0 {
685 return Ok(query_consumed);
686 }
687
688 let identity_consumed = crate::launch_identity_options::parse(args, index, options)?;
690 if identity_consumed > 0 {
691 return Ok(identity_consumed);
692 }
693
694 Ok(0)
696}
697
698pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
700 if options.no_sanitize && options.upload_log.is_none() {
701 return Err("--no-sanitize is only valid with --upload-log".to_string());
702 }
703 if options.remove_original && options.resume.is_none() {
704 return Err("--remove-original is only valid with --resume".to_string());
705 }
706 if !options.labels.is_empty()
707 && options.isolated.as_deref() != Some("docker")
708 && options.resume.is_none()
709 {
710 return Err("--label is only valid with docker isolation or --resume".to_string());
711 }
712 if options.attached && options.detached {
714 return Err(
715 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
716 .to_string(),
717 );
718 }
719
720 if let Some(ref backend) = options.isolated {
722 if !VALID_BACKENDS.contains(&backend.as_str()) {
723 return Err(format!(
724 "Invalid isolation backend: \"{}\". Valid options are: {}",
725 backend,
726 VALID_BACKENDS.join(", ")
727 ));
728 }
729
730 if backend == "docker" && options.image.is_none() {
732 options.image = Some(get_default_docker_image());
733 }
734
735 if backend == "ssh" && options.endpoint.is_none() {
737 return Err(
738 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
739 .to_string(),
740 );
741 }
742 }
743
744 if options.session.is_some() && options.isolated.is_none() {
746 return Err("--session option is only valid with --isolated".to_string());
747 }
748
749 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
751 return Err("--image option is only valid with --isolated docker".to_string());
752 }
753
754 let is_docker = options.isolated.as_deref() == Some("docker");
756 if !options.volumes.is_empty() && !is_docker {
757 return Err("--volume option is only valid with --isolated docker".to_string());
758 }
759 if !options.mounts.is_empty() && !is_docker {
760 return Err("--mount option is only valid with --isolated docker".to_string());
761 }
762 if !options.env.is_empty() && !is_docker {
763 return Err("--env option is only valid with --isolated docker".to_string());
764 }
765 if options.privileged && !is_docker {
766 return Err("--privileged option is only valid with --isolated docker".to_string());
767 }
768 if options.network.is_some() && !is_docker {
769 return Err("--network option is only valid with --isolated docker".to_string());
770 }
771 if !options.network_aliases.is_empty() && !is_docker {
772 return Err("--network-alias option is only valid with --isolated docker".to_string());
773 }
774
775 validate_docker_recovery_options(options)?;
776
777 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
779 return Err("--endpoint option is only valid with --isolated ssh".to_string());
780 }
781
782 if options.keep_alive && options.isolated.is_none() {
784 return Err("--keep-alive option is only valid with --isolated".to_string());
785 }
786
787 let cleanup_flags = [
789 (
790 "--auto-remove-docker-container",
791 options.auto_remove_docker_container,
792 ),
793 (
794 "--always-cleanup-container",
795 options.always_cleanup_container,
796 ),
797 ("--keep-container", options.keep_container),
798 ("--keep-container-on-fail", options.keep_container_on_fail),
799 ];
800 for (flag, enabled) in cleanup_flags {
801 if enabled && !is_docker {
802 return Err(format!(
803 "{} option is only valid with --isolated docker",
804 flag
805 ));
806 }
807 }
808
809 let selected_cleanup_policies = [
810 options.auto_remove_docker_container || options.always_cleanup_container,
811 options.keep_container,
812 options.keep_container_on_fail,
813 ]
814 .into_iter()
815 .filter(|enabled| *enabled)
816 .count();
817 if selected_cleanup_policies > 1 {
818 return Err(
819 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
820 .to_string(),
821 );
822 }
823
824 if options.user {
826 if options.isolated.as_deref() == Some("docker") {
828 return Err(
829 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
830 .to_string(),
831 );
832 }
833 if let Some(ref username) = options.user_name {
835 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
836 if !username_regex.is_match(username) {
837 return Err(format!(
838 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
839 username
840 ));
841 }
842 if username.len() > 32 {
843 return Err(format!(
844 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
845 username
846 ));
847 }
848 }
849 }
850
851 if options.keep_user && !options.user {
853 return Err("--keep-user option is only valid with --isolated-user".to_string());
854 }
855
856 crate::snapshot_resume_options::validate(options)?;
857 validate_query_options(options)?;
859 crate::launch_identity_options::validate(options)?;
860 crate::docker_resource_options::validate(options)?;
861 crate::docker_init_option::validate(options)?;
862 crate::cpu_penalty::validate(options)?;
863
864 if !VALID_SHELLS.contains(&options.shell.as_str()) {
866 return Err(format!(
867 "Invalid shell: \"{}\". Valid options are: {}",
868 options.shell,
869 VALID_SHELLS.join(", ")
870 ));
871 }
872
873 if let Some(ref session_id) = options.session_id {
875 if !is_valid_uuid(session_id) {
876 return Err(format!(
877 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
878 session_id
879 ));
880 }
881 }
882
883 Ok(())
884}
885
886pub fn generate_session_name(prefix: Option<&str>) -> String {
888 use std::cell::RefCell;
889 use std::time::{SystemTime, UNIX_EPOCH};
890
891 thread_local! {
892 static STATE: RefCell<u64> = RefCell::new(
893 SystemTime::now()
894 .duration_since(UNIX_EPOCH)
895 .unwrap()
896 .as_nanos() as u64
897 );
898 }
899
900 fn next_random() -> u64 {
901 STATE.with(|state| {
902 let mut s = state.borrow_mut();
903 *s ^= *s << 13;
904 *s ^= *s >> 7;
905 *s ^= *s << 17;
906 *s
907 })
908 }
909
910 let prefix = prefix.unwrap_or("start");
911 let timestamp = chrono::Utc::now().timestamp_millis();
912 let random: String = (0..6)
913 .map(|_| {
914 let idx = (next_random() % 36) as u8;
915 if idx < 10 {
916 (b'0' + idx) as char
917 } else {
918 (b'a' + idx - 10) as char
919 }
920 })
921 .collect();
922 format!("{}-{}-{}", prefix, timestamp, random)
923}
924
925pub fn has_isolation(options: &WrapperOptions) -> bool {
927 options.isolated.is_some()
928}
929
930pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
933 if options.detached {
934 "detached"
935 } else {
936 "attached"
938 }
939}
940
941#[cfg(test)]
942#[path = "args_parser_cases.rs"]
943mod tests;