use std::env;
use crate::isolation::get_default_docker_image;
pub const VALID_BACKENDS: [&str; 4] = ["screen", "tmux", "docker", "ssh"];
pub const VALID_SHELLS: [&str; 4] = ["auto", "bash", "zsh", "sh"];
pub const VALID_OUTPUT_FORMATS: [&str; 3] = ["links-notation", "json", "text"];
const 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}$";
pub fn is_valid_uuid(s: &str) -> bool {
regex::Regex::new(UUID_REGEX)
.map(|re| re.is_match(&s.to_lowercase()))
.unwrap_or(false)
}
pub fn generate_uuid() -> String {
uuid::Uuid::new_v4().to_string()
}
#[derive(Debug, Clone)]
pub struct WrapperOptions {
pub isolated: Option<String>,
pub attached: bool,
pub detached: bool,
pub session: Option<String>,
pub session_id: Option<String>,
pub image: Option<String>,
pub volumes: Vec<String>,
pub mounts: Vec<String>,
pub env: Vec<String>,
pub privileged: bool,
pub endpoint: Option<String>,
pub user: bool,
pub user_name: Option<String>,
pub keep_user: bool,
pub keep_alive: bool,
pub auto_remove_docker_container: bool,
pub shell: String,
pub use_command_stream: bool,
pub status: Option<String>,
pub list: bool,
pub upload_log: Option<String>,
pub output_format: Option<String>,
pub stop: Option<String>,
pub terminate: Option<String>,
pub cleanup: bool,
pub cleanup_dry_run: bool,
}
impl Default for WrapperOptions {
fn default() -> Self {
WrapperOptions {
isolated: None,
attached: false,
detached: false,
session: None,
session_id: None,
image: None,
volumes: Vec::new(),
mounts: Vec::new(),
env: Vec::new(),
privileged: false,
endpoint: None,
user: false,
user_name: None,
keep_user: false,
keep_alive: false,
auto_remove_docker_container: false,
shell: "auto".to_string(),
use_command_stream: false,
status: None,
list: false,
upload_log: None,
output_format: None,
stop: None,
terminate: None,
cleanup: false,
cleanup_dry_run: false,
}
}
}
#[derive(Debug)]
pub struct ParsedArgs {
pub wrapper_options: WrapperOptions,
pub command: String,
pub raw_command: Vec<String>,
}
pub fn parse_args(args: &[String]) -> Result<ParsedArgs, String> {
let mut wrapper_options = WrapperOptions::default();
let mut command_args: Vec<String> = Vec::new();
let separator_index = args.iter().position(|a| a == "--");
if let Some(sep_idx) = separator_index {
let wrapper_args: Vec<String> = args[..sep_idx].to_vec();
command_args = args[sep_idx + 1..].to_vec();
parse_wrapper_args(&wrapper_args, &mut wrapper_options)?;
} else {
let mut i = 0;
while i < args.len() {
let arg = &args[i];
if arg.starts_with('-') {
match parse_option(args, i, &mut wrapper_options)? {
0 => {
return Err(format!("Unknown wrapper option: {}", arg));
}
consumed => {
i += consumed;
}
}
} else {
command_args = args[i..].to_vec();
break;
}
}
}
validate_options(&mut wrapper_options)?;
Ok(ParsedArgs {
wrapper_options,
command: command_args.join(" "),
raw_command: command_args,
})
}
fn parse_wrapper_args(args: &[String], options: &mut WrapperOptions) -> Result<(), String> {
let mut i = 0;
while i < args.len() {
match parse_option(args, i, options)? {
0 => {
if args[i].starts_with('-') {
return Err(format!("Unknown wrapper option: {}", args[i]));
}
if env::var("START_DEBUG").is_ok_and(|v| v == "1" || v == "true") {
eprintln!("Unknown wrapper option: {}", args[i]);
}
i += 1;
}
consumed => {
i += consumed;
}
}
}
Ok(())
}
fn parse_option(
args: &[String],
index: usize,
options: &mut WrapperOptions,
) -> Result<usize, String> {
let arg = &args[index];
if arg == "--isolated" || arg == "--isolation" || arg == "-i" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.isolated = Some(args[index + 1].to_lowercase());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a backend argument (screen, tmux, docker, ssh)",
arg
));
}
}
if arg.starts_with("--isolated=") || arg.starts_with("--isolation=") {
options.isolated = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
return Ok(1);
}
if arg == "--attached" || arg == "-a" {
options.attached = true;
return Ok(1);
}
if arg == "--detached" || arg == "-d" {
options.detached = true;
return Ok(1);
}
if arg == "--session" || arg == "-s" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.session = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires a session name argument", arg));
}
}
if arg.starts_with("--session=") {
options.session = Some(arg.split('=').nth(1).unwrap_or("").to_string());
return Ok(1);
}
if arg == "--image" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.image = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires an image name argument", arg));
}
}
if arg.starts_with("--image=") {
options.image = Some(arg.split('=').nth(1).unwrap_or("").to_string());
return Ok(1);
}
if arg == "--volume" || arg == "-v" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.volumes.push(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a volume argument (host:container[:mode])",
arg
));
}
}
if arg.starts_with("--volume=") || arg.starts_with("-v=") {
options
.volumes
.push(arg[arg.find('=').unwrap() + 1..].to_string());
return Ok(1);
}
if arg == "--mount" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.mounts.push(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires a mount spec argument", arg));
}
}
if let Some(value) = arg.strip_prefix("--mount=") {
options.mounts.push(value.to_string());
return Ok(1);
}
if arg == "--env" || arg == "-e" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.env.push(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires a KEY=VALUE argument", arg));
}
}
if arg.starts_with("--env=") || arg.starts_with("-e=") {
options
.env
.push(arg[arg.find('=').unwrap() + 1..].to_string());
return Ok(1);
}
if arg == "--privileged" {
options.privileged = true;
return Ok(1);
}
if arg == "--endpoint" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.endpoint = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires an endpoint argument", arg));
}
}
if arg.starts_with("--endpoint=") {
options.endpoint = Some(arg.split('=').nth(1).unwrap_or("").to_string());
return Ok(1);
}
if arg == "--isolated-user" || arg == "-u" {
options.user = true;
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
let next_arg = &args[index + 1];
let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
if username_regex.is_match(next_arg) && next_arg.len() <= 32 {
options.user_name = Some(next_arg.clone());
return Ok(2);
}
}
return Ok(1);
}
if arg.starts_with("--isolated-user=") {
options.user = true;
options.user_name = Some(arg.split('=').nth(1).unwrap_or("").to_string());
return Ok(1);
}
if arg == "--keep-user" {
options.keep_user = true;
return Ok(1);
}
if arg == "--keep-alive" || arg == "-k" {
options.keep_alive = true;
return Ok(1);
}
if arg == "--auto-remove-docker-container" {
options.auto_remove_docker_container = true;
return Ok(1);
}
if arg == "--shell" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.shell = args[index + 1].to_lowercase();
return Ok(2);
} else {
return Err(format!(
"Option {} requires a shell argument (auto, bash, zsh, sh)",
arg
));
}
}
if arg.starts_with("--shell=") {
options.shell = arg.split('=').nth(1).unwrap_or("").to_lowercase();
return Ok(1);
}
if arg == "--use-command-stream" {
options.use_command_stream = true;
return Ok(1);
}
if arg == "--session-id" || arg == "--session-name" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.session_id = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!("Option {} requires a UUID argument", arg));
}
}
if arg.starts_with("--session-id=") || arg.starts_with("--session-name=") {
options.session_id = Some(arg.split('=').nth(1).unwrap_or("").to_string());
return Ok(1);
}
if arg == "--status" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.status = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a UUID or session name argument",
arg
));
}
}
if let Some(value) = arg.strip_prefix("--status=") {
if value.is_empty() {
return Err("Option --status requires a UUID or session name argument".to_string());
}
options.status = Some(value.to_string());
return Ok(1);
}
if arg == "--upload-log" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.upload_log = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a UUID or session name argument",
arg
));
}
}
if let Some(value) = arg.strip_prefix("--upload-log=") {
if value.is_empty() {
return Err("Option --upload-log requires a UUID or session name argument".to_string());
}
options.upload_log = Some(value.to_string());
return Ok(1);
}
if arg == "--stop" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.stop = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a UUID or session name argument",
arg
));
}
}
if let Some(value) = arg.strip_prefix("--stop=") {
if value.is_empty() {
return Err("Option --stop requires a UUID or session name argument".to_string());
}
options.stop = Some(value.to_string());
return Ok(1);
}
if arg == "--terminate" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.terminate = Some(args[index + 1].clone());
return Ok(2);
} else {
return Err(format!(
"Option {} requires a UUID or session name argument",
arg
));
}
}
if let Some(value) = arg.strip_prefix("--terminate=") {
if value.is_empty() {
return Err("Option --terminate requires a UUID or session name argument".to_string());
}
options.terminate = Some(value.to_string());
return Ok(1);
}
if arg == "--list" {
options.list = true;
return Ok(1);
}
if arg == "--output-format" {
if index + 1 < args.len() && !args[index + 1].starts_with('-') {
options.output_format = Some(args[index + 1].to_lowercase());
return Ok(2);
} else {
return Err(format!("Option {} requires a format argument", arg));
}
}
if arg.starts_with("--output-format=") {
options.output_format = Some(arg.split('=').nth(1).unwrap_or("").to_lowercase());
return Ok(1);
}
if arg == "--cleanup" {
options.cleanup = true;
return Ok(1);
}
if arg == "--cleanup-dry-run" {
options.cleanup = true;
options.cleanup_dry_run = true;
return Ok(1);
}
Ok(0)
}
pub fn validate_options(options: &mut WrapperOptions) -> Result<(), String> {
if options.attached && options.detached {
return Err(
"Cannot use both --attached and --detached at the same time. Please choose only one mode."
.to_string(),
);
}
if let Some(ref backend) = options.isolated {
if !VALID_BACKENDS.contains(&backend.as_str()) {
return Err(format!(
"Invalid isolation backend: \"{}\". Valid options are: {}",
backend,
VALID_BACKENDS.join(", ")
));
}
if backend == "docker" && options.image.is_none() {
options.image = Some(get_default_docker_image());
}
if backend == "ssh" && options.endpoint.is_none() {
return Err(
"SSH isolation requires --endpoint option to specify the remote server (e.g., user@host)"
.to_string(),
);
}
}
if options.session.is_some() && options.isolated.is_none() {
return Err("--session option is only valid with --isolated".to_string());
}
if options.image.is_some() && options.isolated.as_deref() != Some("docker") {
return Err("--image option is only valid with --isolated docker".to_string());
}
let is_docker = options.isolated.as_deref() == Some("docker");
if !options.volumes.is_empty() && !is_docker {
return Err("--volume option is only valid with --isolated docker".to_string());
}
if !options.mounts.is_empty() && !is_docker {
return Err("--mount option is only valid with --isolated docker".to_string());
}
if !options.env.is_empty() && !is_docker {
return Err("--env option is only valid with --isolated docker".to_string());
}
if options.privileged && !is_docker {
return Err("--privileged option is only valid with --isolated docker".to_string());
}
if options.endpoint.is_some() && options.isolated.as_deref() != Some("ssh") {
return Err("--endpoint option is only valid with --isolated ssh".to_string());
}
if options.keep_alive && options.isolated.is_none() {
return Err("--keep-alive option is only valid with --isolated".to_string());
}
if options.auto_remove_docker_container && options.isolated.as_deref() != Some("docker") {
return Err(
"--auto-remove-docker-container option is only valid with --isolated docker"
.to_string(),
);
}
if options.user {
if options.isolated.as_deref() == Some("docker") {
return Err(
"--isolated-user is not supported with Docker isolation. Docker uses its own user namespace for isolation."
.to_string(),
);
}
if let Some(ref username) = options.user_name {
let username_regex = regex::Regex::new(r"^[a-zA-Z0-9_-]+$").unwrap();
if !username_regex.is_match(username) {
return Err(format!(
"Invalid username format for --isolated-user: \"{}\". Username should contain only letters, numbers, hyphens, and underscores.",
username
));
}
if username.len() > 32 {
return Err(format!(
"Username too long for --isolated-user: \"{}\". Maximum length is 32 characters.",
username
));
}
}
}
if options.keep_user && !options.user {
return Err("--keep-user option is only valid with --isolated-user".to_string());
}
if let Some(ref format) = options.output_format {
if !VALID_OUTPUT_FORMATS.contains(&format.as_str()) {
return Err(format!(
"Invalid output format: \"{}\". Valid options are: {}",
format,
VALID_OUTPUT_FORMATS.join(", ")
));
}
}
let query_modes = [
options.status.is_some(),
options.list,
options.upload_log.is_some(),
options.stop.is_some(),
options.terminate.is_some(),
options.cleanup,
]
.into_iter()
.filter(|enabled| *enabled)
.count();
if query_modes > 1 {
return Err(
"Cannot combine --status, --list, --upload-log, --stop, --terminate, or --cleanup in the same invocation"
.to_string(),
);
}
if options.output_format.is_some() && options.status.is_none() && !options.list {
return Err("--output-format option is only valid with --status or --list".to_string());
}
if !VALID_SHELLS.contains(&options.shell.as_str()) {
return Err(format!(
"Invalid shell: \"{}\". Valid options are: {}",
options.shell,
VALID_SHELLS.join(", ")
));
}
if let Some(ref session_id) = options.session_id {
if !is_valid_uuid(session_id) {
return Err(format!(
"Invalid session ID: \"{}\". Session ID must be a valid UUID v4.",
session_id
));
}
}
Ok(())
}
pub fn generate_session_name(prefix: Option<&str>) -> String {
use std::cell::RefCell;
use std::time::{SystemTime, UNIX_EPOCH};
thread_local! {
static STATE: RefCell<u64> = RefCell::new(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos() as u64
);
}
fn next_random() -> u64 {
STATE.with(|state| {
let mut s = state.borrow_mut();
*s ^= *s << 13;
*s ^= *s >> 7;
*s ^= *s << 17;
*s
})
}
let prefix = prefix.unwrap_or("start");
let timestamp = chrono::Utc::now().timestamp_millis();
let random: String = (0..6)
.map(|_| {
let idx = (next_random() % 36) as u8;
if idx < 10 {
(b'0' + idx) as char
} else {
(b'a' + idx - 10) as char
}
})
.collect();
format!("{}-{}-{}", prefix, timestamp, random)
}
pub fn has_isolation(options: &WrapperOptions) -> bool {
options.isolated.is_some()
}
pub fn get_effective_mode(options: &WrapperOptions) -> &'static str {
if options.detached {
"detached"
} else {
"attached"
}
}
#[cfg(test)]
#[path = "args_parser_cases.rs"]
mod tests;