Skip to content

Support Linux in ts dev proxy - #1171

Open
ChristianPavilonis wants to merge 2 commits into
mainfrom
feature/dev-proxy-linux
Open

ChristianPavilonis wants to merge 2 commits into
mainfrom
feature/dev-proxy-linux

Conversation

@ChristianPavilonis

Copy link
Copy Markdown
Collaborator

Summary

  • Make ts dev proxy available on Linux. It was previously excluded at compile time even though the proxy engine can run natively on Linux.
  • Launch native Chrome/Chromium and Firefox, and manage the development certificate authority through the user's NSS certificate database on Linux. Certificate query or removal failures stop rotation before the old key is changed.
  • Add Linux CI coverage, regression tests, an isolated browser check, and setup documentation while retaining macOS support.

Scope

This is a CLI-only platform extension, not a proxy-engine rewrite. The changes span command registration, browser launch, certificate trust, tests, CI, and documentation because removing the compile gates alone would leave Linux browsers without working trust management.

Most of the new code and tests cover safe certificate installation and removal. A reproduced NSS behavior required an extra check: importing a different certificate with the same subject can make the original certificate's nickname export ambiguous. Installation now rejects that conflict before import, and shared-database locks serialize ts trust changes.

Supported automation is limited to native Chrome/Chromium and Firefox. Safari stays macOS-only. Windows, Snap/Flatpak automation, root operations, and desktop-wide proxy or certificate-store changes are excluded. Known packaging paths are skipped, but arbitrary shell wrappers are not classified.

Changes

CLI paths below are relative to crates/trusted-server-cli/; other paths are repository-relative.

File Change
.github/workflows/test.yml Run native CLI tests and Clippy on both Ubuntu and macOS.
CLI Cargo.toml Enable Linux proxy dependencies and scope the existing certificate parser to Linux trust checks.
CLI src/lib.rs Enable CLI output support on Linux.
CLI src/run.rs Update developer-command help to include Linux.
CLI src/commands/dev/mod.rs Register and dispatch the proxy on Linux.
CLI src/commands/dev/proxy/mod.rs Wire typed trust operations and locking into CA commands and initial loading.
CLI src/commands/dev/proxy/browser.rs Discover Linux browser launchers, retain temporary-profile settings, and skip Firefox launch if certificate import fails.
CLI src/commands/dev/proxy/config.rs Make browser selection platform-aware and reject Safari on Linux.
CLI src/commands/dev/proxy/trust.rs Manage recorded certificate destinations, identity checks, locks, installation, removal, and macOS keychain errors.
CLI tests/proxy_cli.rs Prove the Linux proxy command is available.
CLI tests/proxy_e2e.rs Enable the existing proxy end-to-end tests on Linux.
CLI tests/proxy_perf.rs Make the existing opt-in performance workloads available on Linux.
CLI tests/proxy_trust_linux.rs Cover failed trust operations, retries, shared stores, unrelated certificates, and same-subject conflicts.
CLI tests/proxy_trust_macos.rs Test keychain-query failures using a fake command, without touching a real keychain.
scripts/test-linux-dev-proxy-browser.py Provide a repeatable Chrome/Chromium trust and revocation check using disposable directories.
docs/guide/ts-dev-proxy.md Document Linux setup, trust scope, browser restrictions, and certificate rotation.
docs/superpowers/plans/linux-dev-proxy.md Record the approved scope, implementation decisions, and verification evidence.

Closes

Closes #1170

Test plan

  • Linux command regression failed before the change and passed afterward.
  • Same-subject certificate regression failed before the fix and passed afterward.
  • ./scripts/test-cli.sh: 182 unit tests and 42 integration/configuration tests passed.
  • cargo test_cli_linux --test proxy_trust_linux -- --include-ignored: all 11 trust tests passed, including real NSS checks.
  • cargo clippy --package trusted-server-cli --target x86_64-unknown-linux-gnu --all-targets -- -D warnings
  • cargo fmt --all -- --check
  • Docs format: cd docs && npm run format, with the existing JS dependency directory added to PATH because docs dependencies were not installed separately.
  • Chrome 153 and Chromium 151 passed XDG, default, and legacy certificate-database layouts. Each check proved missing trust fails, installed trust loads, revoked trust fails after restart, and HTTP bypasses a stopped proxy.
  • Independent correctness review completed with no remaining blockers under the native-install-only scope.
  • macOS CI and real keychain execution: not run locally; the CI matrix includes macOS.
  • Native Firefox runtime smoke: Firefox was not installed on the test host; launch arguments and preferences have unit coverage.

