1use std::ffi::OsString;
6use std::net::IpAddr;
7use std::path::PathBuf;
8
9#[derive(Debug, Clone)]
11pub struct Args {
12 pub host: IpAddr,
14 pub host_explicit: bool,
22 pub port: u16,
24 pub port_explicit: bool,
31 pub config: Option<PathBuf>,
33 pub api_key: Option<String>,
35 pub no_auth: bool,
37 pub require_auth: bool,
39 pub capabilities: Vec<String>,
41 pub preset: Option<String>,
43 pub no_rate_limit: bool,
45 pub tunnel: bool,
47 pub tunnel_command: Option<String>,
49 pub relay: bool,
51 pub relay_url: Option<String>,
53 pub enroll_token: Option<String>,
55 pub public_base: Option<String>,
57 pub device_name: Option<String>,
59 pub tls_cert: Option<PathBuf>,
61 pub tls_key: Option<PathBuf>,
63 pub tls_self_signed: bool,
65 pub relay_fingerprint: Option<String>,
67 pub relay_ca: Option<PathBuf>,
69 pub allow_hosts: Vec<String>,
71 pub audit_log: Option<PathBuf>,
73 pub fs_root: Option<PathBuf>,
75 pub fs_chunk_size: Option<usize>,
77 pub audit_max_bytes_raw: Option<u64>,
84 pub kill_orphans: bool,
90 pub cors_allow_any: bool,
92 pub log_level: Option<String>,
94 pub version: bool,
96 pub help: bool,
98 pub check_update: bool,
100 pub update: bool,
102 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 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
174pub fn parse_args() -> Result<Args, ArgsError> {
176 parse_args_from(std::env::args_os())
177}
178
179pub 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 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 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 if result.tls_cert.is_some() != result.tls_key.is_some() {
336 return Err(ArgsError::Conflicting("--tls-cert", "--tls-key"));
337 }
338
339 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 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 if result.tunnel && result.tunnel_command.is_some() {
364 return Err(ArgsError::Conflicting("--tunnel", "--tunnel-command"));
365 }
366
367 Ok(result)
368}
369
370pub fn print_help() {
372 let version = env!("CARGO_PKG_VERSION");
373
374 #[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
538pub fn print_version() {
540 println!("shell-tunnel {}", env!("CARGO_PKG_VERSION"));
541}
542
543#[derive(Debug)]
545pub enum ArgsError {
546 Lexopt(lexopt::Error),
548 InvalidValue(&'static str, String),
550 UnexpectedArgument(String),
552 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 #[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}