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