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