1use std::env;
40
41use crate::args_parser_queries::{parse_query_option, validate_query_options};
42use crate::isolation::get_default_docker_image;
43
44pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
46
47pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
49
50pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
52
53const 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}$";
55
56pub fn is_valid_uuid(s: &str) -> bool {
58 regex::Regex::new(UUID_REGEX)
59 .map(|re| re.is_match(&s.to_lowercase()))
60 .unwrap_or(false)
61}
62
63pub fn generate_uuid() -> String {
65 uuid::Uuid::new_v4().to_string()
66}
67
68#[derive(Debug, Clone)]
70pub struct WrapperOptions {
71 pub isolated: Option<String>,
73 pub attached: bool,
75 pub detached: bool,
77 pub session: Option<String>,
79 pub session_id: Option<String>,
81 pub image: Option<String>,
83 pub volumes: Vec<String>,
85 pub mounts: Vec<String>,
87 pub env: Vec<String>,
89 pub privileged: bool,
91 pub network: Option<String>,
93 pub networks: Vec<String>,
95 pub network_aliases: Vec<String>,
97 pub endpoint: Option<String>,
99 pub user: bool,
101 pub user_name: Option<String>,
103 pub keep_user: bool,
105 pub keep_alive: bool,
107 pub auto_remove_docker_container: bool,
109 pub always_cleanup_container: bool,
111 pub keep_container: bool,
113 pub keep_container_on_fail: bool,
115 pub shell: String,
117 pub use_command_stream: bool,
119 pub status: Option<String>,
121 pub list: bool,
123 pub running: bool,
125 pub upload_log: Option<String>,
127 pub output_format: Option<String>,
129 pub stop: Option<String>,
131 pub terminate: Option<String>,
133 pub attach: Option<String>,
135 pub read_only: bool,
137 pub resume: Option<String>,
139 pub resume_all: bool,
141 pub cleanup: bool,
143 pub cleanup_dry_run: bool,
145}
146
147impl Default for WrapperOptions {
148 fn default() -> Self {
149 WrapperOptions {
150 isolated: None,
151 attached: false,
152 detached: false,
153 session: None,
154 session_id: None,
155 image: None,
156 volumes: Vec::new(),
157 mounts: Vec::new(),
158 env: Vec::new(),
159 privileged: false,
160 network: None,
161 networks: Vec::new(),
162 network_aliases: Vec::new(),
163 endpoint: None,
164 user: false,
165 user_name: None,
166 keep_user: false,
167 keep_alive: false,
168 auto_remove_docker_container: false,
169 always_cleanup_container: false,
170 keep_container: false,
171 keep_container_on_fail: false,
172 shell: "auto".to_string(),
173 use_command_stream: false,
174 status: None,
175 list: false,
176 running: false,
177 upload_log: None,
178 output_format: None,
179 stop: None,
180 terminate: None,
181 attach: None,
182 read_only: false,
183 resume: None,
184 resume_all: false,
185 cleanup: false,
186 cleanup_dry_run: false,
187 }
188 }
189}
190
191#[derive(Debug)]
193pub struct ParsedArgs {
194 pub wrapper_options: WrapperOptions,
196 pub command: String,
199 pub raw_command: Vec<String>,
201}
202
203pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
205 let mut wrapper_options = WrapperOptions::default();
206 let mut command_args: Vec<String> = Vec::new();
207
208 let separator_index = args.iter().position(|a| a == "--");
210
211 if let Some(sep_idx) = separator_index {
212 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
214 command_args = args[sep_idx + 1..].to_vec();
215 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
216 } else {
217 let mut i = 0;
219 while i < args.len() {
220 let arg = &args[i];
221 if arg.starts_with('-') {
222 match parse_option(args, i, &mut wrapper_options)? {
223 0 => {
224 return Err(format!("Unknown wrapper option: {}", arg));
225 }
226 consumed => {
227 i += consumed;
228 }
229 }
230 } else {
231 command_args = args[i..].to_vec();
233 break;
234 }
235 }
236 }
237
238 validate_options(&mut wrapper_options)?;
240
241 Ok(ParsedArgs {
242 wrapper_options,
243 command: crate::isolation::build_command_string(&command_args),
244 raw_command: command_args,
245 })
246}
247
248fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
250 let mut i = 0;
251 while i < args.len() {
252 match parse_option(args, i, options)? {
253 0 => {
254 if args[i].starts_with('-') {
255 return Err(format!("Unknown wrapper option: {}", args[i]));
256 }
257 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
258 eprintln!("Unknown wrapper option: {}", args[i]);
259 }
260 i += 1;
261 }
262 consumed => {
263 i += consumed;
264 }
265 }
266 }
267 Ok(())
268}
269
270fn parse_option(
273 args: &[String],
274 index: usize,
275 options: &mut WrapperOptions,
276) -> Result<usize, String> {
277 let arg = &args[index];
278
279 if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
281 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
282 options.isolated = Some(args[index + 1].to_lowercase());
283 return Ok(2);
284 } else {
285 return Err(format!(
286 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
287 arg
288 ));
289 }
290 }
291
292 if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
294 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
295 return Ok(1);
296 }
297
298 if arg == "--attached" || arg == "-a" {
300 options.attached = true;
301 return Ok(1);
302 }
303
304 if arg == "--detached" || arg == "-d" {
306 options.detached = true;
307 return Ok(1);
308 }
309
310 if arg == "--session" || arg == "-s" {
312 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
313 options.session = Some(args[index + 1].clone());
314 return Ok(2);
315 } else {
316 return Err(format!("Option {} requires a session name argument", arg));
317 }
318 }
319
320 if arg.starts_with("--session=") {
322 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
323 return Ok(1);
324 }
325
326 if arg == "--image" {
328 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
329 options.image = Some(args[index + 1].clone());
330 return Ok(2);
331 } else {
332 return Err(format!("Option {} requires an image name argument", arg));
333 }
334 }
335
336 if arg.starts_with("--image=") {
338 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
339 return Ok(1);
340 }
341
342 if arg == "--volume" || arg == "-v" {
344 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
345 options.volumes.push(args[index + 1].clone());
346 return Ok(2);
347 } else {
348 return Err(format!(
349 "Option {} requires a volume argument (host:container[:mode])",
350 arg
351 ));
352 }
353 }
354
355 if arg.starts_with("--volume=") || arg.starts_with("-v=") {
357 options
358 .volumes
359 .push(arg[arg.find('=').unwrap() + 1..].to_string());
360 return Ok(1);
361 }
362
363 if arg == "--mount" {
365 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
366 options.mounts.push(args[index + 1].clone());
367 return Ok(2);
368 } else {
369 return Err(format!("Option {} requires a mount spec argument", arg));
370 }
371 }
372
373 if let Some(value) = arg.strip_prefix("--mount=") {
375 options.mounts.push(value.to_string());
376 return Ok(1);
377 }
378
379 if arg == "--env" || arg == "-e" {
381 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
382 options.env.push(args[index + 1].clone());
383 return Ok(2);
384 } else {
385 return Err(format!("Option {} requires a KEY=VALUE argument", arg));
386 }
387 }
388
389 if arg.starts_with("--env=") || arg.starts_with("-e=") {
391 options
392 .env
393 .push(arg[arg.find('=').unwrap() + 1..].to_string());
394 return Ok(1);
395 }
396
397 if arg == "--privileged" {
399 options.privileged = true;
400 return Ok(1);
401 }
402
403 if arg == "--network" {
405 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
406 let network = args[index + 1].clone();
407 options.networks.push(network.clone());
408 if options.network.is_none() {
409 options.network = Some(network);
410 }
411 return Ok(2);
412 }
413 return Err(format!("Option {} requires a network name argument", arg));
414 }
415
416 if let Some(value) = arg.strip_prefix("--network=") {
418 let network = value.to_string();
419 options.networks.push(network.clone());
420 if options.network.is_none() {
421 options.network = Some(network);
422 }
423 return Ok(1);
424 }
425
426 if arg == "--network-alias" {
428 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
429 options.network_aliases.push(args[index + 1].clone());
430 return Ok(2);
431 }
432 return Err(format!("Option {} requires an alias argument", arg));
433 }
434
435 if let Some(value) = arg.strip_prefix("--network-alias=") {
437 options.network_aliases.push(value.to_string());
438 return Ok(1);
439 }
440
441 if arg == "--endpoint" {
443 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
444 options.endpoint = Some(args[index + 1].clone());
445 return Ok(2);
446 } else {
447 return Err(format!("Option {} requires an endpoint argument", arg));
448 }
449 }
450
451 if arg.starts_with("--endpoint=") {
453 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
454 return Ok(1);
455 }
456
457 if arg == "--isolated-user" || arg == "-u" {
459 options.user = true;
460 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
462 let next_arg = &args[index + 1];
463 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
465 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
466 options.user_name = Some(next_arg.clone());
467 return Ok(2);
468 }
469 }
470 return Ok(1);
471 }
472
473 if arg.starts_with("--isolated-user=") {
475 options.user = true;
476 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
477 return Ok(1);
478 }
479
480 if arg == "--keep-user" {
482 options.keep_user = true;
483 return Ok(1);
484 }
485
486 if arg == "--keep-alive" || arg == "-k" {
488 options.keep_alive = true;
489 return Ok(1);
490 }
491
492 if arg == "--auto-remove-docker-container" {
494 options.auto_remove_docker_container = true;
495 return Ok(1);
496 }
497
498 if arg == "--always-cleanup-container" {
500 options.always_cleanup_container = true;
501 return Ok(1);
502 }
503
504 if arg == "--keep-container" {
506 options.keep_container = true;
507 return Ok(1);
508 }
509
510 if arg == "--keep-container-on-fail" {
512 options.keep_container_on_fail = true;
513 return Ok(1);
514 }
515
516 if arg == "--shell" {
518 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
519 options.shell = args[index + 1].to_lowercase();
520 return Ok(2);
521 } else {
522 return Err(format!(
523 "Option {} requires a shell argument (auto, bash, zsh, sh)",
524 arg
525 ));
526 }
527 }
528
529 if arg.starts_with("--shell=") {
531 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
532 return Ok(1);
533 }
534
535 if arg == "--use-command-stream" {
537 options.use_command_stream = true;
538 return Ok(1);
539 }
540
541 if arg == "--session-id" || arg == "--session-name" {
543 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
544 options.session_id = Some(args[index + 1].clone());
545 return Ok(2);
546 } else {
547 return Err(format!("Option {} requires a UUID argument", arg));
548 }
549 }
550
551 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
553 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
554 return Ok(1);
555 }
556
557 let query_consumed = parse_query_option(args, index, options)?;
559 if query_consumed > 0 {
560 return Ok(query_consumed);
561 }
562
563 Ok(0)
565}
566
567pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
569 if options.attached && options.detached {
571 return Err(
572 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
573 .to_string(),
574 );
575 }
576
577 if let Some(ref backend) = options.isolated {
579 if !VALID_BACKENDS.contains(&backend.as_str()) {
580 return Err(format!(
581 "Invalid isolation backend: \"{}\". Valid options are: {}",
582 backend,
583 VALID_BACKENDS.join(", ")
584 ));
585 }
586
587 if backend == "docker" && options.image.is_none() {
589 options.image = Some(get_default_docker_image());
590 }
591
592 if backend == "ssh" && options.endpoint.is_none() {
594 return Err(
595 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
596 .to_string(),
597 );
598 }
599 }
600
601 if options.session.is_some() && options.isolated.is_none() {
603 return Err("--session option is only valid with --isolated".to_string());
604 }
605
606 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
608 return Err("--image option is only valid with --isolated docker".to_string());
609 }
610
611 let is_docker = options.isolated.as_deref() == Some("docker");
613 if !options.volumes.is_empty() && !is_docker {
614 return Err("--volume option is only valid with --isolated docker".to_string());
615 }
616 if !options.mounts.is_empty() && !is_docker {
617 return Err("--mount option is only valid with --isolated docker".to_string());
618 }
619 if !options.env.is_empty() && !is_docker {
620 return Err("--env option is only valid with --isolated docker".to_string());
621 }
622 if options.privileged && !is_docker {
623 return Err("--privileged option is only valid with --isolated docker".to_string());
624 }
625 if options.network.is_some() && !is_docker {
626 return Err("--network option is only valid with --isolated docker".to_string());
627 }
628 if !options.network_aliases.is_empty() && !is_docker {
629 return Err("--network-alias option is only valid with --isolated docker".to_string());
630 }
631
632 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
634 return Err("--endpoint option is only valid with --isolated ssh".to_string());
635 }
636
637 if options.keep_alive && options.isolated.is_none() {
639 return Err("--keep-alive option is only valid with --isolated".to_string());
640 }
641
642 let cleanup_flags = [
644 (
645 "--auto-remove-docker-container",
646 options.auto_remove_docker_container,
647 ),
648 (
649 "--always-cleanup-container",
650 options.always_cleanup_container,
651 ),
652 ("--keep-container", options.keep_container),
653 ("--keep-container-on-fail", options.keep_container_on_fail),
654 ];
655 for (flag, enabled) in cleanup_flags {
656 if enabled && !is_docker {
657 return Err(format!(
658 "{} option is only valid with --isolated docker",
659 flag
660 ));
661 }
662 }
663
664 let selected_cleanup_policies = [
665 options.auto_remove_docker_container || options.always_cleanup_container,
666 options.keep_container,
667 options.keep_container_on_fail,
668 ]
669 .into_iter()
670 .filter(|enabled| *enabled)
671 .count();
672 if selected_cleanup_policies > 1 {
673 return Err(
674 "Cannot combine docker container cleanup policies. Choose only one of --always-cleanup-container, --keep-container, or --keep-container-on-fail."
675 .to_string(),
676 );
677 }
678
679 if options.user {
681 if options.isolated.as_deref() == Some("docker") {
683 return Err(
684 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
685 .to_string(),
686 );
687 }
688 if let Some(ref username) = options.user_name {
690 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
691 if !username_regex.is_match(username) {
692 return Err(format!(
693 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
694 username
695 ));
696 }
697 if username.len() > 32 {
698 return Err(format!(
699 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
700 username
701 ));
702 }
703 }
704 }
705
706 if options.keep_user && !options.user {
708 return Err("--keep-user option is only valid with --isolated-user".to_string());
709 }
710
711 validate_query_options(options)?;
713
714 if !VALID_SHELLS.contains(&options.shell.as_str()) {
716 return Err(format!(
717 "Invalid shell: \"{}\". Valid options are: {}",
718 options.shell,
719 VALID_SHELLS.join(", ")
720 ));
721 }
722
723 if let Some(ref session_id) = options.session_id {
725 if !is_valid_uuid(session_id) {
726 return Err(format!(
727 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
728 session_id
729 ));
730 }
731 }
732
733 Ok(())
734}
735
736pub fn generate_session_name(prefix: Option<&str>) -> String {
738 use std::cell::RefCell;
739 use std::time::{SystemTime, UNIX_EPOCH};
740
741 thread_local! {
742 static STATE: RefCell<u64> = RefCell::new(
743 SystemTime::now()
744 .duration_since(UNIX_EPOCH)
745 .unwrap()
746 .as_nanos() as u64
747 );
748 }
749
750 fn next_random() -> u64 {
751 STATE.with(|state| {
752 let mut s = state.borrow_mut();
753 *s ^= *s << 13;
754 *s ^= *s >> 7;
755 *s ^= *s << 17;
756 *s
757 })
758 }
759
760 let prefix = prefix.unwrap_or("start");
761 let timestamp = chrono::Utc::now().timestamp_millis();
762 let random: String = (0..6)
763 .map(|_| {
764 let idx = (next_random() % 36) as u8;
765 if idx < 10 {
766 (b'0' + idx) as char
767 } else {
768 (b'a' + idx - 10) as char
769 }
770 })
771 .collect();
772 format!("{}-{}-{}", prefix, timestamp, random)
773}
774
775pub fn has_isolation(options: &WrapperOptions) -> bool {
777 options.isolated.is_some()
778}
779
780pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
783 if options.detached {
784 "detached"
785 } else {
786 "attached"
788 }
789}
790
791#[cfg(test)]
792#[path = "args_parser_cases.rs"]
793mod tests;