1use std::env;
35
36use crate::isolation::get_default_docker_image;
37
38pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
40
41pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
43
44pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
46
47const 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}$";
49
50pub fn is_valid_uuid(s: &str) -> bool {
52 regex::Regex::new(UUID_REGEX)
53 .map(|re| re.is_match(&s.to_lowercase()))
54 .unwrap_or(false)
55}
56
57pub fn generate_uuid() -> String {
59 uuid::Uuid::new_v4().to_string()
60}
61
62#[derive(Debug, Clone)]
64pub struct WrapperOptions {
65 pub isolated: Option<String>,
67 pub attached: bool,
69 pub detached: bool,
71 pub session: Option<String>,
73 pub session_id: Option<String>,
75 pub image: Option<String>,
77 pub volumes: Vec<String>,
79 pub mounts: Vec<String>,
81 pub env: Vec<String>,
83 pub privileged: bool,
85 pub network: Option<String>,
87 pub network_aliases: Vec<String>,
89 pub endpoint: Option<String>,
91 pub user: bool,
93 pub user_name: Option<String>,
95 pub keep_user: bool,
97 pub keep_alive: bool,
99 pub auto_remove_docker_container: bool,
101 pub always_cleanup_container: bool,
103 pub keep_container: bool,
105 pub keep_container_on_fail: bool,
107 pub shell: String,
109 pub use_command_stream: bool,
111 pub status: Option<String>,
113 pub list: bool,
115 pub upload_log: Option<String>,
117 pub output_format: Option<String>,
119 pub stop: Option<String>,
121 pub terminate: Option<String>,
123 pub cleanup: bool,
125 pub cleanup_dry_run: bool,
127}
128
129impl Default for WrapperOptions {
130 fn default() -> Self {
131 WrapperOptions {
132 isolated: None,
133 attached: false,
134 detached: false,
135 session: None,
136 session_id: None,
137 image: None,
138 volumes: Vec::new(),
139 mounts: Vec::new(),
140 env: Vec::new(),
141 privileged: false,
142 network: None,
143 network_aliases: Vec::new(),
144 endpoint: None,
145 user: false,
146 user_name: None,
147 keep_user: false,
148 keep_alive: false,
149 auto_remove_docker_container: false,
150 always_cleanup_container: false,
151 keep_container: false,
152 keep_container_on_fail: false,
153 shell: "auto".to_string(),
154 use_command_stream: false,
155 status: None,
156 list: false,
157 upload_log: None,
158 output_format: None,
159 stop: None,
160 terminate: None,
161 cleanup: false,
162 cleanup_dry_run: false,
163 }
164 }
165}
166
167#[derive(Debug)]
169pub struct ParsedArgs {
170 pub wrapper_options: WrapperOptions,
172 pub command: String,
174 pub raw_command: Vec<String>,
176}
177
178pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
180 let mut wrapper_options = WrapperOptions::default();
181 let mut command_args: Vec<String> = Vec::new();
182
183 let separator_index = args.iter().position(|a| a == "--");
185
186 if let Some(sep_idx) = separator_index {
187 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
189 command_args = args[sep_idx + 1..].to_vec();
190 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
191 } else {
192 let mut i = 0;
194 while i < args.len() {
195 let arg = &args[i];
196 if arg.starts_with('-') {
197 match parse_option(args, i, &mut wrapper_options)? {
198 0 => {
199 return Err(format!("Unknown wrapper option: {}", arg));
200 }
201 consumed => {
202 i += consumed;
203 }
204 }
205 } else {
206 command_args = args[i..].to_vec();
208 break;
209 }
210 }
211 }
212
213 validate_options(&mut wrapper_options)?;
215
216 Ok(ParsedArgs {
217 wrapper_options,
218 command: command_args.join(" "),
219 raw_command: command_args,
220 })
221}
222
223fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
225 let mut i = 0;
226 while i < args.len() {
227 match parse_option(args, i, options)? {
228 0 => {
229 if args[i].starts_with('-') {
230 return Err(format!("Unknown wrapper option: {}", args[i]));
231 }
232 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
233 eprintln!("Unknown wrapper option: {}", args[i]);
234 }
235 i += 1;
236 }
237 consumed => {
238 i += consumed;
239 }
240 }
241 }
242 Ok(())
243}
244
245fn parse_option(
248 args: &[String],
249 index: usize,
250 options: &mut WrapperOptions,
251) -> Result<usize, String> {
252 let arg = &args[index];
253
254 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
256 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
257 options.isolated = Some(args[index + 1].to_lowercase());
258 return Ok(2);
259 } else {
260 return Err(format!(
261 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
262 arg
263 ));
264 }
265 }
266
267 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
269 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
270 return Ok(1);
271 }
272
273 if arg == "--attached" || arg == "-a" {
275 options.attached = true;
276 return Ok(1);
277 }
278
279 if arg == "--detached" || arg == "-d" {
281 options.detached = true;
282 return Ok(1);
283 }
284
285 if arg == "--session" || arg == "-s" {
287 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
288 options.session = Some(args[index + 1].clone());
289 return Ok(2);
290 } else {
291 return Err(format!("Option {} requires a session name argument", arg));
292 }
293 }
294
295 if arg.starts_with("--session=") {
297 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
298 return Ok(1);
299 }
300
301 if arg == "--image" {
303 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
304 options.image = Some(args[index + 1].clone());
305 return Ok(2);
306 } else {
307 return Err(format!("Option {} requires an image name argument", arg));
308 }
309 }
310
311 if arg.starts_with("--image=") {
313 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
314 return Ok(1);
315 }
316
317 if arg == "--volume" || arg == "-v" {
319 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
320 options.volumes.push(args[index + 1].clone());
321 return Ok(2);
322 } else {
323 return Err(format!(
324 "Option {} requires a volume argument (host:container[:mode])",
325 arg
326 ));
327 }
328 }
329
330 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
332 options
333 .volumes
334 .push(arg[arg.find('=').unwrap() + 1..].to_string());
335 return Ok(1);
336 }
337
338 if arg == "--mount" {
340 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
341 options.mounts.push(args[index + 1].clone());
342 return Ok(2);
343 } else {
344 return Err(format!("Option {} requires a mount spec argument", arg));
345 }
346 }
347
348 if let Some(value) = arg.strip_prefix("--mount=") {
350 options.mounts.push(value.to_string());
351 return Ok(1);
352 }
353
354 if arg == "--env" || arg == "-e" {
356 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
357 options.env.push(args[index + 1].clone());
358 return Ok(2);
359 } else {
360 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
361 }
362 }
363
364 if arg.starts_with("--env=") || arg.starts_with("-e=") {
366 options
367 .env
368 .push(arg[arg.find('=').unwrap() + 1..].to_string());
369 return Ok(1);
370 }
371
372 if arg == "--privileged" {
374 options.privileged = true;
375 return Ok(1);
376 }
377
378 if arg == "--network" {
380 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
381 options.network = Some(args[index + 1].clone());
382 return Ok(2);
383 }
384 return Err(format!("Option {} requires a network name argument", arg));
385 }
386
387 if let Some(value) = arg.strip_prefix("--network=") {
389 options.network = Some(value.to_string());
390 return Ok(1);
391 }
392
393 if arg == "--network-alias" {
395 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
396 options.network_aliases.push(args[index + 1].clone());
397 return Ok(2);
398 }
399 return Err(format!("Option {} requires an alias argument", arg));
400 }
401
402 if let Some(value) = arg.strip_prefix("--network-alias=") {
404 options.network_aliases.push(value.to_string());
405 return Ok(1);
406 }
407
408 if arg == "--endpoint" {
410 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
411 options.endpoint = Some(args[index + 1].clone());
412 return Ok(2);
413 } else {
414 return Err(format!("Option {} requires an endpoint argument", arg));
415 }
416 }
417
418 if arg.starts_with("--endpoint=") {
420 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
421 return Ok(1);
422 }
423
424 if arg == "--isolated-user" || arg == "-u" {
426 options.user = true;
427 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
429 let next_arg = &args[index + 1];
430 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
432 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
433 options.user_name = Some(next_arg.clone());
434 return Ok(2);
435 }
436 }
437 return Ok(1);
438 }
439
440 if arg.starts_with("--isolated-user=") {
442 options.user = true;
443 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
444 return Ok(1);
445 }
446
447 if arg == "--keep-user" {
449 options.keep_user = true;
450 return Ok(1);
451 }
452
453 if arg == "--keep-alive" || arg == "-k" {
455 options.keep_alive = true;
456 return Ok(1);
457 }
458
459 if arg == "--auto-remove-docker-container" {
461 options.auto_remove_docker_container = true;
462 return Ok(1);
463 }
464
465 if arg == "--always-cleanup-container" {
467 options.always_cleanup_container = true;
468 return Ok(1);
469 }
470
471 if arg == "--keep-container" {
473 options.keep_container = true;
474 return Ok(1);
475 }
476
477 if arg == "--keep-container-on-fail" {
479 options.keep_container_on_fail = true;
480 return Ok(1);
481 }
482
483 if arg == "--shell" {
485 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
486 options.shell = args[index + 1].to_lowercase();
487 return Ok(2);
488 } else {
489 return Err(format!(
490 "Option {} requires a shell argument (auto, bash, zsh, sh)",
491 arg
492 ));
493 }
494 }
495
496 if arg.starts_with("--shell=") {
498 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
499 return Ok(1);
500 }
501
502 if arg == "--use-command-stream" {
504 options.use_command_stream = true;
505 return Ok(1);
506 }
507
508 if arg == "--session-id" || arg == "--session-name" {
510 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
511 options.session_id = Some(args[index + 1].clone());
512 return Ok(2);
513 } else {
514 return Err(format!("Option {} requires a UUID argument", arg));
515 }
516 }
517
518 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
520 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
521 return Ok(1);
522 }
523
524 if arg == "--status" {
526 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
527 options.status = Some(args[index + 1].clone());
528 return Ok(2);
529 } else {
530 return Err(format!(
531 "Option {} requires a UUID or session name argument",
532 arg
533 ));
534 }
535 }
536
537 if let Some(value) = arg.strip_prefix("--status=") {
539 if value.is_empty() {
540 return Err("Option --status requires a UUID or session name argument".to_string());
541 }
542 options.status = Some(value.to_string());
543 return Ok(1);
544 }
545
546 if arg == "--upload-log" {
548 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
549 options.upload_log = Some(args[index + 1].clone());
550 return Ok(2);
551 } else {
552 return Err(format!(
553 "Option {} requires a UUID or session name argument",
554 arg
555 ));
556 }
557 }
558
559 if let Some(value) = arg.strip_prefix("--upload-log=") {
561 if value.is_empty() {
562 return Err("Option --upload-log requires a UUID or session name argument".to_string());
563 }
564 options.upload_log = Some(value.to_string());
565 return Ok(1);
566 }
567
568 if arg == "--stop" {
570 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
571 options.stop = Some(args[index + 1].clone());
572 return Ok(2);
573 } else {
574 return Err(format!(
575 "Option {} requires a UUID or session name argument",
576 arg
577 ));
578 }
579 }
580
581 if let Some(value) = arg.strip_prefix("--stop=") {
583 if value.is_empty() {
584 return Err("Option --stop requires a UUID or session name argument".to_string());
585 }
586 options.stop = Some(value.to_string());
587 return Ok(1);
588 }
589
590 if arg == "--terminate" {
592 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
593 options.terminate = Some(args[index + 1].clone());
594 return Ok(2);
595 } else {
596 return Err(format!(
597 "Option {} requires a UUID or session name argument",
598 arg
599 ));
600 }
601 }
602
603 if let Some(value) = arg.strip_prefix("--terminate=") {
605 if value.is_empty() {
606 return Err("Option --terminate requires a UUID or session name argument".to_string());
607 }
608 options.terminate = Some(value.to_string());
609 return Ok(1);
610 }
611
612 if arg == "--list" {
614 options.list = true;
615 return Ok(1);
616 }
617
618 if arg == "--output-format" {
620 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
621 options.output_format = Some(args[index + 1].to_lowercase());
622 return Ok(2);
623 } else {
624 return Err(format!("Option {} requires a format argument", arg));
625 }
626 }
627
628 if arg.starts_with("--output-format=") {
630 options.output_format = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
631 return Ok(1);
632 }
633
634 if arg == "--cleanup" {
636 options.cleanup = true;
637 return Ok(1);
638 }
639
640 if arg == "--cleanup-dry-run" {
642 options.cleanup = true;
643 options.cleanup_dry_run = true;
644 return Ok(1);
645 }
646
647 Ok(0)
649}
650
651pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
653 if options.attached && options.detached {
655 return Err(
656 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
657 .to_string(),
658 );
659 }
660
661 if let Some(ref backend) = options.isolated {
663 if !VALID_BACKENDS.contains(&backend.as_str()) {
664 return Err(format!(
665 "Invalid isolation backend: \"{}\". Valid options are: {}",
666 backend,
667 VALID_BACKENDS.join(", ")
668 ));
669 }
670
671 if backend == "docker" && options.image.is_none() {
673 options.image = Some(get_default_docker_image());
674 }
675
676 if backend == "ssh" && options.endpoint.is_none() {
678 return Err(
679 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
680 .to_string(),
681 );
682 }
683 }
684
685 if options.session.is_some() && options.isolated.is_none() {
687 return Err("--session option is only valid with --isolated".to_string());
688 }
689
690 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
692 return Err("--image option is only valid with --isolated docker".to_string());
693 }
694
695 let is_docker = options.isolated.as_deref() == Some("docker");
697 if !options.volumes.is_empty() && !is_docker {
698 return Err("--volume option is only valid with --isolated docker".to_string());
699 }
700 if !options.mounts.is_empty() && !is_docker {
701 return Err("--mount option is only valid with --isolated docker".to_string());
702 }
703 if !options.env.is_empty() && !is_docker {
704 return Err("--env option is only valid with --isolated docker".to_string());
705 }
706 if options.privileged && !is_docker {
707 return Err("--privileged option is only valid with --isolated docker".to_string());
708 }
709 if options.network.is_some() && !is_docker {
710 return Err("--network option is only valid with --isolated docker".to_string());
711 }
712 if !options.network_aliases.is_empty() && !is_docker {
713 return Err("--network-alias option is only valid with --isolated docker".to_string());
714 }
715
716 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
718 return Err("--endpoint option is only valid with --isolated ssh".to_string());
719 }
720
721 if options.keep_alive && options.isolated.is_none() {
723 return Err("--keep-alive option is only valid with --isolated".to_string());
724 }
725
726 let cleanup_flags = [
728 (
729 "--auto-remove-docker-container",
730 options.auto_remove_docker_container,
731 ),
732 (
733 "--always-cleanup-container",
734 options.always_cleanup_container,
735 ),
736 ("--keep-container", options.keep_container),
737 ("--keep-container-on-fail", options.keep_container_on_fail),
738 ];
739 for (flag, enabled) in cleanup_flags {
740 if enabled && !is_docker {
741 return Err(format!(
742 "{} option is only valid with --isolated docker",
743 flag
744 ));
745 }
746 }
747
748 let selected_cleanup_policies = [
749 options.auto_remove_docker_container || options.always_cleanup_container,
750 options.keep_container,
751 options.keep_container_on_fail,
752 ]
753 .into_iter()
754 .filter(|enabled| *enabled)
755 .count();
756 if selected_cleanup_policies > 1 {
757 return Err(
758 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
759 .to_string(),
760 );
761 }
762
763 if options.user {
765 if options.isolated.as_deref() == Some("docker") {
767 return Err(
768 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
769 .to_string(),
770 );
771 }
772 if let Some(ref username) = options.user_name {
774 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
775 if !username_regex.is_match(username) {
776 return Err(format!(
777 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
778 username
779 ));
780 }
781 if username.len() > 32 {
782 return Err(format!(
783 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
784 username
785 ));
786 }
787 }
788 }
789
790 if options.keep_user && !options.user {
792 return Err("--keep-user option is only valid with --isolated-user".to_string());
793 }
794
795 if let Some(ref format) = options.output_format {
797 if !VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
798 return Err(format!(
799 "Invalid output format: \"{}\". Valid options are: {}",
800 format,
801 VALID_OUTPUT_FORMATS.join(", ")
802 ));
803 }
804 }
805
806 let query_modes = [
808 options.status.is_some(),
809 options.list,
810 options.upload_log.is_some(),
811 options.stop.is_some(),
812 options.terminate.is_some(),
813 options.cleanup,
814 ]
815 .into_iter()
816 .filter(|enabled| *enabled)
817 .count();
818
819 if query_modes > 1 {
820 return Err(
821 "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation"
822 .to_string(),
823 );
824 }
825
826 if options.output_format.is_some() && options.status.is_none() && !options.list {
828 return Err("--output-format option is only valid with --status or --list".to_string());
829 }
830
831 if !VALID_SHELLS.contains(&options.shell.as_str()) {
833 return Err(format!(
834 "Invalid shell: \"{}\". Valid options are: {}",
835 options.shell,
836 VALID_SHELLS.join(", ")
837 ));
838 }
839
840 if let Some(ref session_id) = options.session_id {
842 if !is_valid_uuid(session_id) {
843 return Err(format!(
844 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
845 session_id
846 ));
847 }
848 }
849
850 Ok(())
851}
852
853pub fn generate_session_name(prefix: Option<&str>) -> String {
855 use std::cell::RefCell;
856 use std::time::{SystemTime, UNIX_EPOCH};
857
858 thread_local! {
859 static STATE: RefCell<u64> = RefCell::new(
860 SystemTime::now()
861 .duration_since(UNIX_EPOCH)
862 .unwrap()
863 .as_nanos() as u64
864 );
865 }
866
867 fn next_random() -> u64 {
868 STATE.with(|state| {
869 let mut s = state.borrow_mut();
870 *s ^= *s << 13;
871 *s ^= *s >> 7;
872 *s ^= *s << 17;
873 *s
874 })
875 }
876
877 let prefix = prefix.unwrap_or("start");
878 let timestamp = chrono::Utc::now().timestamp_millis();
879 let random: String = (0..6)
880 .map(|_| {
881 let idx = (next_random() % 36) as u8;
882 if idx < 10 {
883 (b'0' + idx) as char
884 } else {
885 (b'a' + idx - 10) as char
886 }
887 })
888 .collect();
889 format!("{}-{}-{}", prefix, timestamp, random)
890}
891
892pub fn has_isolation(options: &WrapperOptions) -> bool {
894 options.isolated.is_some()
895}
896
897pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
900 if options.detached {
901 "detached"
902 } else {
903 "attached"
905 }
906}
907
908#[cfg(test)]
909#[path = "args_parser_cases.rs"]
910mod tests;