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