1use std::env;
30
31use crate::isolation::get_default_docker_image;
32
33pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
35
36pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
38
39pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
41
42const 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}$";
44
45pub fn is_valid_uuid(s: &str) -> bool {
47 regex::Regex::new(UUID_REGEX)
48 .map(|re| re.is_match(&s.to_lowercase()))
49 .unwrap_or(false)
50}
51
52pub fn generate_uuid() -> String {
54 uuid::Uuid::new_v4().to_string()
55}
56
57#[derive(Debug, Clone)]
59pub struct WrapperOptions {
60 pub isolated: Option<String>,
62 pub attached: bool,
64 pub detached: bool,
66 pub session: Option<String>,
68 pub session_id: Option<String>,
70 pub image: Option<String>,
72 pub volumes: Vec<String>,
74 pub mounts: Vec<String>,
76 pub env: Vec<String>,
78 pub privileged: bool,
80 pub endpoint: Option<String>,
82 pub user: bool,
84 pub user_name: Option<String>,
86 pub keep_user: bool,
88 pub keep_alive: bool,
90 pub auto_remove_docker_container: bool,
92 pub shell: String,
94 pub use_command_stream: bool,
96 pub status: Option<String>,
98 pub list: bool,
100 pub upload_log: Option<String>,
102 pub output_format: Option<String>,
104 pub stop: Option<String>,
106 pub terminate: Option<String>,
108 pub cleanup: bool,
110 pub cleanup_dry_run: bool,
112}
113
114impl Default for WrapperOptions {
115 fn default() -> Self {
116 WrapperOptions {
117 isolated: None,
118 attached: false,
119 detached: false,
120 session: None,
121 session_id: None,
122 image: None,
123 volumes: Vec::new(),
124 mounts: Vec::new(),
125 env: Vec::new(),
126 privileged: false,
127 endpoint: None,
128 user: false,
129 user_name: None,
130 keep_user: false,
131 keep_alive: false,
132 auto_remove_docker_container: false,
133 shell: "auto".to_string(),
134 use_command_stream: false,
135 status: None,
136 list: false,
137 upload_log: None,
138 output_format: None,
139 stop: None,
140 terminate: None,
141 cleanup: false,
142 cleanup_dry_run: false,
143 }
144 }
145}
146
147#[derive(Debug)]
149pub struct ParsedArgs {
150 pub wrapper_options: WrapperOptions,
152 pub command: String,
154 pub raw_command: Vec<String>,
156}
157
158pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
160 let mut wrapper_options = WrapperOptions::default();
161 let mut command_args: Vec<String> = Vec::new();
162
163 let separator_index = args.iter().position(|a| a == "--");
165
166 if let Some(sep_idx) = separator_index {
167 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
169 command_args = args[sep_idx + 1..].to_vec();
170 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
171 } else {
172 let mut i = 0;
174 while i < args.len() {
175 let arg = &args[i];
176 if arg.starts_with('-') {
177 match parse_option(args, i, &mut wrapper_options)? {
178 0 => {
179 return Err(format!("Unknown wrapper option: {}", arg));
180 }
181 consumed => {
182 i += consumed;
183 }
184 }
185 } else {
186 command_args = args[i..].to_vec();
188 break;
189 }
190 }
191 }
192
193 validate_options(&mut wrapper_options)?;
195
196 Ok(ParsedArgs {
197 wrapper_options,
198 command: command_args.join(" "),
199 raw_command: command_args,
200 })
201}
202
203fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
205 let mut i = 0;
206 while i < args.len() {
207 match parse_option(args, i, options)? {
208 0 => {
209 if args[i].starts_with('-') {
210 return Err(format!("Unknown wrapper option: {}", args[i]));
211 }
212 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
213 eprintln!("Unknown wrapper option: {}", args[i]);
214 }
215 i += 1;
216 }
217 consumed => {
218 i += consumed;
219 }
220 }
221 }
222 Ok(())
223}
224
225fn parse_option(
228 args: &[String],
229 index: usize,
230 options: &mut WrapperOptions,
231) -> Result<usize, String> {
232 let arg = &args[index];
233
234 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
236 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
237 options.isolated = Some(args[index + 1].to_lowercase());
238 return Ok(2);
239 } else {
240 return Err(format!(
241 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
242 arg
243 ));
244 }
245 }
246
247 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
249 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
250 return Ok(1);
251 }
252
253 if arg == "--attached" || arg == "-a" {
255 options.attached = true;
256 return Ok(1);
257 }
258
259 if arg == "--detached" || arg == "-d" {
261 options.detached = true;
262 return Ok(1);
263 }
264
265 if arg == "--session" || arg == "-s" {
267 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
268 options.session = Some(args[index + 1].clone());
269 return Ok(2);
270 } else {
271 return Err(format!("Option {} requires a session name argument", arg));
272 }
273 }
274
275 if arg.starts_with("--session=") {
277 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
278 return Ok(1);
279 }
280
281 if arg == "--image" {
283 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
284 options.image = Some(args[index + 1].clone());
285 return Ok(2);
286 } else {
287 return Err(format!("Option {} requires an image name argument", arg));
288 }
289 }
290
291 if arg.starts_with("--image=") {
293 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
294 return Ok(1);
295 }
296
297 if arg == "--volume" || arg == "-v" {
299 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
300 options.volumes.push(args[index + 1].clone());
301 return Ok(2);
302 } else {
303 return Err(format!(
304 "Option {} requires a volume argument (host:container[:mode])",
305 arg
306 ));
307 }
308 }
309
310 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
312 options
313 .volumes
314 .push(arg[arg.find('=').unwrap() + 1..].to_string());
315 return Ok(1);
316 }
317
318 if arg == "--mount" {
320 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
321 options.mounts.push(args[index + 1].clone());
322 return Ok(2);
323 } else {
324 return Err(format!("Option {} requires a mount spec argument", arg));
325 }
326 }
327
328 if let Some(value) = arg.strip_prefix("--mount=") {
330 options.mounts.push(value.to_string());
331 return Ok(1);
332 }
333
334 if arg == "--env" || arg == "-e" {
336 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
337 options.env.push(args[index + 1].clone());
338 return Ok(2);
339 } else {
340 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
341 }
342 }
343
344 if arg.starts_with("--env=") || arg.starts_with("-e=") {
346 options
347 .env
348 .push(arg[arg.find('=').unwrap() + 1..].to_string());
349 return Ok(1);
350 }
351
352 if arg == "--privileged" {
354 options.privileged = true;
355 return Ok(1);
356 }
357
358 if arg == "--endpoint" {
360 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
361 options.endpoint = Some(args[index + 1].clone());
362 return Ok(2);
363 } else {
364 return Err(format!("Option {} requires an endpoint argument", arg));
365 }
366 }
367
368 if arg.starts_with("--endpoint=") {
370 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
371 return Ok(1);
372 }
373
374 if arg == "--isolated-user" || arg == "-u" {
376 options.user = true;
377 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
379 let next_arg = &args[index + 1];
380 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
382 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
383 options.user_name = Some(next_arg.clone());
384 return Ok(2);
385 }
386 }
387 return Ok(1);
388 }
389
390 if arg.starts_with("--isolated-user=") {
392 options.user = true;
393 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
394 return Ok(1);
395 }
396
397 if arg == "--keep-user" {
399 options.keep_user = true;
400 return Ok(1);
401 }
402
403 if arg == "--keep-alive" || arg == "-k" {
405 options.keep_alive = true;
406 return Ok(1);
407 }
408
409 if arg == "--auto-remove-docker-container" {
411 options.auto_remove_docker_container = true;
412 return Ok(1);
413 }
414
415 if arg == "--shell" {
417 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
418 options.shell = args[index + 1].to_lowercase();
419 return Ok(2);
420 } else {
421 return Err(format!(
422 "Option {} requires a shell argument (auto, bash, zsh, sh)",
423 arg
424 ));
425 }
426 }
427
428 if arg.starts_with("--shell=") {
430 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
431 return Ok(1);
432 }
433
434 if arg == "--use-command-stream" {
436 options.use_command_stream = true;
437 return Ok(1);
438 }
439
440 if arg == "--session-id" || arg == "--session-name" {
442 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
443 options.session_id = Some(args[index + 1].clone());
444 return Ok(2);
445 } else {
446 return Err(format!("Option {} requires a UUID argument", arg));
447 }
448 }
449
450 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
452 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
453 return Ok(1);
454 }
455
456 if arg == "--status" {
458 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
459 options.status = Some(args[index + 1].clone());
460 return Ok(2);
461 } else {
462 return Err(format!(
463 "Option {} requires a UUID or session name argument",
464 arg
465 ));
466 }
467 }
468
469 if let Some(value) = arg.strip_prefix("--status=") {
471 if value.is_empty() {
472 return Err("Option --status requires a UUID or session name argument".to_string());
473 }
474 options.status = Some(value.to_string());
475 return Ok(1);
476 }
477
478 if arg == "--upload-log" {
480 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
481 options.upload_log = Some(args[index + 1].clone());
482 return Ok(2);
483 } else {
484 return Err(format!(
485 "Option {} requires a UUID or session name argument",
486 arg
487 ));
488 }
489 }
490
491 if let Some(value) = arg.strip_prefix("--upload-log=") {
493 if value.is_empty() {
494 return Err("Option --upload-log requires a UUID or session name argument".to_string());
495 }
496 options.upload_log = Some(value.to_string());
497 return Ok(1);
498 }
499
500 if arg == "--stop" {
502 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
503 options.stop = Some(args[index + 1].clone());
504 return Ok(2);
505 } else {
506 return Err(format!(
507 "Option {} requires a UUID or session name argument",
508 arg
509 ));
510 }
511 }
512
513 if let Some(value) = arg.strip_prefix("--stop=") {
515 if value.is_empty() {
516 return Err("Option --stop requires a UUID or session name argument".to_string());
517 }
518 options.stop = Some(value.to_string());
519 return Ok(1);
520 }
521
522 if arg == "--terminate" {
524 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
525 options.terminate = Some(args[index + 1].clone());
526 return Ok(2);
527 } else {
528 return Err(format!(
529 "Option {} requires a UUID or session name argument",
530 arg
531 ));
532 }
533 }
534
535 if let Some(value) = arg.strip_prefix("--terminate=") {
537 if value.is_empty() {
538 return Err("Option --terminate requires a UUID or session name argument".to_string());
539 }
540 options.terminate = Some(value.to_string());
541 return Ok(1);
542 }
543
544 if arg == "--list" {
546 options.list = true;
547 return Ok(1);
548 }
549
550 if arg == "--output-format" {
552 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
553 options.output_format = Some(args[index + 1].to_lowercase());
554 return Ok(2);
555 } else {
556 return Err(format!("Option {} requires a format argument", arg));
557 }
558 }
559
560 if arg.starts_with("--output-format=") {
562 options.output_format = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
563 return Ok(1);
564 }
565
566 if arg == "--cleanup" {
568 options.cleanup = true;
569 return Ok(1);
570 }
571
572 if arg == "--cleanup-dry-run" {
574 options.cleanup = true;
575 options.cleanup_dry_run = true;
576 return Ok(1);
577 }
578
579 Ok(0)
581}
582
583pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
585 if options.attached && options.detached {
587 return Err(
588 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
589 .to_string(),
590 );
591 }
592
593 if let Some(ref backend) = options.isolated {
595 if !VALID_BACKENDS.contains(&backend.as_str()) {
596 return Err(format!(
597 "Invalid isolation backend: \"{}\". Valid options are: {}",
598 backend,
599 VALID_BACKENDS.join(", ")
600 ));
601 }
602
603 if backend == "docker" && options.image.is_none() {
605 options.image = Some(get_default_docker_image());
606 }
607
608 if backend == "ssh" && options.endpoint.is_none() {
610 return Err(
611 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
612 .to_string(),
613 );
614 }
615 }
616
617 if options.session.is_some() && options.isolated.is_none() {
619 return Err("--session option is only valid with --isolated".to_string());
620 }
621
622 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
624 return Err("--image option is only valid with --isolated docker".to_string());
625 }
626
627 let is_docker = options.isolated.as_deref() == Some("docker");
629 if !options.volumes.is_empty() && !is_docker {
630 return Err("--volume option is only valid with --isolated docker".to_string());
631 }
632 if !options.mounts.is_empty() && !is_docker {
633 return Err("--mount option is only valid with --isolated docker".to_string());
634 }
635 if !options.env.is_empty() && !is_docker {
636 return Err("--env option is only valid with --isolated docker".to_string());
637 }
638 if options.privileged && !is_docker {
639 return Err("--privileged option is only valid with --isolated docker".to_string());
640 }
641
642 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
644 return Err("--endpoint option is only valid with --isolated ssh".to_string());
645 }
646
647 if options.keep_alive && options.isolated.is_none() {
649 return Err("--keep-alive option is only valid with --isolated".to_string());
650 }
651
652 if options.auto_remove_docker_container && options.isolated.as_deref() != Some("docker") {
654 return Err(
655 "--auto-remove-docker-container option is only valid with --isolated docker"
656 .to_string(),
657 );
658 }
659
660 if options.user {
662 if options.isolated.as_deref() == Some("docker") {
664 return Err(
665 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
666 .to_string(),
667 );
668 }
669 if let Some(ref username) = options.user_name {
671 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
672 if !username_regex.is_match(username) {
673 return Err(format!(
674 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
675 username
676 ));
677 }
678 if username.len() > 32 {
679 return Err(format!(
680 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
681 username
682 ));
683 }
684 }
685 }
686
687 if options.keep_user && !options.user {
689 return Err("--keep-user option is only valid with --isolated-user".to_string());
690 }
691
692 if let Some(ref format) = options.output_format {
694 if !VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
695 return Err(format!(
696 "Invalid output format: \"{}\". Valid options are: {}",
697 format,
698 VALID_OUTPUT_FORMATS.join(", ")
699 ));
700 }
701 }
702
703 let query_modes = [
705 options.status.is_some(),
706 options.list,
707 options.upload_log.is_some(),
708 options.stop.is_some(),
709 options.terminate.is_some(),
710 options.cleanup,
711 ]
712 .into_iter()
713 .filter(|enabled| *enabled)
714 .count();
715
716 if query_modes > 1 {
717 return Err(
718 "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation"
719 .to_string(),
720 );
721 }
722
723 if options.output_format.is_some() && options.status.is_none() && !options.list {
725 return Err("--output-format option is only valid with --status or --list".to_string());
726 }
727
728 if !VALID_SHELLS.contains(&options.shell.as_str()) {
730 return Err(format!(
731 "Invalid shell: \"{}\". Valid options are: {}",
732 options.shell,
733 VALID_SHELLS.join(", ")
734 ));
735 }
736
737 if let Some(ref session_id) = options.session_id {
739 if !is_valid_uuid(session_id) {
740 return Err(format!(
741 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
742 session_id
743 ));
744 }
745 }
746
747 Ok(())
748}
749
750pub fn generate_session_name(prefix: Option<&str>) -> String {
752 use std::cell::RefCell;
753 use std::time::{SystemTime, UNIX_EPOCH};
754
755 thread_local! {
756 static STATE: RefCell<u64> = RefCell::new(
757 SystemTime::now()
758 .duration_since(UNIX_EPOCH)
759 .unwrap()
760 .as_nanos() as u64
761 );
762 }
763
764 fn next_random() -> u64 {
765 STATE.with(|state| {
766 let mut s = state.borrow_mut();
767 *s ^= *s << 13;
768 *s ^= *s >> 7;
769 *s ^= *s << 17;
770 *s
771 })
772 }
773
774 let prefix = prefix.unwrap_or("start");
775 let timestamp = chrono::Utc::now().timestamp_millis();
776 let random: String = (0..6)
777 .map(|_| {
778 let idx = (next_random() % 36) as u8;
779 if idx < 10 {
780 (b'0' + idx) as char
781 } else {
782 (b'a' + idx - 10) as char
783 }
784 })
785 .collect();
786 format!("{}-{}-{}", prefix, timestamp, random)
787}
788
789pub fn has_isolation(options: &WrapperOptions) -> bool {
791 options.isolated.is_some()
792}
793
794pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
797 if options.detached {
798 "detached"
799 } else {
800 "attached"
802 }
803}
804
805#[cfg(test)]
806#[path = "args_parser_cases.rs"]
807mod tests;