1use std::env;
26
27use crate::isolation::get_default_docker_image;
28
29pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
31
32pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
34
35pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
37
38const 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}$";
40
41pub fn is_valid_uuid(s: &str) -> bool {
43 regex::Regex::new(UUID_REGEX)
44 .map(|re| re.is_match(&s.to_lowercase()))
45 .unwrap_or(false)
46}
47
48pub fn generate_uuid() -> String {
50 uuid::Uuid::new_v4().to_string()
51}
52
53#[derive(Debug, Clone)]
55pub struct WrapperOptions {
56 pub isolated: Option<String>,
58 pub attached: bool,
60 pub detached: bool,
62 pub session: Option<String>,
64 pub session_id: Option<String>,
66 pub image: Option<String>,
68 pub endpoint: Option<String>,
70 pub user: bool,
72 pub user_name: Option<String>,
74 pub keep_user: bool,
76 pub keep_alive: bool,
78 pub auto_remove_docker_container: bool,
80 pub shell: String,
82 pub use_command_stream: bool,
84 pub status: Option<String>,
86 pub list: bool,
88 pub upload_log: Option<String>,
90 pub output_format: Option<String>,
92 pub stop: Option<String>,
94 pub terminate: Option<String>,
96 pub cleanup: bool,
98 pub cleanup_dry_run: bool,
100}
101
102impl Default for WrapperOptions {
103 fn default() -> Self {
104 WrapperOptions {
105 isolated: None,
106 attached: false,
107 detached: false,
108 session: None,
109 session_id: None,
110 image: None,
111 endpoint: None,
112 user: false,
113 user_name: None,
114 keep_user: false,
115 keep_alive: false,
116 auto_remove_docker_container: false,
117 shell: "auto".to_string(),
118 use_command_stream: false,
119 status: None,
120 list: false,
121 upload_log: None,
122 output_format: None,
123 stop: None,
124 terminate: None,
125 cleanup: false,
126 cleanup_dry_run: false,
127 }
128 }
129}
130
131#[derive(Debug)]
133pub struct ParsedArgs {
134 pub wrapper_options: WrapperOptions,
136 pub command: String,
138 pub raw_command: Vec<String>,
140}
141
142pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
144 let mut wrapper_options = WrapperOptions::default();
145 let mut command_args: Vec<String> = Vec::new();
146
147 let separator_index = args.iter().position(|a| a == "--");
149
150 if let Some(sep_idx) = separator_index {
151 let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
153 command_args = args[sep_idx + 1..].to_vec();
154 parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
155 } else {
156 let mut i = 0;
158 while i < args.len() {
159 let arg = &args[i];
160 if arg.starts_with('-') {
161 match parse_option(args, i, &mut wrapper_options)? {
162 0 => {
163 command_args = args[i..].to_vec();
165 break;
166 }
167 consumed => {
168 i += consumed;
169 }
170 }
171 } else {
172 command_args = args[i..].to_vec();
174 break;
175 }
176 }
177 }
178
179 validate_options(&mut wrapper_options)?;
181
182 Ok(ParsedArgs {
183 wrapper_options,
184 command: command_args.join(" "),
185 raw_command: command_args,
186 })
187}
188
189fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
191 let mut i = 0;
192 while i < args.len() {
193 match parse_option(args, i, options)? {
194 0 => {
195 if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
197 eprintln!("Unknown wrapper option: {}", args[i]);
198 }
199 i += 1;
200 }
201 consumed => {
202 i += consumed;
203 }
204 }
205 }
206 Ok(())
207}
208
209fn parse_option(
212 args: &[String],
213 index: usize,
214 options: &mut WrapperOptions,
215) -> Result<usize, String> {
216 let arg = &args[index];
217
218 if arg == "--isolated" || arg == "-i" {
220 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
221 options.isolated = Some(args[index + 1].to_lowercase());
222 return Ok(2);
223 } else {
224 return Err(format!(
225 "Option {} requires a backend argument (screen, tmux, docker, ssh)",
226 arg
227 ));
228 }
229 }
230
231 if arg.starts_with("--isolated=") {
233 options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
234 return Ok(1);
235 }
236
237 if arg == "--attached" || arg == "-a" {
239 options.attached = true;
240 return Ok(1);
241 }
242
243 if arg == "--detached" || arg == "-d" {
245 options.detached = true;
246 return Ok(1);
247 }
248
249 if arg == "--session" || arg == "-s" {
251 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
252 options.session = Some(args[index + 1].clone());
253 return Ok(2);
254 } else {
255 return Err(format!("Option {} requires a session name argument", arg));
256 }
257 }
258
259 if arg.starts_with("--session=") {
261 options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
262 return Ok(1);
263 }
264
265 if arg == "--image" {
267 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
268 options.image = Some(args[index + 1].clone());
269 return Ok(2);
270 } else {
271 return Err(format!("Option {} requires an image name argument", arg));
272 }
273 }
274
275 if arg.starts_with("--image=") {
277 options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
278 return Ok(1);
279 }
280
281 if arg == "--endpoint" {
283 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
284 options.endpoint = Some(args[index + 1].clone());
285 return Ok(2);
286 } else {
287 return Err(format!("Option {} requires an endpoint argument", arg));
288 }
289 }
290
291 if arg.starts_with("--endpoint=") {
293 options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
294 return Ok(1);
295 }
296
297 if arg == "--isolated-user" || arg == "-u" {
299 options.user = true;
300 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
302 let next_arg = &args[index + 1];
303 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
305 if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
306 options.user_name = Some(next_arg.clone());
307 return Ok(2);
308 }
309 }
310 return Ok(1);
311 }
312
313 if arg.starts_with("--isolated-user=") {
315 options.user = true;
316 options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
317 return Ok(1);
318 }
319
320 if arg == "--keep-user" {
322 options.keep_user = true;
323 return Ok(1);
324 }
325
326 if arg == "--keep-alive" || arg == "-k" {
328 options.keep_alive = true;
329 return Ok(1);
330 }
331
332 if arg == "--auto-remove-docker-container" {
334 options.auto_remove_docker_container = true;
335 return Ok(1);
336 }
337
338 if arg == "--shell" {
340 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
341 options.shell = args[index + 1].to_lowercase();
342 return Ok(2);
343 } else {
344 return Err(format!(
345 "Option {} requires a shell argument (auto, bash, zsh, sh)",
346 arg
347 ));
348 }
349 }
350
351 if arg.starts_with("--shell=") {
353 options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
354 return Ok(1);
355 }
356
357 if arg == "--use-command-stream" {
359 options.use_command_stream = true;
360 return Ok(1);
361 }
362
363 if arg == "--session-id" || arg == "--session-name" {
365 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
366 options.session_id = Some(args[index + 1].clone());
367 return Ok(2);
368 } else {
369 return Err(format!("Option {} requires a UUID argument", arg));
370 }
371 }
372
373 if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
375 options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
376 return Ok(1);
377 }
378
379 if arg == "--status" {
381 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
382 options.status = Some(args[index + 1].clone());
383 return Ok(2);
384 } else {
385 return Err(format!(
386 "Option {} requires a UUID or session name argument",
387 arg
388 ));
389 }
390 }
391
392 if let Some(value) = arg.strip_prefix("--status=") {
394 if value.is_empty() {
395 return Err("Option --status requires a UUID or session name argument".to_string());
396 }
397 options.status = Some(value.to_string());
398 return Ok(1);
399 }
400
401 if arg == "--upload-log" {
403 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
404 options.upload_log = Some(args[index + 1].clone());
405 return Ok(2);
406 } else {
407 return Err(format!(
408 "Option {} requires a UUID or session name argument",
409 arg
410 ));
411 }
412 }
413
414 if let Some(value) = arg.strip_prefix("--upload-log=") {
416 if value.is_empty() {
417 return Err("Option --upload-log requires a UUID or session name argument".to_string());
418 }
419 options.upload_log = Some(value.to_string());
420 return Ok(1);
421 }
422
423 if arg == "--stop" {
425 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
426 options.stop = Some(args[index + 1].clone());
427 return Ok(2);
428 } else {
429 return Err(format!(
430 "Option {} requires a UUID or session name argument",
431 arg
432 ));
433 }
434 }
435
436 if let Some(value) = arg.strip_prefix("--stop=") {
438 if value.is_empty() {
439 return Err("Option --stop requires a UUID or session name argument".to_string());
440 }
441 options.stop = Some(value.to_string());
442 return Ok(1);
443 }
444
445 if arg == "--terminate" {
447 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
448 options.terminate = Some(args[index + 1].clone());
449 return Ok(2);
450 } else {
451 return Err(format!(
452 "Option {} requires a UUID or session name argument",
453 arg
454 ));
455 }
456 }
457
458 if let Some(value) = arg.strip_prefix("--terminate=") {
460 if value.is_empty() {
461 return Err("Option --terminate requires a UUID or session name argument".to_string());
462 }
463 options.terminate = Some(value.to_string());
464 return Ok(1);
465 }
466
467 if arg == "--list" {
469 options.list = true;
470 return Ok(1);
471 }
472
473 if arg == "--output-format" {
475 if index + 1 < args.len() && !args[index + 1].starts_with('-') {
476 options.output_format = Some(args[index + 1].to_lowercase());
477 return Ok(2);
478 } else {
479 return Err(format!("Option {} requires a format argument", arg));
480 }
481 }
482
483 if arg.starts_with("--output-format=") {
485 options.output_format = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
486 return Ok(1);
487 }
488
489 if arg == "--cleanup" {
491 options.cleanup = true;
492 return Ok(1);
493 }
494
495 if arg == "--cleanup-dry-run" {
497 options.cleanup = true;
498 options.cleanup_dry_run = true;
499 return Ok(1);
500 }
501
502 Ok(0)
504}
505
506pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
508 if options.attached && options.detached {
510 return Err(
511 "Cannot use both --attached and --detached at the same time. Please choose only one mode."
512 .to_string(),
513 );
514 }
515
516 if let Some(ref backend) = options.isolated {
518 if !VALID_BACKENDS.contains(&backend.as_str()) {
519 return Err(format!(
520 "Invalid isolation backend: \"{}\". Valid options are: {}",
521 backend,
522 VALID_BACKENDS.join(", ")
523 ));
524 }
525
526 if backend == "docker" && options.image.is_none() {
528 options.image = Some(get_default_docker_image());
529 }
530
531 if backend == "ssh" && options.endpoint.is_none() {
533 return Err(
534 "SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
535 .to_string(),
536 );
537 }
538 }
539
540 if options.session.is_some() && options.isolated.is_none() {
542 return Err("--session option is only valid with --isolated".to_string());
543 }
544
545 if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
547 return Err("--image option is only valid with --isolated docker".to_string());
548 }
549
550 if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
552 return Err("--endpoint option is only valid with --isolated ssh".to_string());
553 }
554
555 if options.keep_alive && options.isolated.is_none() {
557 return Err("--keep-alive option is only valid with --isolated".to_string());
558 }
559
560 if options.auto_remove_docker_container && options.isolated.as_deref() != Some("docker") {
562 return Err(
563 "--auto-remove-docker-container option is only valid with --isolated docker"
564 .to_string(),
565 );
566 }
567
568 if options.user {
570 if options.isolated.as_deref() == Some("docker") {
572 return Err(
573 "--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
574 .to_string(),
575 );
576 }
577 if let Some(ref username) = options.user_name {
579 let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
580 if !username_regex.is_match(username) {
581 return Err(format!(
582 "Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
583 username
584 ));
585 }
586 if username.len() > 32 {
587 return Err(format!(
588 "Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
589 username
590 ));
591 }
592 }
593 }
594
595 if options.keep_user && !options.user {
597 return Err("--keep-user option is only valid with --isolated-user".to_string());
598 }
599
600 if let Some(ref format) = options.output_format {
602 if !VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
603 return Err(format!(
604 "Invalid output format: \"{}\". Valid options are: {}",
605 format,
606 VALID_OUTPUT_FORMATS.join(", ")
607 ));
608 }
609 }
610
611 let query_modes = [
613 options.status.is_some(),
614 options.list,
615 options.upload_log.is_some(),
616 options.stop.is_some(),
617 options.terminate.is_some(),
618 options.cleanup,
619 ]
620 .into_iter()
621 .filter(|enabled| *enabled)
622 .count();
623
624 if query_modes > 1 {
625 return Err(
626 "Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation"
627 .to_string(),
628 );
629 }
630
631 if options.output_format.is_some() && options.status.is_none() && !options.list {
633 return Err("--output-format option is only valid with --status or --list".to_string());
634 }
635
636 if !VALID_SHELLS.contains(&options.shell.as_str()) {
638 return Err(format!(
639 "Invalid shell: \"{}\". Valid options are: {}",
640 options.shell,
641 VALID_SHELLS.join(", ")
642 ));
643 }
644
645 if let Some(ref session_id) = options.session_id {
647 if !is_valid_uuid(session_id) {
648 return Err(format!(
649 "Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
650 session_id
651 ));
652 }
653 }
654
655 Ok(())
656}
657
658pub fn generate_session_name(prefix: Option<&str>) -> String {
660 use std::cell::RefCell;
661 use std::time::{SystemTime, UNIX_EPOCH};
662
663 thread_local! {
664 static STATE: RefCell<u64> = RefCell::new(
665 SystemTime::now()
666 .duration_since(UNIX_EPOCH)
667 .unwrap()
668 .as_nanos() as u64
669 );
670 }
671
672 fn next_random() -> u64 {
673 STATE.with(|state| {
674 let mut s = state.borrow_mut();
675 *s ^= *s << 13;
676 *s ^= *s >> 7;
677 *s ^= *s << 17;
678 *s
679 })
680 }
681
682 let prefix = prefix.unwrap_or("start");
683 let timestamp = chrono::Utc::now().timestamp_millis();
684 let random: String = (0..6)
685 .map(|_| {
686 let idx = (next_random() % 36) as u8;
687 if idx < 10 {
688 (b'0' + idx) as char
689 } else {
690 (b'a' + idx - 10) as char
691 }
692 })
693 .collect();
694 format!("{}-{}-{}", prefix, timestamp, random)
695}
696
697pub fn has_isolation(options: &WrapperOptions) -> bool {
699 options.isolated.is_some()
700}
701
702pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
705 if options.detached {
706 "detached"
707 } else {
708 "attached"
710 }
711}
712
713#[cfg(test)]
714#[path = "args_parser_cases.rs"]
715mod tests;