From 1476137e794e95f43d36a7531d59a082d640ca8f Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 15 Sep 2026 17:29:49 +0530 Subject: [PATCH 1/7] Show help for a bare ts dev proxy invocation Signed-off-by: dhruv8sh --- .../src/commands/dev/mod.rs | 2 +- .../src/commands/dev/proxy/config.rs | 11 ++-------- .../src/commands/dev/proxy/mod.rs | 1 + crates/trusted-server-cli/src/run.rs | 21 +++++++++++++++++++ 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/mod.rs b/crates/trusted-server-cli/src/commands/dev/mod.rs index 7a61d769b..822d99fd1 100644 --- a/crates/trusted-server-cli/src/commands/dev/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/mod.rs @@ -31,6 +31,6 @@ pub enum DevCommand { pub fn run(command: DevCommand) -> Result<(), String> { match command { #[cfg(target_os = "macos")] - DevCommand::Proxy(args) => proxy::run(&args).map_err(|report| format!("{report:?}")), + DevCommand::Proxy(args) => proxy::run(&args).map_err(|report| format!("{report:#}")), } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index d831735c2..9d0ef2380 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -359,14 +359,7 @@ mod tests { }; fn base_args() -> crate::commands::dev::proxy::ProxyArgs { - // Construct via clap so defaults match the real surface. - use clap::Parser; - #[derive(clap::Parser)] - struct W { - #[command(flatten)] - a: crate::commands::dev::proxy::ProxyArgs, - } - W::parse_from(["ts"]).a + parse_args(&["ts", "--listen", "127.0.0.1:18080"]) } fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { @@ -382,7 +375,7 @@ mod tests { #[test] fn clap_parses_rewrite_host_as_a_bool() { assert!( - !parse_args(&["ts"]).rewrite_host, + !parse_args(&["ts", "--listen", "127.0.0.1:18080"]).rewrite_host, "absent --rewrite-host is false" ); assert!( diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 1bebd872d..6b6d2b748 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -78,6 +78,7 @@ async fn finish_interrupted_run( /// `ts dev proxy [OPTIONS]` — see the design spec §4. #[derive(Debug, clap::Args)] +#[command(arg_required_else_help = true)] pub struct ProxyArgs { /// Rewrite rule `FROM=TO` (repeatable). #[arg(long = "map", value_name = "FROM=TO")] diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index 13009d448..e5079ebb8 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -678,4 +678,25 @@ mod tests { "error should explain unsupported option" ); } + + #[test] + #[cfg(target_os = "macos")] + fn dev_proxy_bare_invocation_shows_help_before_running() { + let error = Args::try_parse_from(["ts", "dev", "proxy"]) + .expect_err("a bare `ts dev proxy` should short-circuit to help, not run"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand, + "should print help instead of touching system proxy state or attempting sudo" + ); + parse(&["ts", "dev", "proxy", "ca", "path"]); + } + + #[test] + #[cfg(target_os = "macos")] + fn dev_proxy_partial_rule_parses_instead_of_showing_help() { + // An explicit but incomplete rule (`--from` with no `--to`) must reach + // `run` and surface the concise no-rule error there, not clap help. + parse(&["ts", "dev", "proxy", "--from", "a.example.com"]); + } } From ae1d7c6a3d98cf8e521fe6e2efb2acd42a378caa Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 15 Sep 2026 19:59:10 +0530 Subject: [PATCH 2/7] Address PR review: preserve CA error attachments, fix test process-exit trap, dedupe listen default Signed-off-by: dhruv8sh --- crates/trusted-server-cli/src/commands/dev/mod.rs | 2 +- .../src/commands/dev/proxy/config.rs | 10 +++++++--- .../trusted-server-cli/src/commands/dev/proxy/mod.rs | 6 +++++- crates/trusted-server-cli/src/run.rs | 9 ++++++++- 4 files changed, 21 insertions(+), 6 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/mod.rs b/crates/trusted-server-cli/src/commands/dev/mod.rs index 822d99fd1..7a61d769b 100644 --- a/crates/trusted-server-cli/src/commands/dev/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/mod.rs @@ -31,6 +31,6 @@ pub enum DevCommand { pub fn run(command: DevCommand) -> Result<(), String> { match command { #[cfg(target_os = "macos")] - DevCommand::Proxy(args) => proxy::run(&args).map_err(|report| format!("{report:#}")), + DevCommand::Proxy(args) => proxy::run(&args).map_err(|report| format!("{report:?}")), } } diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 9d0ef2380..0b536808f 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -359,7 +359,11 @@ mod tests { }; fn base_args() -> crate::commands::dev::proxy::ProxyArgs { - parse_args(&["ts", "--listen", "127.0.0.1:18080"]) + parse_args(&[ + "ts", + "--listen", + crate::commands::dev::proxy::DEFAULT_LISTEN, + ]) } fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { @@ -369,13 +373,13 @@ mod tests { #[command(flatten)] a: crate::commands::dev::proxy::ProxyArgs, } - W::parse_from(argv).a + W::try_parse_from(argv).expect("should parse proxy args").a } #[test] fn clap_parses_rewrite_host_as_a_bool() { assert!( - !parse_args(&["ts", "--listen", "127.0.0.1:18080"]).rewrite_host, + !parse_args(&["ts", "--insecure"]).rewrite_host, "absent --rewrite-host is false" ); assert!( diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 6b6d2b748..5cece9849 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -76,6 +76,10 @@ async fn finish_interrupted_run( let _ = tokio::time::timeout(std::time::Duration::from_secs(2), drain_manager).await; } +/// Default `--listen` address, shared with the `config` tests so they cannot +/// silently drift from the real default. +pub const DEFAULT_LISTEN: &str = "127.0.0.1:18080"; + /// `ts dev proxy [OPTIONS]` — see the design spec §4. #[derive(Debug, clap::Args)] #[command(arg_required_else_help = true)] @@ -95,7 +99,7 @@ pub struct ProxyArgs { pub to: Option, /// Proxy listen address. Non-loopback requires `--allow-non-loopback`. - #[arg(long, value_name = "ADDR", default_value = "127.0.0.1:18080")] + #[arg(long, value_name = "ADDR", default_value = DEFAULT_LISTEN)] pub listen: String, /// Permit binding a non-loopback `--listen` (disables blind tunnel/forward). diff --git a/crates/trusted-server-cli/src/run.rs b/crates/trusted-server-cli/src/run.rs index e5079ebb8..d6f7c8f3b 100644 --- a/crates/trusted-server-cli/src/run.rs +++ b/crates/trusted-server-cli/src/run.rs @@ -689,7 +689,14 @@ mod tests { clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand, "should print help instead of touching system proxy state or attempting sudo" ); - parse(&["ts", "dev", "proxy", "ca", "path"]); + } + + #[test] + #[cfg(target_os = "macos")] + fn dev_proxy_ca_subcommands_still_parse_under_arg_required_else_help() { + for action in ["path", "install", "uninstall", "regenerate"] { + parse(&["ts", "dev", "proxy", "ca", action]); + } } #[test] From 85ac1ff370431a7f8b61cf07c3ac62507d4089fb Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 15 Sep 2026 20:23:49 +0530 Subject: [PATCH 3/7] Move test-only clap import to module scope, harden second parse trap Signed-off-by: dhruv8sh --- crates/trusted-server-cli/src/commands/dev/proxy/config.rs | 2 +- crates/trusted-server-cli/tests/support/mod.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 0b536808f..42bae4023 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -349,6 +349,7 @@ fn resolve_basic_auth(args: &ProxyArgs) -> Result, ConfigError #[cfg(test)] mod tests { + use clap::Parser as _; use hyper::header::HeaderValue; use rustls::pki_types::ServerName; @@ -367,7 +368,6 @@ mod tests { } fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { - use clap::Parser; #[derive(clap::Parser)] struct W { #[command(flatten)] diff --git a/crates/trusted-server-cli/tests/support/mod.rs b/crates/trusted-server-cli/tests/support/mod.rs index 8e8e3e66f..6c464b015 100644 --- a/crates/trusted-server-cli/tests/support/mod.rs +++ b/crates/trusted-server-cli/tests/support/mod.rs @@ -206,7 +206,7 @@ fn resolve(argv: &[&str]) -> config::ResolvedConfig { #[command(flatten)] args: trusted_server_cli::commands::dev::proxy::ProxyArgs, } - let parsed = Wrapper::parse_from(argv); + let parsed = Wrapper::try_parse_from(argv).expect("should parse proxy args"); config::resolve(&parsed.args).expect("should resolve test config") } From 40b65c62c56207325d3285538c7657a0dfa8ccb3 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Wed, 16 Sep 2026 20:02:28 +0530 Subject: [PATCH 4/7] Narrow DEFAULT_LISTEN visibility and simplify rewrite_host test DEFAULT_LISTEN only has in-crate consumers, so pub(crate) is enough. Use the existing base_args() helper instead of an unrelated --insecure flag to satisfy arg_required_else_help in the rewrite_host test. Co-Authored-By: Claude Sonnet 5 Signed-off-by: dhruv8sh --- crates/trusted-server-cli/src/commands/dev/proxy/config.rs | 5 +---- crates/trusted-server-cli/src/commands/dev/proxy/mod.rs | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 42bae4023..6f4ada02d 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -378,10 +378,7 @@ mod tests { #[test] fn clap_parses_rewrite_host_as_a_bool() { - assert!( - !parse_args(&["ts", "--insecure"]).rewrite_host, - "absent --rewrite-host is false" - ); + assert!(!base_args().rewrite_host, "absent --rewrite-host is false"); assert!( parse_args(&["ts", "--rewrite-host"]).rewrite_host, "present --rewrite-host is true" diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 5cece9849..6ec9e497d 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -78,7 +78,7 @@ async fn finish_interrupted_run( /// Default `--listen` address, shared with the `config` tests so they cannot /// silently drift from the real default. -pub const DEFAULT_LISTEN: &str = "127.0.0.1:18080"; +pub(crate) const DEFAULT_LISTEN: &str = "127.0.0.1:18080"; /// `ts dev proxy [OPTIONS]` — see the design spec §4. #[derive(Debug, clap::Args)] From ffd5bf8ffa8d9e1170362f646f7f94637b368139 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Tue, 22 Sep 2026 09:46:25 +0530 Subject: [PATCH 5/7] Rework proxy CLI arg-parsing tests to cover the real clap defaults Removes base_args, the shared test helper that let every test skip the real --listen default; each test now passes its args explicitly through parse_args, so nothing can mask default_value drifting from DEFAULT_LISTEN. Splits the former no_rule_passed_is_a_no_rule_error into bare_invocation_is_rejected_at_parse_time (proves arg_required_else_help rejects a fully-bare ts before resolve runs) and a fixed no_rule_passed_is_a_no_rule_error that uses --insecure so it still exercises the NoRule path, restoring coverage the rewrite had silently dropped. Also fixes two invalid --rewrite-host true/false assertions (that flag takes no value) and corrects the dev-proxy guide, which still described the pre-fix bare-invocation error. Signed-off-by: dhruv8sh --- .../src/commands/dev/proxy/config.rs | 178 ++++++++++++------ docs/guide/ts-dev-proxy.md | 3 +- 2 files changed, 122 insertions(+), 59 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index 6f4ada02d..f097c8b71 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -359,14 +359,6 @@ mod tests { AddressPolicy, OriginKey, ReferenceIdentity, Transport, VerifyMode, }; - fn base_args() -> crate::commands::dev::proxy::ProxyArgs { - parse_args(&[ - "ts", - "--listen", - crate::commands::dev::proxy::DEFAULT_LISTEN, - ]) - } - fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { #[derive(clap::Parser)] struct W { @@ -378,18 +370,35 @@ mod tests { #[test] fn clap_parses_rewrite_host_as_a_bool() { - assert!(!base_args().rewrite_host, "absent --rewrite-host is false"); + assert!( + !parse_args(&["ts", "--from", "a.example.com", "--to", "b.example.com"]).rewrite_host, + "absent --rewrite-host is false" + ); assert!( parse_args(&["ts", "--rewrite-host"]).rewrite_host, "present --rewrite-host is true" ); } + #[test] + fn clap_applies_the_real_listen_default() { + let args = parse_args(&["ts", "--rewrite-host"]); + assert_eq!( + args.listen, + crate::commands::dev::proxy::DEFAULT_LISTEN, + "should apply the real clap --listen default" + ); + } + #[test] fn single_rule_from_to_keeps_from_host_by_default() { - let mut args = base_args(); - args.from = Some("www.example-publisher.com".into()); - args.to = Some("to.edgecompute.app".into()); + let args = parse_args(&[ + "ts", + "--from", + "www.example-publisher.com", + "--to", + "to.edgecompute.app", + ]); let cfg = resolve(&args).expect("should resolve"); let rule = cfg .rules @@ -405,9 +414,12 @@ mod tests { #[test] fn rewrite_host_uses_to() { - let mut args = base_args(); - args.map = vec!["www.example-publisher.com=to.edgecompute.app".into()]; - args.rewrite_host = true; + let args = parse_args(&[ + "ts", + "--map", + "www.example-publisher.com=to.edgecompute.app", + "--rewrite-host", + ]); let cfg = resolve(&args).expect("should resolve"); assert_eq!( rewrite_for( @@ -423,10 +435,13 @@ mod tests { #[test] fn resolve_pins_host_to_ip() { - let mut args = base_args(); - args.map = vec!["www.example-publisher.com=ts.edgecompute.app".into()]; - // Mixed case to confirm the host key is lowercased. - args.resolve = vec!["TS.EdgeCompute.app:192.0.2.10".into()]; + let args = parse_args(&[ + "ts", + "--map", + "www.example-publisher.com=ts.edgecompute.app", + "--resolve", + "TS.EdgeCompute.app:192.0.2.10", // Mixed case to confirm the host key is lowercased. + ]); let cfg = resolve(&args).expect("should resolve"); assert_eq!( cfg.resolve.get("ts.edgecompute.app"), @@ -437,10 +452,13 @@ mod tests { #[test] fn resolve_accepts_ipv6_target() { - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - // Split-on-first-colon must keep the colon-bearing IPv6 address intact. - args.resolve = vec!["b.edgecompute.app:::1".into()]; + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--resolve", + "b.edgecompute.app:::1", // Split-on-first-colon must keep the colon-bearing IPv6 address intact. + ]); let cfg = resolve(&args).expect("should resolve"); assert_eq!( cfg.resolve.get("b.edgecompute.app"), @@ -451,11 +469,15 @@ mod tests { #[test] fn resolve_host_not_matching_any_rule_warns_but_succeeds() { - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - // A pin for a host that is no rule's TO is a likely typo: it should warn - // (not error) and still be recorded. - args.resolve = vec!["typo.edgecompute.app:192.0.2.10".into()]; + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + // A pin for a host that is no rule's TO is a likely typo: it should warn + // (not error) and still be recorded. + "--resolve", + "typo.edgecompute.app:192.0.2.10", + ]); let cfg = resolve(&args).expect("an unmatched --resolve host should warn, not error"); assert!( cfg.resolve.contains_key("typo.edgecompute.app"), @@ -465,9 +487,13 @@ mod tests { #[test] fn resolve_rejects_malformed_value() { - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.resolve = vec!["b.edgecompute.app:not-an-ip".into()]; + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--resolve", + "b.edgecompute.app:not-an-ip", + ]); let err = resolve(&args).expect_err("a non-IP --resolve target should error"); assert!( matches!(err.current_context(), ConfigError::Resolve { .. }), @@ -477,8 +503,7 @@ mod tests { #[test] fn map_value_must_be_from_equals_to() { - let mut args = base_args(); - args.map = vec!["not-a-map".into()]; + let args = parse_args(&["ts", "--map", "not-a-map"]); assert!(resolve(&args).is_err(), "malformed --map errors"); } @@ -486,11 +511,16 @@ mod tests { fn basic_auth_on_non_loopback_listen_is_rejected() { // Injected Basic auth on a non-loopback bind would expose the upstream // credentials to any reachable network client. - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.listen = "0.0.0.0:18080".into(); - args.allow_non_loopback = true; - args.basic_auth = Some("dev:secret".into()); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--listen", + "0.0.0.0:18080", + "--allow-non-loopback", + "--basic-auth", + "dev:secret", + ]); let err = resolve(&args).expect_err("non-loopback listen with --basic-auth should be rejected"); assert!( @@ -502,7 +532,14 @@ mod tests { ); // The same non-loopback bind without credentials is allowed. - args.basic_auth = None; + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--listen", + "0.0.0.0:18080", + "--allow-non-loopback", + ]); assert!( resolve(&args).is_ok(), "non-loopback without --basic-auth is allowed" @@ -512,8 +549,7 @@ mod tests { #[test] fn invalid_from_host_is_rejected() { // A FROM with characters that would break the PAC JS / Host header. - let mut args = base_args(); - args.map = vec!["bad\"host=to.edgecompute.app".into()]; + let args = parse_args(&["ts", "--map", "bad\"host=to.edgecompute.app"]); let err = resolve(&args).expect_err("a malformed FROM host should error"); assert!( matches!(err.current_context(), ConfigError::InvalidFrom { .. }), @@ -523,14 +559,25 @@ mod tests { #[test] fn non_loopback_listen_requires_flag() { - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.listen = "0.0.0.0:18080".into(); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--listen", + "0.0.0.0:18080", + ]); assert!( resolve(&args).is_err(), "non-loopback without flag is rejected" ); - args.allow_non_loopback = true; + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--listen", + "0.0.0.0:18080", + "--allow-non-loopback", + ]); assert!(resolve(&args).is_ok(), "non-loopback allowed with flag"); } @@ -553,11 +600,15 @@ mod tests { #[test] fn resolve_precomputes_typed_rule_identity_and_headers() { - let mut args = base_args(); - args.map = vec!["www.example.com=TO.Example.com:8443".into()]; - args.rewrite_host = true; - args.insecure = true; - args.resolve = vec!["to.example.com:192.0.2.10".into()]; + let args = parse_args(&[ + "ts", + "--map", + "www.example.com=TO.Example.com:8443", + "--rewrite-host", + "--insecure", + "--resolve", + "to.example.com:192.0.2.10", + ]); let cfg = resolve(&args).expect("should resolve"); let rule = cfg @@ -596,10 +647,7 @@ mod tests { #[test] fn resolve_keeps_ip_reference_identities_http1_only() { - let mut args = base_args(); - args.map = vec!["www.example.com=127.0.0.1".into()]; - args.rewrite_host = true; - + let args = parse_args(&["ts", "--map", "www.example.com=127.0.0.1", "--rewrite-host"]); let cfg = resolve(&args).expect("should resolve"); let rule = cfg .rules @@ -649,9 +697,13 @@ mod tests { let dir = tempfile::tempdir().expect("should create temp dir"); let missing = dir.path().join("no-such-file.txt"); - let mut args = base_args(); - args.map = vec!["a.example.com=b.edgecompute.app".into()]; - args.basic_auth_file = Some(missing.to_string_lossy().into_owned()); + let args = parse_args(&[ + "ts", + "--map", + "a.example.com=b.edgecompute.app", + "--basic-auth-file", + &missing.to_string_lossy(), + ]); let err = resolve(&args).expect_err("should fail when file is missing"); assert!( @@ -660,9 +712,19 @@ mod tests { ); } + #[test] + #[should_panic(expected = "DisplayHelpOnMissingArgumentOrSubcommand")] + fn bare_invocation_is_rejected_at_parse_time() { + // `arg_required_else_help` makes a fully-bare `ts` fail to parse at all, + // before `resolve` (and its `NoRule` check) ever runs. + parse_args(&["ts"]); + } + #[test] fn no_rule_passed_is_a_no_rule_error() { - let args = base_args(); + // An invocation with some other flag but no rule still reaches + // `resolve`: `arg_required_else_help` only rejects a fully-bare `ts`. + let args = parse_args(&["ts", "--insecure"]); let err = resolve(&args).expect_err("should error when no rule is passed"); assert!( matches!(err.current_context(), ConfigError::NoRule), diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index b99603b9e..234385f3d 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -77,7 +77,8 @@ shorthand, or one or more `--map FROM=TO` rules: ts dev proxy -f www.example-publisher.com -t trusted-server-example.edgecompute.app ``` -With no `--map`/`-f`/`-t`, the proxy exits with +A bare `ts dev proxy` prints help and exits before proxy startup. An +invocation with explicit options but no complete rewrite rule reports `no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to)`. Connection options — `--rewrite-host`, `--basic-auth`/`--basic-auth-file`, From 8dd9713b3fcf30cba7b9615e6486b8a9c199e8a0 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 24 Sep 2026 14:01:15 +0530 Subject: [PATCH 6/7] Assert bare proxy invocation via clap's typed error kind Add a fallible try_parse_args test helper and check error.kind() against DisplayHelpOnMissingArgumentOrSubcommand instead of matching a should_panic substring from clap's private ErrorInner Debug output. Signed-off-by: dhruv8sh --- .../src/commands/dev/proxy/config.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs index f097c8b71..86ffc9d0f 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/config.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/config.rs @@ -360,12 +360,18 @@ mod tests { }; fn parse_args(argv: &[&str]) -> crate::commands::dev::proxy::ProxyArgs { + try_parse_args(argv).expect("should parse proxy args") + } + + fn try_parse_args( + argv: &[&str], + ) -> Result { #[derive(clap::Parser)] struct W { #[command(flatten)] a: crate::commands::dev::proxy::ProxyArgs, } - W::try_parse_from(argv).expect("should parse proxy args").a + W::try_parse_from(argv).map(|w| w.a) } #[test] @@ -713,11 +719,16 @@ mod tests { } #[test] - #[should_panic(expected = "DisplayHelpOnMissingArgumentOrSubcommand")] fn bare_invocation_is_rejected_at_parse_time() { // `arg_required_else_help` makes a fully-bare `ts` fail to parse at all, // before `resolve` (and its `NoRule` check) ever runs. - parse_args(&["ts"]); + let error = try_parse_args(&["ts"]) + .expect_err("a fully-bare invocation should short-circuit to help"); + assert_eq!( + error.kind(), + clap::error::ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand, + "should short-circuit to help rather than reaching resolve" + ); } #[test] From d100d1b3c87755d5af42ab106a904ebe565e2698 Mon Sep 17 00:00:00 2001 From: dhruv8sh Date: Thu, 24 Sep 2026 14:01:15 +0530 Subject: [PATCH 7/7] Resolve proxy rules before restoring pending system proxy state An invocation with flags but no usable rewrite rule (for example ts dev proxy --insecure) previously ran the Safari system-proxy restore, and could attempt sudo, before failing on the missing rule. Resolve the config first so it fails without touching system proxy state; a proxy stranded by a hard-killed run is still restored on the next valid run. Add a regression test using a malformed restore file, which the restore path deletes without running networksetup, and soften the guide so it no longer promises the concise one-line error output. Signed-off-by: dhruv8sh --- .../src/commands/dev/proxy/browser.rs | 2 +- .../src/commands/dev/proxy/mod.rs | 50 ++++++++++++++++--- docs/guide/ts-dev-proxy.md | 4 +- 3 files changed, 47 insertions(+), 9 deletions(-) diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs index 4af9b2d3d..e61f92a01 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/browser.rs @@ -18,7 +18,7 @@ use crate::output; /// is `on` or `off`. A missing third line is tolerated when reading (treated as /// `on` if a URL is present, else `off`) for forward-compatibility with the /// earlier two-line format. -const SAFARI_RESTORE_FILE: &str = "safari-proxy-restore"; +pub(super) const SAFARI_RESTORE_FILE: &str = "safari-proxy-restore"; /// Generates a PAC script that proxies only `https://` requests for matched FROM hosts. /// diff --git a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs index 6ec9e497d..8bb28118a 100644 --- a/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs +++ b/crates/trusted-server-cli/src/commands/dev/proxy/mod.rs @@ -252,14 +252,19 @@ pub fn run(args: &ProxyArgs) -> core::result::Result<(), error_stack::Report::try_parse_from(["ts", "--insecure", "--ca-dir", &ca_dir]) + .expect("should parse proxy args") + .a; + + let err = run(&args).expect_err("should fail without a rewrite rule"); + + assert!( + matches!(err.current_context(), ProxyError::Config), + "should fail with a config error" + ); + assert!( + restore_path.exists(), + "should fail before attempting to restore the system proxy" + ); + } } diff --git a/docs/guide/ts-dev-proxy.md b/docs/guide/ts-dev-proxy.md index 234385f3d..789393e92 100644 --- a/docs/guide/ts-dev-proxy.md +++ b/docs/guide/ts-dev-proxy.md @@ -78,8 +78,8 @@ ts dev proxy -f www.example-publisher.com -t trusted-server-example.edgecompute. ``` A bare `ts dev proxy` prints help and exits before proxy startup. An -invocation with explicit options but no complete rewrite rule reports -`no rewrite rule: pass --map FROM=TO (or -f/--from with -t/--to)`. +invocation with explicit options but no complete rewrite rule fails with a +`no rewrite rule` error before touching system proxy state. Connection options — `--rewrite-host`, `--basic-auth`/`--basic-auth-file`, `--insecure`, and `--upstream-plaintext` — apply to every mapping, not per-rule.