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