All real certificate/browser tests used disposable HOME, XDG, CA, profile, and NSS directories. They did not modify the developer's real trust stores or disable TLS verification or the browser sandbox. Adapter, JavaScript, and WASM suites were not rerun because those implementations are unchanged. Manual performance workloads remain opt-in.

Checklist

  • Changes follow project conventions and remain within the CLI platform boundary.
  • No new unwrap() in production code.
  • Existing CLI output and error-reporting conventions are preserved.
  • New behavior has regression tests.
  • No secrets or credentials committed.

@prk-Jr prk-Jr left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Extends ts dev proxy from macOS-only to macOS + Linux: dependency and cfg gates widened, Chrome/Chromium and Firefox launcher discovery added, and a new trust.rs that manages the dev CA through the user's NSS database. The CA-trust work is the substantial part, and it is careful — imports are journalled before NSS is touched, full DER equality authorizes every mutation, and two lock layers serialize CA commands and shared-store writes.

No blocking findings. The comments below are design observations, one seedling, and documentation drift; none of them gate the merge.

All comments are prose — nothing is offered as a one-click suggestion, because the two documentation lines worth changing fall outside the diff hunks, the script fix is a file-mode change, and the remainder are judgement calls that should stay the author's.

Non-blocking

🤔 thinking

  • Whole-store NSS export scan hard-fails on any single bad entry — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:283
  • Every try_lock failure reports as "another operation is running" — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:59
  • A rejected install still creates a new NSS store — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:332

🌱 seedling

  • Persisted nickname derives from DefaultHasher — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:339

⛏ nitpick

  • ConfigError::Browser omits safari on macOS — see inline at crates/trusted-server-cli/src/commands/dev/proxy/config.rs:50
  • Firefox trust flags drifted from the documented manual command — below
  • The guide's --help transcript is now wrong on Linux — below
  • New script is not executable — below

👍 praise

  • Trust-mutation design and its failure-path assertions — see inline at crates/trusted-server-cli/tests/proxy_trust_linux.rs:76

