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