Skip to main content

shell_tunnel/
cli.rs

1//! Command-line interface for shell-tunnel.
2//!
3//! Uses lexopt for minimal binary size overhead (~34KB).
4
5use std::ffi::OsString;
6use std::net::IpAddr;
7use std::path::PathBuf;
8
9/// Command-line arguments.
10#[derive(Debug, Clone)]
11pub struct Args {
12    /// Host address to bind to.
13    pub host: IpAddr,
14    /// Whether the bind address was stated rather than defaulted.
15    ///
16    /// Without this the default is indistinguishable from a choice, and
17    /// `apply_args` overwrites a configured `server.host` with `127.0.0.1` for
18    /// a user who passed no flag at all. Since 0.14.0 that field also decides
19    /// the security posture, so "not stated" has to be a fact the config layer
20    /// can read rather than one it has to guess.
21    pub host_explicit: bool,
22    /// Port to listen on.
23    pub port: u16,
24    /// Whether the port was stated rather than defaulted.
25    ///
26    /// A relay-attached device serves only itself on loopback, so the port is an
27    /// implementation detail there — but only if the user did not ask for one.
28    /// Also what keeps `apply_args` from overwriting a configured
29    /// `server.port`, the same way `host_explicit` does above.
30    pub port_explicit: bool,
31    /// Path to configuration file.
32    pub config: Option<PathBuf>,
33    /// API key for authentication (overrides config file).
34    pub api_key: Option<String>,
35    /// Disable authentication.
36    pub no_auth: bool,
37    /// Require authentication, auto-generating an API key if none is provided.
38    pub require_auth: bool,
39    /// Capability strings scoping the issued token(s) (empty = full-control).
40    pub capabilities: Vec<String>,
41    /// Role preset scoping the issued token(s) (operator/file-write/file-read/full-control).
42    pub preset: Option<String>,
43    /// Disable rate limiting.
44    pub no_rate_limit: bool,
45    /// Expose the server through a Cloudflare quick tunnel.
46    pub tunnel: bool,
47    /// Expose the server through an arbitrary tunnel command.
48    pub tunnel_command: Option<String>,
49    /// Run as a relay server (`shell-tunnel relay`) instead of a shell gateway.
50    pub relay: bool,
51    /// Attach to this relay instead of publishing through a tunnel.
52    pub relay_url: Option<String>,
53    /// Shared secret devices present to attach to this relay.
54    pub enroll_token: Option<String>,
55    /// Public base URL this relay is reachable at.
56    pub public_base: Option<String>,
57    /// Stable name to claim on the relay (keeps one URL across reconnects).
58    pub device_name: Option<String>,
59    /// PEM certificate chain for serving HTTPS directly.
60    pub tls_cert: Option<PathBuf>,
61    /// PEM private key matching `tls_cert`.
62    pub tls_key: Option<PathBuf>,
63    /// Generate a self-signed certificate when none is present.
64    pub tls_self_signed: bool,
65    /// Expect exactly this certificate fingerprint from the relay.
66    pub relay_fingerprint: Option<String>,
67    /// Extra PEM certificate authority to trust when dialling a relay.
68    pub relay_ca: Option<PathBuf>,
69    /// Additional host names this server answers to.
70    pub allow_hosts: Vec<String>,
71    /// Append an audit trail of executions and refusals to this file.
72    pub audit_log: Option<PathBuf>,
73    /// Directory the filesystem API is confined to. `None` disables the API.
74    pub fs_root: Option<PathBuf>,
75    /// Chunk size advertised to upload clients, in bytes.
76    pub fs_chunk_size: Option<usize>,
77    /// Rotation limit for the audit trail exactly as the operator wrote it.
78    ///
79    /// `None` means the flag was absent, which is *not* the same as unbounded —
80    /// see [`Args::audit_rotation_limit`], which is what callers should use.
81    /// Kept raw so the three cases (absent / a size / an explicit `0`) stay
82    /// distinguishable here.
83    pub audit_max_bytes_raw: Option<u64>,
84    /// Kill whatever a command leaves running when the command ends.
85    ///
86    /// Off by default: a command that deliberately starts a daemon expects it
87    /// to outlive the request, and turning that off by default would break it
88    /// silently. See `src/process.rs`.
89    pub kill_orphans: bool,
90    /// Allow any CORS origin (permissive; opt-in for browser UIs).
91    pub cors_allow_any: bool,
92    /// Log level (error, warn, info, debug, trace).
93    pub log_level: Option<String>,
94    /// Show version and exit.
95    pub version: bool,
96    /// Show help and exit.
97    pub help: bool,
98    /// Check for updates and exit.
99    pub check_update: bool,
100    /// Perform self-update and exit.
101    pub update: bool,
102    /// Disable automatic update check on startup.
103    pub no_update_check: bool,
104}
105
106impl Default for Args {
107    fn default() -> Self {
108        Self {
109            host: "127.0.0.1".parse().unwrap(),
110            port: 3000,
111            host_explicit: false,
112            port_explicit: false,
113            config: None,
114            api_key: None,
115            no_auth: false,
116            require_auth: false,
117            capabilities: Vec::new(),
118            preset: None,
119            no_rate_limit: false,
120            tunnel: false,
121            tunnel_command: None,
122            relay: false,
123            relay_url: None,
124            enroll_token: None,
125            public_base: None,
126            device_name: None,
127            tls_cert: None,
128            tls_key: None,
129            tls_self_signed: false,
130            relay_fingerprint: None,
131            relay_ca: None,
132            allow_hosts: Vec::new(),
133            audit_log: None,
134            audit_max_bytes_raw: None,
135            fs_root: None,
136            fs_chunk_size: None,
137            kill_orphans: false,
138            cors_allow_any: false,
139            log_level: None,
140            version: false,
141            help: false,
142            check_update: false,
143            update: false,
144            no_update_check: false,
145        }
146    }
147}
148
149impl Args {
150    /// The audit rotation limit to apply, or `None` to never rotate.
151    ///
152    /// Three inputs, three answers, and the middle one is the reason this is a
153    /// method rather than a field read:
154    ///
155    /// | `--audit-max-bytes` | result |
156    /// |---|---|
157    /// | absent | [`audit::DEFAULT_MAX_BYTES`](crate::audit::DEFAULT_MAX_BYTES) |
158    /// | `0` | `None` — never rotate |
159    /// | `N` | `Some(N)` |
160    ///
161    /// `0` has to mean "unbounded" rather than "rotate immediately", because
162    /// rotating at zero bytes would rotate on every single entry and keep
163    /// nothing. It is also the escape hatch for an operator who had the old
164    /// unbounded behaviour and wants it back.
165    pub fn audit_rotation_limit(&self) -> Option<u64> {
166        match self.audit_max_bytes_raw {
167            None => Some(crate::audit::DEFAULT_MAX_BYTES),
168            Some(0) => None,
169            Some(n) => Some(n),
170        }
171    }
172}
173
174/// Parse command-line arguments.
175pub fn parse_args() -> Result<Args, ArgsError> {
176    parse_args_from(std::env::args_os())
177}
178
179/// Parse arguments from an iterator (for testing).
180pub fn parse_args_from<I>(args: I) -> Result<Args, ArgsError>
181where
182    I: IntoIterator<Item = OsString>,
183{
184    use lexopt::prelude::*;
185
186    let mut result = Args::default();
187    let mut parser = lexopt::Parser::from_iter(args);
188
189    while let Some(arg) = parser.next()? {
190        match arg {
191            Short('h') | Long("help") => {
192                result.help = true;
193            }
194            Short('V') | Long("version") => {
195                result.version = true;
196            }
197            Short('H') | Long("host") => {
198                let value: String = parser.value()?.parse()?;
199                result.host = value
200                    .parse()
201                    .map_err(|_| ArgsError::InvalidValue("host", value))?;
202                result.host_explicit = true;
203            }
204            Short('p') | Long("port") => {
205                let value: String = parser.value()?.parse()?;
206                result.port = value
207                    .parse()
208                    .map_err(|_| ArgsError::InvalidValue("port", value))?;
209                result.port_explicit = true;
210            }
211            Short('c') | Long("config") => {
212                result.config = Some(parser.value()?.parse()?);
213            }
214            Short('k') | Long("api-key") => {
215                result.api_key = Some(parser.value()?.parse()?);
216            }
217            Long("no-auth") => {
218                result.no_auth = true;
219            }
220            Long("require-auth") => {
221                result.require_auth = true;
222            }
223            Long("capabilities") => {
224                // Comma-separated; may be repeated. Accumulate non-empty entries.
225                let value: String = parser.value()?.parse()?;
226                result.capabilities.extend(
227                    value
228                        .split(',')
229                        .map(|s| s.trim())
230                        .filter(|s| !s.is_empty())
231                        .map(String::from),
232                );
233            }
234            Long("preset") => {
235                result.preset = Some(parser.value()?.parse()?);
236            }
237            Long("no-rate-limit") => {
238                result.no_rate_limit = true;
239            }
240            Long("tunnel") => {
241                result.tunnel = true;
242            }
243            Long("tunnel-command") => {
244                result.tunnel_command = Some(parser.value()?.parse()?);
245            }
246            Long("relay") => {
247                result.relay_url = Some(parser.value()?.parse()?);
248            }
249            Long("enroll-token") => {
250                result.enroll_token = Some(parser.value()?.parse()?);
251            }
252            Long("public-base") => {
253                result.public_base = Some(parser.value()?.parse()?);
254            }
255            Long("device-name") => {
256                result.device_name = Some(parser.value()?.parse()?);
257            }
258            Long("tls-cert") => {
259                result.tls_cert = Some(parser.value()?.parse()?);
260            }
261            Long("tls-key") => {
262                result.tls_key = Some(parser.value()?.parse()?);
263            }
264            Long("tls-self-signed") => {
265                result.tls_self_signed = true;
266            }
267            Long("relay-fingerprint") => {
268                result.relay_fingerprint = Some(parser.value()?.parse()?);
269            }
270            Long("relay-ca") => {
271                result.relay_ca = Some(parser.value()?.parse()?);
272            }
273            Long("allow-host") => {
274                let value: String = parser.value()?.parse()?;
275                result.allow_hosts.push(value);
276            }
277            Long("audit-log") => {
278                result.audit_log = Some(parser.value()?.parse()?);
279            }
280            Long("audit-max-bytes") => {
281                let value: String = parser.value()?.parse()?;
282                result.audit_max_bytes_raw = Some(
283                    value
284                        .parse()
285                        .map_err(|_| ArgsError::InvalidValue("audit-max-bytes", value))?,
286                );
287            }
288            Long("kill-orphans") => {
289                result.kill_orphans = true;
290            }
291            Long("cors-allow-any") => {
292                result.cors_allow_any = true;
293            }
294            Long("fs-root") => {
295                result.fs_root = Some(parser.value()?.parse()?);
296            }
297            Long("fs-chunk-size") => {
298                let value: String = parser.value()?.parse()?;
299                result.fs_chunk_size = Some(
300                    value
301                        .parse()
302                        .map_err(|_| ArgsError::InvalidValue("fs-chunk-size", value))?,
303                );
304            }
305            Short('l') | Long("log-level") => {
306                result.log_level = Some(parser.value()?.parse()?);
307            }
308            #[cfg(feature = "self-update")]
309            Long("check-update") => {
310                result.check_update = true;
311            }
312            #[cfg(feature = "self-update")]
313            Long("update") => {
314                result.update = true;
315            }
316            #[cfg(feature = "self-update")]
317            Long("no-update-check") => {
318                result.no_update_check = true;
319            }
320            // The only positional is the `relay` subcommand, which switches the
321            // binary into relay-server mode. Bind address and port keep using
322            // -H/-p so one CLI vocabulary covers both modes.
323            Value(val) if val == "relay" && !result.relay => {
324                result.relay = true;
325            }
326            Value(val) => {
327                return Err(ArgsError::UnexpectedArgument(val.to_string_lossy().into()));
328            }
329            _ => return Err(arg.unexpected().into()),
330        }
331    }
332
333    // A certificate without its key (or the reverse) cannot serve anything, and
334    // silently falling back to plaintext would be the opposite of what was asked.
335    if result.tls_cert.is_some() != result.tls_key.is_some() {
336        return Err(ArgsError::Conflicting("--tls-cert", "--tls-key"));
337    }
338
339    // `--tls-self-signed` needs no paths; naming them just says where to put it.
340    if result.tls_self_signed && result.tls_cert.is_none() {
341        let defaults = (
342            std::path::PathBuf::from("shell-tunnel-cert.pem"),
343            std::path::PathBuf::from("shell-tunnel-key.pem"),
344        );
345        result.tls_cert = Some(defaults.0);
346        result.tls_key = Some(defaults.1);
347    }
348
349    // Relay mode serves devices, not shells: a tunnel would publish the wrong
350    // thing entirely.
351    if result.relay && (result.tunnel || result.tunnel_command.is_some()) {
352        return Err(ArgsError::Conflicting("relay", "--tunnel"));
353    }
354    if result.relay_url.is_some() && result.tunnel {
355        return Err(ArgsError::Conflicting("--relay", "--tunnel"));
356    }
357    if result.relay_url.is_some() && result.tunnel_command.is_some() {
358        return Err(ArgsError::Conflicting("--relay", "--tunnel-command"));
359    }
360
361    // Reachability paths are mutually exclusive: two tunnels would each publish
362    // a different public URL for the same server, and only one can be reported.
363    if result.tunnel && result.tunnel_command.is_some() {
364        return Err(ArgsError::Conflicting("--tunnel", "--tunnel-command"));
365    }
366
367    Ok(result)
368}
369
370/// Print help message.
371pub fn print_help() {
372    let version = env!("CARGO_PKG_VERSION");
373
374    // Update flags exist only when compiled with the `self-update` feature.
375    #[cfg(feature = "self-update")]
376    let update_opts = "        --check-update      Check for updates and exit\n        --update            Download and install latest version\n        --no-update-check   Disable automatic update check on startup\n";
377    #[cfg(not(feature = "self-update"))]
378    let update_opts = "";
379
380    #[cfg(feature = "self-update")]
381    let update_examples = "\n    # Check for updates\n    shell-tunnel --check-update\n\n    # Self-update to latest version\n    shell-tunnel --update\n";
382    #[cfg(not(feature = "self-update"))]
383    let update_examples = "";
384
385    println!(
386        r#"shell-tunnel {version}
387Ultra-lightweight remote shell gateway with a REST/WebSocket API
388
389USAGE:
390    shell-tunnel [OPTIONS]              Serve a shell gateway
391    shell-tunnel relay [OPTIONS]        Serve a relay that devices dial out to
392
393OPTIONS:
394    -H, --host <ADDR>       Host address to bind [default: 127.0.0.1]
395    -p, --port <PORT>       Port to listen on [default: 3000]
396    -c, --config <FILE>     Path to configuration file (JSON)
397    -k, --api-key <KEY>     API key callers present to run commands here. Adds to
398                            any keys a config file lists rather than replacing
399                            them; edit the file to retire a key
400    -l, --log-level <LVL>   Log level (error, warn, info, debug, trace)
401        --no-auth           Disable authentication (refused when reachable)
402        --require-auth      Require auth, auto-generating an API key if none given
403                            and printing it on stdout (never in the log, which a
404                            log level can silence)
405        --capabilities <C>  Scope issued token(s): comma-separated capabilities
406                            (e.g. exec,session.read). Default: full-control, or
407                            operator when the server is reachable
408        --preset <NAME>     Scope issued token(s) by role preset
409                            (operator | file-write | file-read | full-control)
410        --no-rate-limit     Disable rate limiting. No X-RateLimit-* headers are
411                            then sent — there is no budget to report. On a relay
412                            it also drops the only thing bounding guesses at the
413                            enrol token, which the relay warns about at startup
414        --tunnel            Expose publicly via a Cloudflare quick tunnel
415                            (requires `cloudflared`; implies authentication)
416        --tunnel-command <C>
417                            Expose publicly by running an arbitrary tunnel
418                            command (ngrok, bore, frp, ...); its printed URL
419                            is used. Implies authentication
420        --relay <URL>       Attach to a self-hosted relay (dial out, no inbound
421                            port). Needs the relay's --enroll-token; implies
422                            authentication. The local port is chosen for you
423                            unless -p says otherwise
424        --device-name <N>   Claim a stable name on the relay, so the device URL
425                            survives reconnects [default: this machine's name]
426        --relay-fingerprint <FP>
427                            Expect exactly this certificate from the relay, as
428                            printed by `shell-tunnel relay --tls-self-signed`.
429                            Nothing to copy but the string, and the certificate
430                            need not name the address being dialled
431        --relay-ca <FILE>   Also trust this PEM authority when dialling a relay
432                            (the alternative to a fingerprint, for a private CA)
433        --allow-host <HOST> Also answer to this host name. A loopback-bound
434                            server that is not published otherwise answers only
435                            to localhost, which is what stops DNS rebinding.
436                            Published, nothing is host-checked. Repeatable
437        --audit-log <FILE>  Append executions, denied requests, and file
438                            operations to this file (JSON per line; the token
439                            itself is never written)
440                            [default: off; shell-tunnel-audit.jsonl when reachable]
441        --audit-max-bytes <N>
442                            Rotate the audit trail to <FILE>.1 past this size,
443                            keeping one generation. 0 never rotates
444                            [default: 67108864 (64 MiB)]
445        --kill-orphans      Kill anything a command leaves running when the
446                            command ends. Off by default, because a daemon
447                            started on purpose is meant to outlive the request
448        --cors-allow-any    Allow any CORS origin (opt-in; for browser UIs)
449        --fs-root <PATH>    Confine the file API to this directory. Without it
450                            the API reaches everything this account can
451        --fs-chunk-size <N> Upload chunk size advertised to callers, in bytes.
452                            Default 4194304; 262144 when --relay is given,
453                            because a relayed chunk must also finish inside the
454                            relay's 120s request deadline
455
456TLS OPTIONS (with `relay`):
457        --tls-self-signed   Serve HTTPS with a self-signed certificate,
458                            generating one on first run and reusing it after.
459                            Needs no paths; devices trust it with the
460                            --relay-fingerprint the banner prints. Its names are
461                            fixed when it is generated, so adding --public-base
462                            later does not add that name — the banner says which
463                            names it actually covers
464        --tls-cert <FILE>   PEM certificate chain [default with --tls-self-signed:
465                            shell-tunnel-cert.pem]
466        --tls-key <FILE>    PEM private key matching the certificate
467
468                            A gateway does not serve HTTPS and refuses these
469                            flags at startup: reach it through a tunnel or a
470                            relay, which carry their own TLS, or put a reverse
471                            proxy in front. Its own socket is plaintext.
472                            With a proxy, pass --require-auth: the proxy does
473                            not change the bind address, so a loopback-bound
474                            gateway still counts itself local and leaves
475                            authentication off while the proxy publishes it.
476                            Forget it and the server warns once, on the first
477                            request carrying a proxy header — but a proxy that
478                            forwards none of them leaves nothing to warn about
479
480RELAY OPTIONS (with `relay`):
481        --enroll-token <T>  Secret devices present to attach to this relay
482                            (generated if unset). Distinct from --api-key, which
483                            is what callers present to a device
484        --public-base <URL> Public base URL of this relay. A URL with no port
485                            uses this relay's listen port; name a port only when
486                            a proxy remaps it [default: http://<bind address>]
487
488OTHER OPTIONS:
489{update_opts}    -h, --help              Print help
490    -V, --version           Print version
491
492ENVIRONMENT VARIABLES:
493    SHELL_TUNNEL_HOST       Bind address, unless -H names one
494    SHELL_TUNNEL_PORT       Port, unless -p names one
495    SHELL_TUNNEL_API_KEY    Adds an API key and turns auth on. Keys from the
496                            config file stay valid alongside it
497    SHELL_TUNNEL_LOG_LEVEL  Log level (overrides config)
498    RUST_LOG                Alternative log level setting
499
500EXAMPLES:
501    # Start with defaults (localhost:3000, no auth)
502    shell-tunnel
503
504    # Start on all interfaces with API key
505    shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
506
507    # Start with config file
508    shell-tunnel -c /etc/shell-tunnel/config.json
509
510    # Development mode (no security)
511    shell-tunnel --no-auth --no-rate-limit
512
513    # Publish on the internet with a generated key (no account needed)
514    shell-tunnel --tunnel
515
516    # Publish using a different tunnel client
517    shell-tunnel --tunnel-command "ngrok http 3000"
518
519    # Attach to a relay under a stable name
520    shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
521
522    # Run a relay with HTTPS, generating a certificate on first run.
523    # --public-base names the host; the URL uses this relay's port (8443).
524    shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
525
526    # Behind a proxy that forwards 443 here, name the port devices dial
527    shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
528
529    # Issue a token that can only read files, confined to one directory
530    shell-tunnel -k readonly-key --preset file-read --fs-root /srv/deploy
531
532    # Issue a token scoped to specific capabilities
533    shell-tunnel -k ci-key --capabilities exec,session.read
534{update_examples}"#
535    );
536}
537
538/// Print version.
539pub fn print_version() {
540    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
541}
542
543/// Argument parsing errors.
544#[derive(Debug)]
545pub enum ArgsError {
546    /// Lexopt parsing error.
547    Lexopt(lexopt::Error),
548    /// Invalid argument value.
549    InvalidValue(&'static str, String),
550    /// Unexpected positional argument.
551    UnexpectedArgument(String),
552    /// Two mutually exclusive flags were given.
553    Conflicting(&'static str, &'static str),
554}
555
556impl std::fmt::Display for ArgsError {
557    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
558        match self {
559            Self::Lexopt(e) => write!(f, "{}", e),
560            Self::InvalidValue(name, value) => {
561                write!(f, "invalid value for --{}: '{}'", name, value)
562            }
563            Self::UnexpectedArgument(arg) => {
564                write!(f, "unexpected argument: '{}'", arg)
565            }
566            Self::Conflicting(a, b) if a.starts_with("--tls") => {
567                write!(f, "{} and {} must be given together", a, b)
568            }
569            Self::Conflicting(a, b) => {
570                write!(f, "{} and {} cannot be used together", a, b)
571            }
572        }
573    }
574}
575
576impl std::error::Error for ArgsError {}
577
578impl From<lexopt::Error> for ArgsError {
579    fn from(e: lexopt::Error) -> Self {
580        Self::Lexopt(e)
581    }
582}
583
584#[cfg(test)]
585mod tests {
586    use super::*;
587
588    fn args(args: &[&str]) -> Vec<OsString> {
589        std::iter::once("shell-tunnel")
590            .chain(args.iter().copied())
591            .map(OsString::from)
592            .collect()
593    }
594
595    #[test]
596    fn test_default_args() {
597        let result = parse_args_from(args(&[])).unwrap();
598        assert_eq!(result.host.to_string(), "127.0.0.1");
599        assert_eq!(result.port, 3000);
600        assert!(!result.no_auth);
601    }
602
603    #[test]
604    fn test_host_port() {
605        let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
606        assert_eq!(result.host.to_string(), "0.0.0.0");
607        assert_eq!(result.port, 8080);
608    }
609
610    #[test]
611    fn test_long_options() {
612        let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
613        assert_eq!(result.host.to_string(), "192.168.1.1");
614        assert_eq!(result.port, 9000);
615    }
616
617    #[test]
618    fn test_api_key() {
619        let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
620        assert_eq!(result.api_key, Some("my-secret".to_string()));
621    }
622
623    #[test]
624    fn test_config_file() {
625        let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
626        assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
627    }
628
629    #[test]
630    fn test_no_auth() {
631        let result = parse_args_from(args(&["--no-auth"])).unwrap();
632        assert!(result.no_auth);
633    }
634
635    #[test]
636    fn test_require_auth() {
637        let result = parse_args_from(args(&["--require-auth"])).unwrap();
638        assert!(result.require_auth);
639        assert!(!Args::default().require_auth);
640    }
641
642    #[test]
643    fn test_no_rate_limit() {
644        let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
645        assert!(result.no_rate_limit);
646    }
647
648    #[test]
649    fn test_capabilities_csv() {
650        let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
651        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
652        assert!(Args::default().capabilities.is_empty());
653    }
654
655    #[test]
656    fn test_capabilities_trims_and_ignores_blanks() {
657        let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
658        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
659    }
660
661    #[test]
662    fn test_capabilities_repeated_accumulate() {
663        let result = parse_args_from(args(&[
664            "--capabilities",
665            "exec",
666            "--capabilities",
667            "session.read,session.manage",
668        ]))
669        .unwrap();
670        assert_eq!(
671            result.capabilities,
672            vec!["exec", "session.read", "session.manage"]
673        );
674    }
675
676    #[test]
677    fn test_preset() {
678        let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
679        assert_eq!(result.preset, Some("operator".to_string()));
680        assert!(Args::default().preset.is_none());
681    }
682
683    #[test]
684    fn test_help_flag() {
685        let result = parse_args_from(args(&["-h"])).unwrap();
686        assert!(result.help);
687
688        let result = parse_args_from(args(&["--help"])).unwrap();
689        assert!(result.help);
690    }
691
692    #[test]
693    fn test_version_flag() {
694        let result = parse_args_from(args(&["-V"])).unwrap();
695        assert!(result.version);
696
697        let result = parse_args_from(args(&["--version"])).unwrap();
698        assert!(result.version);
699    }
700
701    #[test]
702    fn test_log_level() {
703        let result = parse_args_from(args(&["-l", "debug"])).unwrap();
704        assert_eq!(result.log_level, Some("debug".to_string()));
705    }
706
707    #[test]
708    fn test_invalid_port() {
709        let result = parse_args_from(args(&["-p", "invalid"]));
710        assert!(result.is_err());
711    }
712
713    #[test]
714    fn test_invalid_host() {
715        let result = parse_args_from(args(&["-H", "not-an-ip"]));
716        assert!(result.is_err());
717    }
718
719    #[test]
720    fn test_combined_options() {
721        let result = parse_args_from(args(&[
722            "-H",
723            "0.0.0.0",
724            "-p",
725            "8080",
726            "-k",
727            "secret",
728            "-l",
729            "debug",
730            "--no-rate-limit",
731        ]))
732        .unwrap();
733
734        assert_eq!(result.host.to_string(), "0.0.0.0");
735        assert_eq!(result.port, 8080);
736        assert_eq!(result.api_key, Some("secret".to_string()));
737        assert_eq!(result.log_level, Some("debug".to_string()));
738        assert!(result.no_rate_limit);
739        assert!(!result.no_auth);
740    }
741
742    #[test]
743    fn test_tunnel_flag() {
744        let result = parse_args_from(vec![
745            OsString::from("shell-tunnel"),
746            OsString::from("--tunnel"),
747        ])
748        .unwrap();
749        assert!(result.tunnel);
750        assert!(result.tunnel_command.is_none());
751    }
752
753    #[test]
754    fn test_tunnel_command_flag() {
755        let result = parse_args_from(vec![
756            OsString::from("shell-tunnel"),
757            OsString::from("--tunnel-command"),
758            OsString::from("ngrok http 3000"),
759        ])
760        .unwrap();
761        assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
762        assert!(!result.tunnel);
763    }
764
765    #[test]
766    fn test_tunnel_paths_are_mutually_exclusive() {
767        let err = parse_args_from(vec![
768            OsString::from("shell-tunnel"),
769            OsString::from("--tunnel"),
770            OsString::from("--tunnel-command"),
771            OsString::from("bore local 3000 --to bore.pub"),
772        ])
773        .unwrap_err();
774        let msg = err.to_string();
775        assert!(msg.contains("--tunnel"), "{msg}");
776        assert!(msg.contains("cannot be used together"), "{msg}");
777    }
778
779    #[test]
780    fn test_no_tunnel_by_default() {
781        let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
782        assert!(!result.tunnel);
783        assert!(result.tunnel_command.is_none());
784    }
785
786    /// The three inputs and the three answers, with `0` singled out.
787    ///
788    /// `0` meaning "never rotate" is the whole reason `audit_rotation_limit`
789    /// exists. Read as a plain size it would mean "rotate past zero bytes",
790    /// which rotates on every entry and keeps nothing — a trail that silently
791    /// retains one line is worse than one that is switched off, because it still
792    /// looks like a trail.
793    #[test]
794    fn the_audit_rotation_limit_maps_absent_zero_and_a_size_differently() {
795        let absent = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
796        assert_eq!(
797            absent.audit_rotation_limit(),
798            Some(crate::audit::DEFAULT_MAX_BYTES),
799            "an operator who said nothing gets the bounded default"
800        );
801
802        let zero = parse_args_from(vec![
803            OsString::from("shell-tunnel"),
804            OsString::from("--audit-max-bytes"),
805            OsString::from("0"),
806        ])
807        .unwrap();
808        assert_eq!(
809            zero.audit_rotation_limit(),
810            None,
811            "0 is the opt-out to the old unbounded behaviour, not a zero-byte limit"
812        );
813
814        let sized = parse_args_from(vec![
815            OsString::from("shell-tunnel"),
816            OsString::from("--audit-max-bytes"),
817            OsString::from("4096"),
818        ])
819        .unwrap();
820        assert_eq!(sized.audit_rotation_limit(), Some(4096));
821    }
822}