Cross-cutting / body-level findings

  • Firefox trust flags drifted from the documented manual commandimport_firefox (trust.rs:94) narrowed the NSS trust flags from CT,, (the previous browser.rs code) to C,,. Fine on the merits: server-authentication CA trust is all a proxy needs, and dropping the client-auth bit is tighter. But the manual import command in docs/guide/ts-dev-proxy.md:200 still says -t "CT,,", so a developer following the documented path now grants different trust bits than --launch firefox does. Either align the doc block to C,, or note why they differ. (Body-level: line 200 is outside the diff hunks, so it can't carry an inline comment.)

  • The guide's --help transcript is now wrong on Linuxdocs/guide/ts-dev-proxy.md:347 advertises --launch <LIST> Browsers to launch (chrome,firefox,safari or all), but safari is rejected on Linux by the new cfg in Browser::parse_list. Line 355's --ca-dir default reads ~/Library/Application Support/trusted-server/dev-proxy on macOS with no Linux equivalent, while the prose added above it documents $XDG_DATA_HOME/trusted-server/dev-proxy. Separately, and pre-existing on main: neither line matches clap's real output — the actual --launch help string is "Browsers to launch + configure (comma list or all)", and ca_dir is an Option<String> with no default_value, so clap prints no default at all. The transcript is hand-maintained and already drifting; this PR is a reasonable moment to regenerate or trim it.

  • scripts/test-linux-dev-proxy-browser.py is mode 644 — every other file in scripts/ is 755. It carries a #!/usr/bin/env python3 shebang but cannot be executed directly. Nothing breaks, since the guide invokes it as python3 scripts/..., but git update-index --chmod=+x would make it consistent with test-cli.sh and friends.

CI Status

  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo fmt: PASS (required)
  • cargo test: PASS (required)
  • format-docs: PASS (required)
  • format-typescript: PASS (required)
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • vitest: PASS
  • prepare integration artifacts: PASS
  • CLAUDE.md symlink guard: PASS
  • Analyze (actions): PASS
  • Analyze (javascript-typescript): PASS
  • CodeQL: SKIPPED
  • Analyze (rust): PENDING
  • integration tests: PENDING
  • integration tests (Fastly EC lifecycle): PENDING
  • browser integration tests: PENDING

No failed checks. Four checks were still in progress at review time; the Linux and macOS CLI jobs this PR actually exercises have both passed.


/// NSS named exports include every certificate with the same subject.
/// Reject a different certificate with that subject before importing anything.
fn check_subject_conflicts(entry: &Destination) -> Result<()> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinkingca install scans and exports every nickname in the shared user NSS database, and hard-fails on any one of them.

check_subject_conflicts walks all nicknames from nicknames() and runs certutil -L -n <nick> -a per entry. Any failure — a duplicate nickname, an orphaned entry, a cert whose named export isn't parseable PEM — propagates as TrustError::Command / TrustError::State and aborts the install.

On the fixture databases in tests/proxy_trust_linux.rs that is fine. On a real developer's ~/.pki/nssdb (corporate roots, leftovers from mkcert or earlier nss-tools experiments) a single bad entry makes ca install permanently unusable, and there is no documented recovery path other than editing the NSS store by hand — which docs/guide/ts-dev-proxy.md tells the user not to do.

Fail-closed is right for an identity check, but a per-nickname export failure isn't an identity conflict. Two options that keep the guarantee:

  • Only hard-fail when a successfully-parsed certificate has a matching subject and different bytes; treat an unparseable/failed export as "not a conflict for this subject" and warn.
  • Keep the strict behaviour and document the escape hatch (which nickname failed, and what to do about it) — the checked() attach already carries the certutil stderr, so the diagnostic exists, it just isn't actionable in the guide.

.mode(0o600)
.open(path)
.change_context(TrustError::Io)?;
file.try_lock().change_context(TrustError::Busy)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — every try_lock failure is reported as "another operation is running".

TryLockError covers WouldBlock and genuine I/O failures — ENOLCK, or a filesystem that doesn't support advisory locks. NFS-mounted home directories are a live case on Linux, which is the platform this PR is adding.

Both map to TrustError::Busy, so the user is told another CA or NSS trust operation is running; retry after it exits and retries forever against a condition that will never clear. Splitting the two keeps the diagnostic honest:

match file.try_lock() {
    Ok(()) => {}
    Err(std::fs::TryLockError::WouldBlock) => return Err(Report::new(TrustError::Busy)),
    Err(err) => return Err(Report::new(err).change_context(TrustError::Io)),
}

Apply manually — worth confirming the exact TryLockError variant names against the pinned 1.95 toolchain before committing to this shape.


fn install_into(ca_dir: &Path, cert_path: &Path, database: &Path) -> Result<()> {
let mut entries = read_record(ca_dir)?;
fs::create_dir_all(database).change_context(TrustError::Io)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — a rejected install still leaves a freshly created NSS store behind.

install_into runs fs::create_dir_all(database) here and initialize() (which runs certutil -N) on the next line, both before the contains() / check_subject_conflicts() preflight further down. So an install rejected for a same-subject conflict creates ~/.local/share/pki/nssdb with a new cert9.db / key4.db that did not exist beforehand.

It's benign — browsers create that store themselves — but it's a side effect of a command that otherwise advertises "rejected before mutation", and real_nss_same_subject_install_conflict_is_rejected_before_mutation doesn't cover it because the database already exists in that fixture. If the ordering can't move (the preflight needs a queryable database), it may be worth saying so in a comment here.

initialize(&database)?;
let certificate = certificate(cert_path)?;
// The hash only names the entry. Full DER equality authorizes all mutations.
let mut hash = DefaultHasher::new();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🌱 seedling — the persisted nickname is derived from DefaultHasher, whose output isn't stable across Rust releases.

The resulting ts-dev-proxy-{:016x} string is written to managed-nss-trust.json and into the NSS database. Today that's safe, and the comment right above says why: the entry lookup keys on (database, certificate) and uninstall replays the recorded nickname, so cross-version stability is never required on the happy path.

The one case it bites is recovery: journal lost, or a different --ca-dir used, while the NSS import survives. The orphan can then only be re-derived under the exact toolchain that wrote it, so ca uninstall can never reach it. A stable digest over the DER — SHA-256 is already reachable through the rustls/ring stack this crate pulls in — would make that recoverable.

Not for this PR; the 16-hex-digit shape read_record validates would be unchanged either way.

/// An unknown browser name was passed to `--launch`.
#[display("unknown browser `{value}` (expected chrome|firefox|safari|all)")]
/// An unknown or unsupported browser was passed to `--launch`.
#[display("unsupported browser `{value}` (use chrome|firefox|all; safari is macOS-only)")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — the message drops safari from the accepted list on macOS, where it is accepted.

A macOS user who typos --launch chrom is told to "use chrome|firefox|all", which reads as though safari isn't an option on the platform they're actually on. The trailing clause is doing double duty as both a restriction and an implicit list member.

Something that reads correctly on both targets without needing a cfg:

#[display("unsupported browser `{value}` (use chrome|firefox|all, plus safari on macOS)")]

Apply manually — left as prose rather than a one-click suggestion so the wording stays yours.

bin
}

fn assert_rotation_fails_unchanged(&self, path: &Path) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 praiseassert_rotation_fails_unchanged re-asserting the key, the certificate and the journal bytes on every failure path is the right shape for testing a trust-store mutator: it turns "rotation never proceeds on an unconfirmed revoke" into a property rather than a comment.

Same for the design it's testing — record-before-mutate journal, full DER equality (not the nickname) as the authorization check, a CA-directory lock plus a per-NSS-directory lock so distinct --ca-dirs serialize against a shared store, and the same-subject preflight that stops NSS's named-export ambiguity from silently rebinding someone else's certificate. That last one is a genuinely non-obvious NSS behaviour to have found and closed before shipping.

@aram356
aram356 requested a review from dhruv8sh September 17, 2026 15:35

@aram356 aram356 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Summary

Extends ts dev proxy from macOS-only to macOS + Linux: NSS-based CA trust, native browser discovery, a Linux CI matrix, and substantial regression coverage. The platform split is well constructed and the shared/cfg'd division is right — certutil, checked, lock, lock_file, and import_firefox are genuinely shared, and only install/uninstall/ensure_can_generate are conditional, which is correct since the keychain and NSS have no common substrate.

Two blocking defects, both in the certutil -L listing parser and both reproduced against a real certutil. They share one root cause: scraping a fixed-width human-readable table cannot losslessly recover an NSS nickname. Their blast radius is wider than it first appears — nicknames() feeds contains(), which uninstall and regenerate also depend on, so an unrelated pre-existing entry in the user's NSS database can make the dev CA impossible to install, remove, or rotate.

All inline comments describe fixes in prose; none is expressible as a one-click suggestion (findings 1 and 2 need a parsing-strategy change plus tests, and the rest either span two call sites or touch lines outside the diff hunks).

Blocking

🔧 wrench

  • nicknames() strips real trailing whitespace, blocking install, uninstall, and regenerate — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:264
  • A newline in any nickname hard-fails the same three commands — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:254

Non-blocking

♻️ refactor

  • Browser::Safari is representable but unconstructible on Linux — see inline at crates/trusted-server-cli/src/commands/dev/proxy/config.rs:113
  • Duplicated which + canonicalize lookup closure — see inline at crates/trusted-server-cli/src/commands/dev/proxy/browser.rs:278-281

🤔 thinking

  • contains(entry)?; discards its bool beside an identical call that uses it — see inline at crates/trusted-server-cli/src/commands/dev/proxy/trust.rs:357

⛏ nitpick

  • safari is macOS-only hint is emitted on macOS too — see inline at crates/trusted-server-cli/src/commands/dev/proxy/config.rs:50

Cross-cutting / body-level findings

  • 📝 Checked and found correct, recorded so these are not re-raised — several plausible-looking concerns were investigated against the running system and are not defects: DefaultHasher nicknames are deterministic across processes (and the journal is authoritative regardless, so cross-release hash drift is survivable); File::try_lock is stable on the pinned 1.95.0 and is OS-released on SIGKILL; the macOS errSecItemNotFound → exit-code-44 assumption is correct (verified against security find-certificate on a real host); switching the macOS path from .status() to .output() is safe because security add-trusted-cert authenticates via a GUI dialog rather than a TTY prompt; write_record persists at mode 0600; uninstall's ordering is genuinely fail-safe (the record only shrinks after a removal is confirmed); and the absence of a #[cfg(not(any(macos, linux)))] arm in chrome_command/firefox_command is fine because the whole proxy module and its dependency set are gated at dev/mod.rs:1 and Cargo.toml:35.

  • 📝 On the platform-abstraction question: do not introduce a TrustStore trait. The #[cfg] resolves at compile time, so there is never more than one implementation in a given build — a trait would add a vtable or a generic parameter for zero dispatch, and object-safety would force &self onto what are genuinely free functions over a &Path. Cfg'd free functions behind a re-export is the correct idiom here and matches what std::sys does. The asymmetry between the inline mod linux (9 helpers, a Destination type, its own tests) and the bare macOS items (one helper) tracks a real size asymmetry and is justified. Two small follow-ups worth considering, neither blocking: a one-line doc comment above the pub(super) use linux::{...} re-export naming the three-function platform contract (nothing currently makes the compiler check that the two arms agree in signature — they would diverge silently until someone builds the other platform); and if mod linux grows past its current ~375 lines, promoting it to its own file.

  • 🌱 Consider obtaining nicknames in a machine-readable form. Both blocking findings dissolve if the tool stops parsing the human-readable table. Worth evaluating whether the certificate set can be enumerated without relying on certutil -L's column layout, which is a presentation format with no stability guarantee across NSS releases.

CI Status

  • integration tests (Fastly EC lifecycle): PASS
  • integration tests: PASS
  • browser integration tests: PASS
  • CodeQL: PASS
  • cargo test (ts CLI, native) (macos-latest): PASS
  • cargo test (ts CLI, native) (ubuntu-latest): PASS
  • Analyze (javascript-typescript): PASS
  • Analyze (actions): PASS
  • Analyze (rust): PASS
  • CLAUDE.md symlink guard: PASS
  • cargo fmt: PASS
  • cargo test (axum native): PASS
  • cargo test (cross-adapter parity): PASS
  • cargo test: PASS
  • cargo check (cloudflare native + wasm32-unknown-unknown): PASS
  • cargo check/build/test (spin native + wasm32-wasip1): PASS
  • format-docs: PASS
  • format-typescript: PASS
  • prepare integration artifacts: PASS
  • vitest: PASS

Reviewer-side verification on the PR head: cargo clippy --package trusted-server-cli --target aarch64-apple-darwin --all-targets -- -D warnings clean, and the macOS CLI test suite passes (29 unit + 7 perf + 1 keychain).

{
return Err(Report::new(TrustError::Command).attach("invalid NSS trust attributes"));
}
names.push(name.trim_end().to_owned());

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — NSS accepts and preserves trailing whitespace in a nickname, but certutil -L's listing renders it indistinguishably from the column padding. trim_end() here therefore produces a name that certutil -L -n cannot resolve, and checked() turns that non-zero exit into a hard error.

Reproduced against a real certutil (NSS 3.x):

$ certutil -A -n "Some Corp CA " -t "C,," -i c.pem -d sql:$D
IMPORT OK
$ certutil -L -d sql:$D
Some Corp CA                                                 C,,
$ certutil -L -n "Some Corp CA" -a -d sql:$D     # the name this parser yields
exit 255   certutil: Could not find cert: Some Corp CA
$ certutil -L -n "Some Corp CA " -a -d sql:$D    # the real nickname
exit 0

Feeding that captured listing through this function's exact logic returns Ok(["Some Corp CA"]) — the trailing space is gone.

Why the blast radius is larger than install. nicknames() also backs contains() (line 313), which uninstall calls at lines 409 and 412, and which regenerate reaches through uninstall. So a single unrelated third-party certificate with a trailing-space nickname in the user's Chrome/Chromium NSS database makes the dev CA impossible to install, impossible to remove, and impossible to rotate — all reported as a generic browser trust command failed with nothing pointing at the foreign entry as the cause.

Second-order effect on the conflict check. Two distinct certificates under "Corp CA" and "Corp CA " render identically in the listing and both parse to "Corp CA"; the exact-match export then returns only one of them. check_subject_conflicts consequently scans the same certificate twice and never inspects the other, so a same-subject conflicting CA hiding behind a trailing-space nickname defeats the check that lines 281-283 exist to enforce. That is a soundness gap in a security control, not only an availability bug.

Proposed fix (apply manually — a parsing-strategy change plus a regression test; cannot be expressed as a single-range suggestion):

// Split on the fixed column boundary that certutil emits rather than
// rsplit_once + trim_end, so a nickname's own trailing spaces survive.
// Additionally, treat a nickname that fails to re-export as a
// skip-with-warning rather than a hard failure of the whole operation,
// so one foreign entry cannot block install/uninstall/regenerate.

A regression test can be written without NSS installed by feeding the captured listing bytes above straight into nicknames().

{
continue;
}
let (name, trust) = line.rsplit_once(char::is_whitespace).ok_or_else(|| {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔧 wrench — NSS also accepts newlines inside a nickname. The listing then wraps across lines and rsplit_once treats each fragment as its own row, so the first fragment fails the trust-attribute validation below.

Reproduced against a real certutil:

$ certutil -A -n "$(printf 'Line One\nLine Two')" -t "C,," -i c.pem -d sql:$D
IMPORT OK
$ certutil -L -d sql:$D
Line One
Line Two                                            C,,

Through this function's exact logic: Err("invalid NSS trust attributes: \"One\" (line \"Line One\")")"Line One" splits into ("Line", "One") and "One" fails the three-comma check.

This shares the root cause and the blast radius of the trailing-whitespace finding on line 264: it propagates as TrustError::Command out of install, uninstall, and regenerate alike. Severity is lower because it fails closed — an error, never a wrong trust decision — and a newline nickname is rarer than a trailing space. The same column-boundary parse fixes both.

Proposed fix (apply manually — same change as the line 264 finding):

// Parse against certutil's fixed column layout so a wrapped nickname is
// reassembled rather than being read as two malformed rows.

For context, the header-skip logic immediately above this is correct — I confirmed SSL,S/MIME,JAR/XPI is a genuine second header line that equals the literal exactly after str::trim. Only the row parse needs changing.

return Ok(vec![
Self::Chrome,
Self::Firefox,
#[cfg(target_os = "macos")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactorBrowser::Safari is declared unconditionally at line 99, but both of its construction sites are #[cfg(target_os = "macos")] (this one and the "safari" match arm at 123-124). On Linux the type therefore admits a state the parser can never produce, while browser.rs:80 still dispatches Browser::Safari => launch_safari(cfg) into a function compiled on Linux with an entirely macOS body (networksetup, route, sudo, open -a Safari).

I verified this degrades safely today — detect_network_service() returns None on non-macOS, so launch_safari warns and returns — so this is a latent trap rather than a live bug. It bites when a second constructor appears: a clap ValueEnum, a config-file default, or a test fixture would all compile happily and then take an unreachable-by-design path at runtime instead of failing the build.

Cfg'ing the variant itself makes the state unrepresentable on Linux and gets the dispatch compiler-checked:

pub enum Browser {
    Chrome,
    Firefox,
    #[cfg(target_os = "macos")]
    Safari,
}

The three existing cfgs in parse_list then stay exactly as they are and become required rather than conventional; the compiler will point at browser.rs:80, which needs a matching #[cfg(target_os = "macos")]; and launch_safari plus its macOS support cast (detect_network_service, get_auto_proxy_state, restore_auto_proxy, manual_restore_command) can then be cfg'd out of the Linux build rather than compiled and never called. The test at 640-659 already spells the cfg'd-element form, so it needs no change.

Apply manually — the change spans config.rs and browser.rs together, so it cannot be a single-file suggestion.

For the record, the cfg-inside-vec![] and cfg-on-a-match-arm technique used here is perfectly legitimate stable Rust and reads fine; the objection is only to the variant's unconditional declaration, not to how parse_list is written.

Comment on lines +278 to +281
find_native_browser(CHROME_LAUNCHERS, |name| {
which::which(name)
.ok()
.and_then(|path| path.canonicalize().ok())

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ refactor — this closure is byte-identical to the one in firefox_command() at lines 379-383:

which::which(name).ok().and_then(|path| path.canonicalize().ok())

The canonicalize() is the security-relevant half: without it, a symlink from /usr/bin into /snap or /flatpak would slip past the component check in find_native_browser at lines 261-266, which is the mechanism the PR relies on to refuse packaged browsers. Two copies means a future hardening of the lookup has to remember both sites, and a fix applied to one is silently absent from the other.

Proposed fix (apply manually — the change touches two separate hunks in this file, so it cannot be a single-range suggestion):

#[cfg(target_os = "linux")]
fn lookup_on_path(name: &str) -> Option<std::path::PathBuf> {
    which::which(name)
        .ok()
        .and_then(|path| path.canonicalize().ok())
}

Both call sites then collapse to find_native_browser(CHROME_LAUNCHERS, lookup_on_path) and find_native_browser(&["firefox"], lookup_on_path).

Injecting lookup as a parameter on find_native_browser was the right call — it is what makes the test at 748-766 possible without touching the real PATH, and that test covers discovery order, total absence, and the snap rejection. This finding is only about the two duplicate closure bodies.

Minor, while you are here: firefox_command() carries a doc comment but chrome_command() has none.

}
};
let entry = &entries[index];
contains(entry)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔 thinking — the bool is discarded here, but the identical call at line 372 uses its value, which makes this line read as a dropped result rather than a deliberate choice.

Having traced it, the behaviour is correct: contains() returns Ok(true) only when the entry is present and DER-identical (line 320 has already raised TrustError::Identity on a mismatch), Ok(false) when absent, and in both cases the right next action is to import. The ? is doing the load-bearing work of surfacing the identity conflict; the presence answer genuinely is not needed. I also confirmed against a real certutil that re-importing an identical certificate under the same nickname is idempotent in NSS — the listing still shows one row and -r returns a single DER — so the re-import is neither incorrect nor meaningfully wasteful.

The suggestion is just to make that intent legible to the next reader, either by binding the value or by stating it above the call:

// Called for its identity-conflict check only; an already-present
// identical certificate is re-imported idempotently either way.
contains(entry)?;

This file otherwise comments exactly this class of subtlety well — see the notes at lines 307, 334, and 338 — so this line stands out as the outlier.

/// An unknown browser name was passed to `--launch`.
#[display("unknown browser `{value}` (expected chrome|firefox|safari|all)")]
/// An unknown or unsupported browser was passed to `--launch`.
#[display("unsupported browser `{value}` (use chrome|firefox|all; safari is macOS-only)")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nitpick — the parenthetical is unconditional, so on macOS, where safari is a perfectly valid value, a user who typos netscape is told that safari is macOS-only on the one platform where it works.

Either cfg the hint so each platform lists what it actually accepts, or drop the platform note from the shared message and let the Linux-specific rejection carry it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Linux in ts dev proxy

3 participants