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 cpu_penalty: bool,
107 pub cpu_penalty_config: Option<crate::cpu_penalty::Config>,
108 pub memory: Option<String>,
109 pub memory_swap: Option<String>,
110 pub cpus: Option<String>,
111 pub on_kill_resume_memory: Option<String>,
112 pub resource_limits: Vec<String>,
113 pub resolved_limits: serde_json::Value,
114 pub endpoint: Option<String>,
116 pub user: bool,
118 pub user_name: Option<String>,
120 pub keep_user: bool,
122 pub keep_alive: bool,
124 pub auto_remove_docker_container: bool,
126 pub always_cleanup_container: bool,
128 pub keep_container: bool,
130 pub keep_container_on_fail: bool,
132 pub shell: String,
134 pub use_command_stream: bool,
136 pub status: Option<String>,
138 pub list: bool,
140 pub running: bool,
142 pub upload_log: Option<String>,
144 pub output_format: Option<String>,
146 pub stop: Option<String>,
148 pub terminate: Option<String>,
150 pub attach: Option<String>,
152 pub read_only: bool,
154 pub resume: Option<String>,
156 pub resume_all: bool,
158 pub cleanup: bool,
160 pub cleanup_dry_run: bool,
162}
163
164impl Default for WrapperOptions {
165 fn default() -> Self {
166 WrapperOptions {
167 isolated: None,
168 attached: false,
169 detached: false,
170 session: None,
171 session_id: None,
172 image: None,
173 volumes: Vec::new(),
174 mounts: Vec::new(),
175 env: Vec::new(),
176 privileged: false,
177 network: None,
178 networks: Vec::new(),
179 network_aliases: Vec::new(),
180 on_kill_resume: None,
181 recovery_command: None,
182 on_kill_resume_delay: None,
183 cpu_penalty: false,
184 cpu_penalty_config: None,
185 memory: None,
186 memory_swap: None,
187 cpus: None,
188 on_kill_resume_memory: None,
189 resource_limits: Vec::new(),
190 resolved_limits: serde_json::json!({}),
191 endpoint: None,
192 user: false,
193 user_name: None,
194 keep_user: false,
195 keep_alive: false,
196 auto_remove_docker_container: false,
197 always_cleanup_container: false,
198 keep_container: false,
199 keep_container_on_fail: false,
200 shell: "auto".to_string(),
201 use_command_stream: false,
202 status: None,
203 list: false,
204 running: false,
205 upload_log: None,
206 output_format: None,
207 stop: None,
208 terminate: None,
209 attach: None,
210 read_only: false,
211 resume: None,
212 resume_all: false,
213 cleanup: false,
214 cleanup_dry_run: false,
215 }
216 }
217}
218
219#[derive(Debug)]
221pub struct ParsedArgs {
222 pub wrapper_options: WrapperOptions,
224 pub command: String,
227 pub raw_command: Vec<String>,
229}
230
231pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
233 let mut wrapper_options = WrapperOptions::default();
234 let mut command_args: Vec<String> = Vec::new();
235
236 let separator_index = args.iter().position(|a| a == "--");
238
239 if let Some(sep_idx) = separator_index {
240 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
242 command_args = args[sep_idx + 1..].to_vec();
243 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
244 } else {
245 let mut i = 0;
247 while i < args.len() {
248 let arg = &args[i];
249 if arg.starts_with('-') {
250 match parse_option(args, i, &mut wrapper_options)? {
251 0 => {
252 return Err(format!("Unknown wrapper option: {}", arg));
253 }
254 consumed => {
255 i += consumed;
256 }
257 }
258 } else {
259 command_args = args[i..].to_vec();
261 break;
262 }
263 }
264 }
265
266 validate_options(&mut wrapper_options)?;
268
269 Ok(ParsedArgs {
270 wrapper_options,
271 command: crate::isolation::build_command_string(&command_args),
272 raw_command: command_args,
273 })
274}
275
276fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
278 let mut i = 0;
279 while i < args.len() {
280 match parse_option(args, i, options)? {
281 0 => {
282 if args[i].starts_with('-') {
283 return Err(format!("Unknown wrapper option: {}", args[i]));
284 }
285 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
286 eprintln!("Unknown wrapper option: {}", args[i]);
287 }
288 i += 1;
289 }
290 consumed => {
291 i += consumed;
292 }
293 }
294 }
295 Ok(())
296}
297
298fn parse_option(
301 args: &[String],
302 index: usize,
303 options: &mut WrapperOptions,
304) -> Result<usize, String> {
305 let arg = &args[index];
306
307 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
309 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
310 options.isolated = Some(args[index + 1].to_lowercase());
311 return Ok(2);
312 } else {
313 return Err(format!(
314 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
315 arg
316 ));
317 }
318 }
319
320 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
322 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
323 return Ok(1);
324 }
325
326 if arg == "--attached" || arg == "-a" {
328 options.attached = true;
329 return Ok(1);
330 }
331
332 if arg == "--detached" || arg == "-d" {
334 options.detached = true;
335 return Ok(1);
336 }
337
338 if arg == "--session" || arg == "-s" {
340 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
341 options.session = Some(args[index + 1].clone());
342 return Ok(2);
343 } else {
344 return Err(format!("Option {} requires a session name argument", arg));
345 }
346 }
347
348 if arg.starts_with("--session=") {
350 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
351 return Ok(1);
352 }
353
354 if arg == "--image" {
356 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
357 options.image = Some(args[index + 1].clone());
358 return Ok(2);
359 } else {
360 return Err(format!("Option {} requires an image name argument", arg));
361 }
362 }
363
364 if arg.starts_with("--image=") {
366 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
367 return Ok(1);
368 }
369
370 if arg == "--volume" || arg == "-v" {
372 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
373 options.volumes.push(args[index + 1].clone());
374 return Ok(2);
375 } else {
376 return Err(format!(
377 "Option {} requires a volume argument (host:container[:mode])",
378 arg
379 ));
380 }
381 }
382
383 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
385 options
386 .volumes
387 .push(arg[arg.find('=').unwrap() + 1..].to_string());
388 return Ok(1);
389 }
390
391 if arg == "--mount" {
393 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
394 options.mounts.push(args[index + 1].clone());
395 return Ok(2);
396 } else {
397 return Err(format!("Option {} requires a mount spec argument", arg));
398 }
399 }
400
401 if let Some(value) = arg.strip_prefix("--mount=") {
403 options.mounts.push(value.to_string());
404 return Ok(1);
405 }
406
407 if arg == "--env" || arg == "-e" {
409 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
410 options.env.push(args[index + 1].clone());
411 return Ok(2);
412 } else {
413 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
414 }
415 }
416
417 if arg.starts_with("--env=") || arg.starts_with("-e=") {
419 options
420 .env
421 .push(arg[arg.find('=').unwrap() + 1..].to_string());
422 return Ok(1);
423 }
424
425 if arg == "--privileged" {
427 options.privileged = true;
428 return Ok(1);
429 }
430
431 if arg == "--network" {
433 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
434 let network = args[index + 1].clone();
435 options.networks.push(network.clone());
436 if options.network.is_none() {
437 options.network = Some(network);
438 }
439 return Ok(2);
440 }
441 return Err(format!("Option {} requires a network name argument", arg));
442 }
443
444 if let Some(value) = arg.strip_prefix("--network=") {
446 let network = value.to_string();
447 options.networks.push(network.clone());
448 if options.network.is_none() {
449 options.network = Some(network);
450 }
451 return Ok(1);
452 }
453
454 if arg == "--network-alias" {
456 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
457 options.network_aliases.push(args[index + 1].clone());
458 return Ok(2);
459 }
460 return Err(format!("Option {} requires an alias argument", arg));
461 }
462
463 if let Some(value) = arg.strip_prefix("--network-alias=") {
465 options.network_aliases.push(value.to_string());
466 return Ok(1);
467 }
468
469 if arg == "--endpoint" {
471 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
472 options.endpoint = Some(args[index + 1].clone());
473 return Ok(2);
474 } else {
475 return Err(format!("Option {} requires an endpoint argument", arg));
476 }
477 }
478
479 if arg.starts_with("--endpoint=") {
481 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
482 return Ok(1);
483 }
484
485 if arg == "--isolated-user" || arg == "-u" {
487 options.user = true;
488 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
490 let next_arg = &args[index + 1];
491 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
493 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
494 options.user_name = Some(next_arg.clone());
495 return Ok(2);
496 }
497 }
498 return Ok(1);
499 }
500
501 if arg.starts_with("--isolated-user=") {
503 options.user = true;
504 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
505 return Ok(1);
506 }
507
508 if arg == "--keep-user" {
510 options.keep_user = true;
511 return Ok(1);
512 }
513
514 if arg == "--keep-alive" || arg == "-k" {
516 options.keep_alive = true;
517 return Ok(1);
518 }
519
520 if arg == "--auto-remove-docker-container" {
522 options.auto_remove_docker_container = true;
523 return Ok(1);
524 }
525
526 if arg == "--always-cleanup-container" {
528 options.always_cleanup_container = true;
529 return Ok(1);
530 }
531
532 if arg == "--keep-container" {
534 options.keep_container = true;
535 return Ok(1);
536 }
537
538 if arg == "--keep-container-on-fail" {
540 options.keep_container_on_fail = true;
541 return Ok(1);
542 }
543
544 if arg == "--shell" {
546 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
547 options.shell = args[index + 1].to_lowercase();
548 return Ok(2);
549 } else {
550 return Err(format!(
551 "Option {} requires a shell argument (auto, bash, zsh, sh)",
552 arg
553 ));
554 }
555 }
556
557 if arg.starts_with("--shell=") {
559 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
560 return Ok(1);
561 }
562
563 if arg == "--use-command-stream" {
565 options.use_command_stream = true;
566 return Ok(1);
567 }
568
569 if arg == "--session-id" || arg == "--session-name" {
571 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
572 options.session_id = Some(args[index + 1].clone());
573 return Ok(2);
574 } else {
575 return Err(format!("Option {} requires a UUID argument", arg));
576 }
577 }
578
579 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
581 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
582 return Ok(1);
583 }
584
585 let cpu_consumed = crate::cpu_penalty::parse(args, index, options)?;
586 if cpu_consumed > 0 {
587 return Ok(cpu_consumed);
588 }
589 let resource_consumed = crate::docker_resource_options::parse(args, index, options)?;
590 if resource_consumed > 0 {
591 return Ok(resource_consumed);
592 }
593 let recovery_consumed = parse_docker_recovery_option(args, index, options)?;
595 if recovery_consumed > 0 {
596 return Ok(recovery_consumed);
597 }
598
599 let query_consumed = parse_query_option(args, index, options)?;
601 if query_consumed > 0 {
602 return Ok(query_consumed);
603 }
604
605 Ok(0)
607}
608
609pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
611 if options.attached && options.detached {
613 return Err(
614 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
615 .to_string(),
616 );
617 }
618
619 if let Some(ref backend) = options.isolated {
621 if !VALID_BACKENDS.contains(&backend.as_str()) {
622 return Err(format!(
623 "Invalid isolation backend: \"{}\". Valid options are: {}",
624 backend,
625 VALID_BACKENDS.join(", ")
626 ));
627 }
628
629 if backend == "docker" && options.image.is_none() {
631 options.image = Some(get_default_docker_image());
632 }
633
634 if backend == "ssh" && options.endpoint.is_none() {
636 return Err(
637 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
638 .to_string(),
639 );
640 }
641 }
642
643 if options.session.is_some() && options.isolated.is_none() {
645 return Err("--session option is only valid with --isolated".to_string());
646 }
647
648 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
650 return Err("--image option is only valid with --isolated docker".to_string());
651 }
652
653 let is_docker = options.isolated.as_deref() == Some("docker");
655 if !options.volumes.is_empty() && !is_docker {
656 return Err("--volume option is only valid with --isolated docker".to_string());
657 }
658 if !options.mounts.is_empty() && !is_docker {
659 return Err("--mount option is only valid with --isolated docker".to_string());
660 }
661 if !options.env.is_empty() && !is_docker {
662 return Err("--env option is only valid with --isolated docker".to_string());
663 }
664 if options.privileged && !is_docker {
665 return Err("--privileged option is only valid with --isolated docker".to_string());
666 }
667 if options.network.is_some() && !is_docker {
668 return Err("--network option is only valid with --isolated docker".to_string());
669 }
670 if !options.network_aliases.is_empty() && !is_docker {
671 return Err("--network-alias option is only valid with --isolated docker".to_string());
672 }
673
674 validate_docker_recovery_options(options)?;
675
676 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
678 return Err("--endpoint option is only valid with --isolated ssh".to_string());
679 }
680
681 if options.keep_alive && options.isolated.is_none() {
683 return Err("--keep-alive option is only valid with --isolated".to_string());
684 }
685
686 let cleanup_flags = [
688 (
689 "--auto-remove-docker-container",
690 options.auto_remove_docker_container,
691 ),
692 (
693 "--always-cleanup-container",
694 options.always_cleanup_container,
695 ),
696 ("--keep-container", options.keep_container),
697 ("--keep-container-on-fail", options.keep_container_on_fail),
698 ];
699 for (flag, enabled) in cleanup_flags {
700 if enabled && !is_docker {
701 return Err(format!(
702 "{} option is only valid with --isolated docker",
703 flag
704 ));
705 }
706 }
707
708 let selected_cleanup_policies = [
709 options.auto_remove_docker_container || options.always_cleanup_container,
710 options.keep_container,
711 options.keep_container_on_fail,
712 ]
713 .into_iter()
714 .filter(|enabled| *enabled)
715 .count();
716 if selected_cleanup_policies > 1 {
717 return Err(
718 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
719 .to_string(),
720 );
721 }
722
723 if options.user {
725 if options.isolated.as_deref() == Some("docker") {
727 return Err(
728 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
729 .to_string(),
730 );
731 }
732 if let Some(ref username) = options.user_name {
734 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
735 if !username_regex.is_match(username) {
736 return Err(format!(
737 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
738 username
739 ));
740 }
741 if username.len() > 32 {
742 return Err(format!(
743 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
744 username
745 ));
746 }
747 }
748 }
749
750 if options.keep_user && !options.user {
752 return Err("--keep-user option is only valid with --isolated-user".to_string());
753 }
754
755 validate_query_options(options)?;
757 crate::docker_resource_options::validate(options)?;
758 crate::cpu_penalty::validate(options)?;
759
760 if !VALID_SHELLS.contains(&options.shell.as_str()) {
762 return Err(format!(
763 "Invalid shell: \"{}\". Valid options are: {}",
764 options.shell,
765 VALID_SHELLS.join(", ")
766 ));
767 }
768
769 if let Some(ref session_id) = options.session_id {
771 if !is_valid_uuid(session_id) {
772 return Err(format!(
773 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
774 session_id
775 ));
776 }
777 }
778
779 Ok(())
780}
781
782pub fn generate_session_name(prefix: Option<&str>) -> String {
784 use std::cell::RefCell;
785 use std::time::{SystemTime, UNIX_EPOCH};
786
787 thread_local! {
788 static STATE: RefCell<u64> = RefCell::new(
789 SystemTime::now()
790 .duration_since(UNIX_EPOCH)
791 .unwrap()
792 .as_nanos() as u64
793 );
794 }
795
796 fn next_random() -> u64 {
797 STATE.with(|state| {
798 let mut s = state.borrow_mut();
799 *s ^= *s << 13;
800 *s ^= *s >> 7;
801 *s ^= *s << 17;
802 *s
803 })
804 }
805
806 let prefix = prefix.unwrap_or("start");
807 let timestamp = chrono::Utc::now().timestamp_millis();
808 let random: String = (0..6)
809 .map(|_| {
810 let idx = (next_random() % 36) as u8;
811 if idx < 10 {
812 (b'0' + idx) as char
813 } else {
814 (b'a' + idx - 10) as char
815 }
816 })
817 .collect();
818 format!("{}-{}-{}", prefix, timestamp, random)
819}
820
821pub fn has_isolation(options: &WrapperOptions) -> bool {
823 options.isolated.is_some()
824}
825
826pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
829 if options.detached {
830 "detached"
831 } else {
832 "attached"
834 }
835}
836
837#[cfg(test)]
838#[path = "args_parser_cases.rs"]
839mod tests;