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
412        --tunnel            Expose publicly via a Cloudflare quick tunnel
413                            (requires `cloudflared`; implies authentication)
414        --tunnel-command <C>
415                            Expose publicly by running an arbitrary tunnel
416                            command (ngrok, bore, frp, ...); its printed URL
417                            is used. Implies authentication
418        --relay <URL>       Attach to a self-hosted relay (dial out, no inbound
419                            port). Needs the relay's --enroll-token; implies
420                            authentication. The local port is chosen for you
421                            unless -p says otherwise
422        --device-name <N>   Claim a stable name on the relay, so the device URL
423                            survives reconnects [default: this machine's name]
424        --relay-fingerprint <FP>
425                            Expect exactly this certificate from the relay, as
426                            printed by `shell-tunnel relay --tls-self-signed`.
427                            Nothing to copy but the string, and the certificate
428                            need not name the address being dialled
429        --relay-ca <FILE>   Also trust this PEM authority when dialling a relay
430                            (the alternative to a fingerprint, for a private CA)
431        --allow-host <HOST> Also answer to this host name. A loopback-bound
432                            server that is not published otherwise answers only
433                            to localhost, which is what stops DNS rebinding.
434                            Published, nothing is host-checked. Repeatable
435        --audit-log <FILE>  Append executions, denied requests, and file
436                            operations to this file (JSON per line; the token
437                            itself is never written)
438                            [default: off; shell-tunnel-audit.jsonl when reachable]
439        --audit-max-bytes <N>
440                            Rotate the audit trail to <FILE>.1 past this size,
441                            keeping one generation. 0 never rotates
442                            [default: 67108864 (64 MiB)]
443        --kill-orphans      Kill anything a command leaves running when the
444                            command ends. Off by default, because a daemon
445                            started on purpose is meant to outlive the request
446        --cors-allow-any    Allow any CORS origin (opt-in; for browser UIs)
447        --fs-root <PATH>    Confine the file API to this directory. Without it
448                            the API reaches everything this account can
449        --fs-chunk-size <N> Upload chunk size advertised to callers, in bytes.
450                            Default 4194304; 262144 when --relay is given,
451                            because a relayed chunk must also finish inside the
452                            relay's 120s request deadline
453
454TLS OPTIONS (with `relay`):
455        --tls-self-signed   Serve HTTPS with a self-signed certificate,
456                            generating one on first run and reusing it after.
457                            Needs no paths; devices trust it with the
458                            --relay-fingerprint the banner prints. Its names are
459                            fixed when it is generated, so adding --public-base
460                            later does not add that name — the banner says which
461                            names it actually covers
462        --tls-cert <FILE>   PEM certificate chain [default with --tls-self-signed:
463                            shell-tunnel-cert.pem]
464        --tls-key <FILE>    PEM private key matching the certificate
465
466                            A gateway does not serve HTTPS and refuses these
467                            flags at startup: reach it through a tunnel or a
468                            relay, which carry their own TLS, or put a reverse
469                            proxy in front. Its own socket is plaintext.
470                            With a proxy, pass --require-auth: the proxy does
471                            not change the bind address, so a loopback-bound
472                            gateway still counts itself local and leaves
473                            authentication off while the proxy publishes it.
474                            Forget it and the server warns once, on the first
475                            request carrying a proxy header — but a proxy that
476                            forwards none of them leaves nothing to warn about
477
478RELAY OPTIONS (with `relay`):
479        --enroll-token <T>  Secret devices present to attach to this relay
480                            (generated if unset). Distinct from --api-key, which
481                            is what callers present to a device
482        --public-base <URL> Public base URL of this relay. A URL with no port
483                            uses this relay's listen port; name a port only when
484                            a proxy remaps it [default: http://<bind address>]
485
486OTHER OPTIONS:
487{update_opts}    -h, --help              Print help
488    -V, --version           Print version
489
490ENVIRONMENT VARIABLES:
491    SHELL_TUNNEL_HOST       Bind address, unless -H names one
492    SHELL_TUNNEL_PORT       Port, unless -p names one
493    SHELL_TUNNEL_API_KEY    Adds an API key and turns auth on. Keys from the
494                            config file stay valid alongside it
495    SHELL_TUNNEL_LOG_LEVEL  Log level (overrides config)
496    RUST_LOG                Alternative log level setting
497
498EXAMPLES:
499    # Start with defaults (localhost:3000, no auth)
500    shell-tunnel
501
502    # Start on all interfaces with API key
503    shell-tunnel -H 0.0.0.0 -p 8080 -k my-secret-key
504
505    # Start with config file
506    shell-tunnel -c /etc/shell-tunnel/config.json
507
508    # Development mode (no security)
509    shell-tunnel --no-auth --no-rate-limit
510
511    # Publish on the internet with a generated key (no account needed)
512    shell-tunnel --tunnel
513
514    # Publish using a different tunnel client
515    shell-tunnel --tunnel-command "ngrok http 3000"
516
517    # Attach to a relay under a stable name
518    shell-tunnel --relay https://relay.example.com --enroll-token <t> --device-name box
519
520    # Run a relay with HTTPS, generating a certificate on first run.
521    # --public-base names the host; the URL uses this relay's port (8443).
522    shell-tunnel relay -H 0.0.0.0 -p 8443 --tls-self-signed --public-base https://relay.example.com
523
524    # Behind a proxy that forwards 443 here, name the port devices dial
525    shell-tunnel relay -H 0.0.0.0 -p 8443 --public-base https://relay.example.com:443
526
527    # Issue a token that can only read files, confined to one directory
528    shell-tunnel -k readonly-key --preset file-read --fs-root /srv/deploy
529
530    # Issue a token scoped to specific capabilities
531    shell-tunnel -k ci-key --capabilities exec,session.read
532{update_examples}"#
533    );
534}
535
536/// Print version.
537pub fn print_version() {
538    println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
539}
540
541/// Argument parsing errors.
542#[derive(Debug)]
543pub enum ArgsError {
544    /// Lexopt parsing error.
545    Lexopt(lexopt::Error),
546    /// Invalid argument value.
547    InvalidValue(&'static str, String),
548    /// Unexpected positional argument.
549    UnexpectedArgument(String),
550    /// Two mutually exclusive flags were given.
551    Conflicting(&'static str, &'static str),
552}
553
554impl std::fmt::Display for ArgsError {
555    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
556        match self {
557            Self::Lexopt(e) => write!(f, "{}", e),
558            Self::InvalidValue(name, value) => {
559                write!(f, "invalid value for --{}: '{}'", name, value)
560            }
561            Self::UnexpectedArgument(arg) => {
562                write!(f, "unexpected argument: '{}'", arg)
563            }
564            Self::Conflicting(a, b) if a.starts_with("--tls") => {
565                write!(f, "{} and {} must be given together", a, b)
566            }
567            Self::Conflicting(a, b) => {
568                write!(f, "{} and {} cannot be used together", a, b)
569            }
570        }
571    }
572}
573
574impl std::error::Error for ArgsError {}
575
576impl From<lexopt::Error> for ArgsError {
577    fn from(e: lexopt::Error) -> Self {
578        Self::Lexopt(e)
579    }
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    fn args(args: &[&str]) -> Vec<OsString> {
587        std::iter::once("shell-tunnel")
588            .chain(args.iter().copied())
589            .map(OsString::from)
590            .collect()
591    }
592
593    #[test]
594    fn test_default_args() {
595        let result = parse_args_from(args(&[])).unwrap();
596        assert_eq!(result.host.to_string(), "127.0.0.1");
597        assert_eq!(result.port, 3000);
598        assert!(!result.no_auth);
599    }
600
601    #[test]
602    fn test_host_port() {
603        let result = parse_args_from(args(&["-H", "0.0.0.0", "-p", "8080"])).unwrap();
604        assert_eq!(result.host.to_string(), "0.0.0.0");
605        assert_eq!(result.port, 8080);
606    }
607
608    #[test]
609    fn test_long_options() {
610        let result = parse_args_from(args(&["--host", "192.168.1.1", "--port", "9000"])).unwrap();
611        assert_eq!(result.host.to_string(), "192.168.1.1");
612        assert_eq!(result.port, 9000);
613    }
614
615    #[test]
616    fn test_api_key() {
617        let result = parse_args_from(args(&["-k", "my-secret"])).unwrap();
618        assert_eq!(result.api_key, Some("my-secret".to_string()));
619    }
620
621    #[test]
622    fn test_config_file() {
623        let result = parse_args_from(args(&["-c", "/etc/config.json"])).unwrap();
624        assert_eq!(result.config, Some(PathBuf::from("/etc/config.json")));
625    }
626
627    #[test]
628    fn test_no_auth() {
629        let result = parse_args_from(args(&["--no-auth"])).unwrap();
630        assert!(result.no_auth);
631    }
632
633    #[test]
634    fn test_require_auth() {
635        let result = parse_args_from(args(&["--require-auth"])).unwrap();
636        assert!(result.require_auth);
637        assert!(!Args::default().require_auth);
638    }
639
640    #[test]
641    fn test_no_rate_limit() {
642        let result = parse_args_from(args(&["--no-rate-limit"])).unwrap();
643        assert!(result.no_rate_limit);
644    }
645
646    #[test]
647    fn test_capabilities_csv() {
648        let result = parse_args_from(args(&["--capabilities", "exec,session.read"])).unwrap();
649        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
650        assert!(Args::default().capabilities.is_empty());
651    }
652
653    #[test]
654    fn test_capabilities_trims_and_ignores_blanks() {
655        let result = parse_args_from(args(&["--capabilities", " exec , , session.read "])).unwrap();
656        assert_eq!(result.capabilities, vec!["exec", "session.read"]);
657    }
658
659    #[test]
660    fn test_capabilities_repeated_accumulate() {
661        let result = parse_args_from(args(&[
662            "--capabilities",
663            "exec",
664            "--capabilities",
665            "session.read,session.manage",
666        ]))
667        .unwrap();
668        assert_eq!(
669            result.capabilities,
670            vec!["exec", "session.read", "session.manage"]
671        );
672    }
673
674    #[test]
675    fn test_preset() {
676        let result = parse_args_from(args(&["--preset", "operator"])).unwrap();
677        assert_eq!(result.preset, Some("operator".to_string()));
678        assert!(Args::default().preset.is_none());
679    }
680
681    #[test]
682    fn test_help_flag() {
683        let result = parse_args_from(args(&["-h"])).unwrap();
684        assert!(result.help);
685
686        let result = parse_args_from(args(&["--help"])).unwrap();
687        assert!(result.help);
688    }
689
690    #[test]
691    fn test_version_flag() {
692        let result = parse_args_from(args(&["-V"])).unwrap();
693        assert!(result.version);
694
695        let result = parse_args_from(args(&["--version"])).unwrap();
696        assert!(result.version);
697    }
698
699    #[test]
700    fn test_log_level() {
701        let result = parse_args_from(args(&["-l", "debug"])).unwrap();
702        assert_eq!(result.log_level, Some("debug".to_string()));
703    }
704
705    #[test]
706    fn test_invalid_port() {
707        let result = parse_args_from(args(&["-p", "invalid"]));
708        assert!(result.is_err());
709    }
710
711    #[test]
712    fn test_invalid_host() {
713        let result = parse_args_from(args(&["-H", "not-an-ip"]));
714        assert!(result.is_err());
715    }
716
717    #[test]
718    fn test_combined_options() {
719        let result = parse_args_from(args(&[
720            "-H",
721            "0.0.0.0",
722            "-p",
723            "8080",
724            "-k",
725            "secret",
726            "-l",
727            "debug",
728            "--no-rate-limit",
729        ]))
730        .unwrap();
731
732        assert_eq!(result.host.to_string(), "0.0.0.0");
733        assert_eq!(result.port, 8080);
734        assert_eq!(result.api_key, Some("secret".to_string()));
735        assert_eq!(result.log_level, Some("debug".to_string()));
736        assert!(result.no_rate_limit);
737        assert!(!result.no_auth);
738    }
739
740    #[test]
741    fn test_tunnel_flag() {
742        let result = parse_args_from(vec![
743            OsString::from("shell-tunnel"),
744            OsString::from("--tunnel"),
745        ])
746        .unwrap();
747        assert!(result.tunnel);
748        assert!(result.tunnel_command.is_none());
749    }
750
751    #[test]
752    fn test_tunnel_command_flag() {
753        let result = parse_args_from(vec![
754            OsString::from("shell-tunnel"),
755            OsString::from("--tunnel-command"),
756            OsString::from("ngrok http 3000"),
757        ])
758        .unwrap();
759        assert_eq!(result.tunnel_command.as_deref(), Some("ngrok http 3000"));
760        assert!(!result.tunnel);
761    }
762
763    #[test]
764    fn test_tunnel_paths_are_mutually_exclusive() {
765        let err = parse_args_from(vec![
766            OsString::from("shell-tunnel"),
767            OsString::from("--tunnel"),
768            OsString::from("--tunnel-command"),
769            OsString::from("bore local 3000 --to bore.pub"),
770        ])
771        .unwrap_err();
772        let msg = err.to_string();
773        assert!(msg.contains("--tunnel"), "{msg}");
774        assert!(msg.contains("cannot be used together"), "{msg}");
775    }
776
777    #[test]
778    fn test_no_tunnel_by_default() {
779        let result = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
780        assert!(!result.tunnel);
781        assert!(result.tunnel_command.is_none());
782    }
783
784    /// The three inputs and the three answers, with `0` singled out.
785    ///
786    /// `0` meaning "never rotate" is the whole reason `audit_rotation_limit`
787    /// exists. Read as a plain size it would mean "rotate past zero bytes",
788    /// which rotates on every entry and keeps nothing — a trail that silently
789    /// retains one line is worse than one that is switched off, because it still
790    /// looks like a trail.
791    #[test]
792    fn the_audit_rotation_limit_maps_absent_zero_and_a_size_differently() {
793        let absent = parse_args_from(vec![OsString::from("shell-tunnel")]).unwrap();
794        assert_eq!(
795            absent.audit_rotation_limit(),
796            Some(crate::audit::DEFAULT_MAX_BYTES),
797            "an operator who said nothing gets the bounded default"
798        );
799
800        let zero = parse_args_from(vec![
801            OsString::from("shell-tunnel"),
802            OsString::from("--audit-max-bytes"),
803            OsString::from("0"),
804        ])
805        .unwrap();
806        assert_eq!(
807            zero.audit_rotation_limit(),
808            None,
809            "0 is the opt-out to the old unbounded behaviour, not a zero-byte limit"
810        );
811
812        let sized = parse_args_from(vec![
813            OsString::from("shell-tunnel"),
814            OsString::from("--audit-max-bytes"),
815            OsString::from("4096"),
816        ])
817        .unwrap();
818        assert_eq!(sized.audit_rotation_limit(), Some(4096));
819    }
820}