From 9ff3e3f611e8db67681b65a7ebd3a16e14b7cc37 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 8 Oct 2026 01:12:31 -0400 Subject: [PATCH 1/4] fix(helper)!: check ports 80/443 as root and drop kernel-table inspection --- .github/workflows/ci.yml | 1 - Cargo.lock | 2 +- DESIGN.md | 10 +- crates/cli/src/commands/artifact_resource.rs | 6 + crates/cli/src/commands/mod.rs | 6 + crates/cli/src/commands/php.rs | 6 + crates/cli/src/commands/ports.rs | 35 +- crates/cli/src/environment.rs | 16 +- crates/cli/tests/ca.rs | 4 + crates/cli/tests/composer.rs | 4 + crates/cli/tests/daemon.rs | 4 + crates/cli/tests/dns.rs | 4 + crates/cli/tests/doctor.rs | 12 +- crates/cli/tests/init.rs | 4 + crates/cli/tests/jobs.rs | 4 + crates/cli/tests/list.rs | 4 + crates/cli/tests/logs.rs | 4 + crates/cli/tests/mailpit.rs | 4 + crates/cli/tests/php.rs | 4 + crates/cli/tests/ports.rs | 99 ++- crates/cli/tests/presentation.rs | 4 + crates/cli/tests/project_env.rs | 4 + crates/cli/tests/project_open.rs | 4 + crates/cli/tests/project_unlink.rs | 4 + crates/cli/tests/rustfs.rs | 4 + crates/cli/tests/setup.rs | 68 +- ..._when_active_pf_redirects_are_missing.snap | 2 +- ...tor_fails_when_daemon_socket_is_stale.snap | 2 +- ...fails_when_system_ca_trust_is_missing.snap | 2 +- ...fails_when_system_resolver_is_missing.snap | 2 +- ...or__doctor_fails_with_repair_commands.snap | 2 +- .../doctor__doctor_on_a_terminal_failing.snap | 2 +- .../doctor__doctor_on_a_terminal_healthy.snap | 2 +- ...ctor_passes_when_required_checks_pass.snap | 4 +- ...ilure_repair_and_identical_recurrence.snap | 6 +- .../doctor__doctor_warnings_do_not_fail.snap | 2 +- ...ict_before_writing_prepared_artifacts.snap | 2 +- ...__ports_install_names_low_port_owners.snap | 13 + ...conflict_when_redirects_already_match.snap | 13 + ..._setup_manifest_missing_default_plain.snap | 2 +- ...tup_manifest_missing_default_terminal.snap | 2 +- ...grations_and_waits_for_reconciliation.snap | 2 +- ...e_fails_before_shell_profile_mutation.snap | 2 +- .../snapshots/setup__setup_on_a_terminal.snap | 2 +- ...onflict_does_not_reinstall_the_helper.snap | 18 + ...s_from_active_release_helper_metadata.snap | 4 +- ...setup_stops_at_a_failed_required_step.snap | 2 +- ...stops_at_a_failed_required_step_plain.snap | 2 +- ...ifest_with_warning_when_refresh_fails.snap | 2 +- ...uninstall_removes_shell_profile_block.snap | 4 +- ...ocol_mismatch_health_after_activation.snap | 2 +- ...orts_app_and_managed_resource_updates.snap | 2 +- ...eck_on_a_terminal_renders_status_rows.snap | 2 +- ...orts_app_and_managed_resource_updates.snap | 2 +- ...heck_reports_blocked_managed_resource.snap | 2 +- ...heck_reports_current_managed_resource.snap | 2 +- ...heck_reports_revoked_managed_resource.snap | 2 +- ..._reports_unavailable_managed_resource.snap | 2 +- ...reexecs_managed_resource_continuation.snap | 2 +- ...agent_without_restarting_when_current.snap | 2 +- ...forwards_no_color_to_the_continuation.snap | 2 +- ...__update_on_a_terminal_renders_a_flow.snap | 2 +- ...r_identity_that_cannot_be_rolled_back.snap | 2 +- ...current_app_without_restarting_daemon.snap | 2 +- ...lure_without_rolling_back_updated_app.snap | 2 +- ...ns_when_pruning_old_app_release_fails.snap | 2 +- crates/cli/tests/status.rs | 4 + crates/cli/tests/support/resource_cli.rs | 4 + crates/cli/tests/update.rs | 154 ++-- crates/platform/src/ca.rs | 3 +- crates/platform/src/capability.rs | 2 - crates/platform/src/command.rs | 34 + crates/platform/src/error.rs | 7 - crates/platform/src/helper.rs | 135 +++- crates/platform/src/lib.rs | 4 +- crates/platform/src/listener.rs | 24 - crates/platform/src/listener/linux.rs | 8 - crates/platform/src/listener/macos.rs | 14 - .../src/listener/macos/kernel_table.rs | 749 ------------------ ...ure_matches_xnu_single_envelope_shape.snap | 18 - ...tects_all_controlled_listener_classes.snap | 26 - ...res_return_deterministic_typed_errors.snap | 62 -- ...tates_generations_and_unknown_records.snap | 10 - crates/platform/src/listener/unsupported.rs | 8 - crates/platform/src/listener/windows.rs | 8 - crates/platform/src/low_port.rs | 192 +++++ crates/platform/src/pf.rs | 27 +- ..._only_the_requested_empty_exit_status.snap | 30 + ...atform__low_port__tests__empty_output.snap | 18 + ..._capped_and_free_ports_have_no_owners.snap | 49 ++ ...latform__low_port__tests__named_owner.snap | 23 + ...low_port__tests__nginx_master_workers.snap | 40 + ...atform__low_port__tests__several_pids.snap | 28 + ...tests__unrelated_or_incomplete_fields.snap | 18 + crates/platform/tests/resolver_config.rs | 41 - ..._ports_include_ipv4_wildcard_listener.snap | 10 - ..._ipv6_loopback_and_wildcard_listeners.snap | 14 - crates/platform/tests/unsupported_listener.rs | 32 - crates/privileged-helper/Cargo.toml | 2 +- 99 files changed, 1001 insertions(+), 1246 deletions(-) create mode 100644 crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap create mode 100644 crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap create mode 100644 crates/cli/tests/snapshots/setup__setup_pf_apply_conflict_does_not_reinstall_the_helper.snap delete mode 100644 crates/platform/src/listener.rs delete mode 100644 crates/platform/src/listener/linux.rs delete mode 100644 crates/platform/src/listener/macos.rs delete mode 100644 crates/platform/src/listener/macos/kernel_table.rs delete mode 100644 crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__empty_pcb_fixture_matches_xnu_single_envelope_shape.snap delete mode 100644 crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap delete mode 100644 crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__malformed_pcb_fixtures_return_deterministic_typed_errors.snap delete mode 100644 crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__pcb_fixture_covers_address_families_states_generations_and_unknown_records.snap delete mode 100644 crates/platform/src/listener/unsupported.rs delete mode 100644 crates/platform/src/listener/windows.rs create mode 100644 crates/platform/src/low_port.rs create mode 100644 crates/platform/src/snapshots/platform__command__tests__bounded_command_accepts_only_the_requested_empty_exit_status.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__lsof_owners_are_capped_and_free_ports_have_no_owners.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap delete mode 100644 crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv4_wildcard_listener.snap delete mode 100644 crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv6_loopback_and_wildcard_listeners.snap delete mode 100644 crates/platform/tests/unsupported_listener.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a72bc5d4..88c70ad5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -160,7 +160,6 @@ jobs: cargo test --locked -p state --lib update_lock cargo test --locked -p resources --lib target_platform_for cargo test --locked -p platform --test unsupported_launch_agent - cargo test --locked -p platform --test unsupported_listener cargo test --locked -p daemon --test platform_preflight - name: Compile daemon tests on Linux diff --git a/Cargo.lock b/Cargo.lock index 51f2ec6d..9789b70c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3133,7 +3133,7 @@ dependencies = [ [[package]] name = "pv-privileged-helper" -version = "1.0.0" +version = "2.0.0" dependencies = [ "anyhow", "platform", diff --git a/DESIGN.md b/DESIGN.md index 7f1023b8..9ee5ce9e 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -28,7 +28,7 @@ PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the macOS 13 may continue to run PV when the application and Managed Resource binaries remain compatible, but it is untested and unsupported. Dropping support does not by itself require raising binary deployment targets or republishing otherwise compatible Managed Resource artifacts. Before PV deliberately ships an application binary that cannot run on macOS 13, the application update manifest and updater must prevent an incompatible update from being activated there. -The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Private-interface acceptance tests such as listener inspection run as part of every matrix lane. New supported macOS major versions must be added to the matrix before PV relies on behavior there. +The full macOS CI quality and behavior suite covers every supported macOS major version and both supported architectures across a representative matrix rather than every version/architecture combination. The initial matrix is macOS 14 on Apple Silicon, macOS 15 on Intel, and macOS 26 on Apple Silicon. Every use of an undocumented or private macOS interface fails closed: a result that cannot prove it saw other processes is an error, never an empty success. Acceptance tests for OS behavior PV relies on, such as bind conflict rules and process identity, observe a separate process and run in every matrix lane. Behavior that depends on root is tested as root. New supported macOS major versions must be added to the matrix before PV relies on behavior there. Linux and Windows are committed subsequent platforms. During macOS stabilization, the installed application and runtime crates compile natively on macOS, Linux, and Windows so new system boundaries do not create unnecessary portability blockers. @@ -80,7 +80,7 @@ Managed Resources remain external binaries/artifacts managed by PV rather than R Initial PV distribution is a standalone install script/direct binary download. Homebrew support can be added after the release flow stabilizes. A signed `.pkg` is deferred unless macOS trust/onboarding requires it. -The install script downloads the PV application and its separate privileged helper artifact, verifies each against its published SHA-256 checksum, and installs both plus the required adjacent `pv-helper.json` release metadata into the user-owned release directory. Setup and update require that metadata rather than relying on a checksum compiled into the application. If either verification fails, installation deletes the bad download and stops before editing shell profiles or running setup. Dogfood helper artifacts use ad-hoc code signing; Developer ID signing, notarization, and a signed package are deferred until public release. +The install script downloads the PV application and its separate privileged helper artifact, verifies each against its published SHA-256 checksum, and installs both plus the required adjacent `pv-helper.json` release metadata into the user-owned release directory. Setup and update require that metadata rather than relying on a checksum compiled into the application. If either verification fails, installation deletes the bad download and stops before editing shell profiles or running setup. Dogfood helper artifacts use ad-hoc code signing; Developer ID signing, notarization, and a signed package are deferred until public release. No correctness path may depend on the signing identity of PV or of any ancestor process. The stable installer URL serves a generated installer script based on PV app release metadata. The bash installer does not need to parse the JSON PV app update manifest. The installer script may embed or otherwise receive the resolved current PV version, platform asset URLs, and SHA-256 checksums from the server-side installer generation flow. The JSON PV app update manifest is used by the Rust self-updater. @@ -162,7 +162,7 @@ The Gateway listens as the user on high loopback ports only. PV prefers uncommon PV v1 does not expose Projects on the LAN or through tunnels. LAN access or tunnel integrations such as Cloudflare Tunnels may be considered later. -If another process is already listening on loopback port `80` or `443`, `pv setup` and `pv ports:install` fail with a clear conflict instead of silently taking over traffic. When detectable, PV reports the process that owns the port. +Before installing redirects, `pv setup` and `pv ports:install` ask the privileged helper whether loopback ports `80` and `443` are free, and the helper checks again before applying pf rules. The helper decides with a root bind probe on `127.0.0.1` and `0.0.0.0` that ignores closing connections. If either port is in use, PV fails with a clear conflict instead of silently taking over traffic, and names the owning process and pid when `lsof` can identify it. ### PF Health and Recovery @@ -310,7 +310,7 @@ Privileged repair happens only from foreground commands such as `pv setup`, `pv PV uses a separate minimal `pv-helper` executable registered as the system launchd job `com.prvious.pv.helper`. Launchd creates the restricted `/var/run/com.prvious.pv.helper.sock` and activates the helper on demand. Once activated, the helper serves sequential connections until launchd stops it, avoiding launchd restart throttling between related operations. Its LaunchDaemon writes startup and uncaptured child-process diagnostics to `/var/log/com.prvious.pv.helper.err.log`. The daemon, Gateway, DNS resolver, and all Managed Resources remain unprivileged. The helper supports one installing macOS account per machine. Helper removal is restricted to the original installing UID, so that account must remove it before setup by a replacement account; an unavailable owner requires manual administrator recovery. -The helper accepts only versioned typed requests for status, DNS inspection/apply/removal, PF inspection/apply/reload/removal, and CA trust inspection/apply/removal. Requests cannot provide commands, executable paths, destination paths, raw configuration, environment behavior, or network operations. DNS and PF content is generated internally for fixed system destinations. `CaApply` reads only the installing account's fixed PV CA path, validates PV CA metadata and the requested fingerprint, stages that exact certificate in the fixed root work path, revalidates it, and trusts it. The helper revalidates ownership, conflicts, arguments, and expected state before each mutation. `pv.db` remains the only desired-state source of truth; root-owned helper metadata contains only the installing UID, helper version, and protocol version. +The helper accepts only versioned typed requests for status, DNS inspection/apply/removal, PF inspection/apply/removal, loopback port 80/443 inspection, and CA trust inspection/apply/removal. Requests cannot provide commands, executable paths, destination paths, raw configuration, environment behavior, or network operations. DNS and PF content is generated internally for fixed system destinations. `CaApply` reads only the installing account's fixed PV CA path, validates PV CA metadata and the requested fingerprint, stages that exact certificate in the fixed root work path, revalidates it, and trusts it. The helper revalidates ownership, conflicts, arguments, and expected state before each mutation. `pv.db` remains the only desired-state source of truth; root-owned helper metadata contains only the installing UID, helper version, and protocol version. The socket is restricted to the installing account, and the helper verifies the Unix peer UID before decoding and dispatching a bounded request. Protocol version is validated independently from app and helper versions. Install and replacement readiness uses a small protocol-neutral lifecycle probe, separate from the versioned operational request schema, so an app may verify a newly installed helper before activating a matching protocol update. Missing or incompatible helpers produce repair guidance to run `pv setup`; `pv doctor` reports cross-account authentication failures with guidance to use the original installing account or perform manual administrator recovery. Normal DNS, PF, and CA commands never fall back to `sudo`. @@ -944,7 +944,7 @@ Managed Resource artifacts must run from PV's installed resource layout before p PV does not rely on whole-archive binary or string scanning as a v1 publication gate. Artifact recipes prove portability through archive layout validation, adapter-required file checks, and target-platform smoke tests. Resource-specific static checks may be added later if a resource's packaging risk justifies them. -For v1, PV ad-hoc signs Managed Resource Mach-O binaries in the release pipeline after any binary path fixes. Paid Developer ID signing and notarization for Managed Resource artifacts are deferred unless macOS Gatekeeper or quarantine behavior requires them for a reliable v1 install experience. Checksums are computed only after final signing and packaging. +For v1, PV ad-hoc signs Managed Resource Mach-O binaries in the release pipeline after any binary path fixes. Paid Developer ID signing and notarization for Managed Resource artifacts are deferred unless macOS Gatekeeper or quarantine behavior requires them for a reliable v1 install experience. Checksums are computed only after final signing and packaging. No correctness path may depend on the signing identity of PV or of any ancestor process. The remote Managed Resource artifact manifest is the only manifest in v1. PV-owned artifact archives do not contain per-archive manifest files in v1; validation comes from the remote manifest plus the compiled-in resource adapter rules. diff --git a/crates/cli/src/commands/artifact_resource.rs b/crates/cli/src/commands/artifact_resource.rs index 666e1ded..62554da7 100644 --- a/crates/cli/src/commands/artifact_resource.rs +++ b/crates/cli/src/commands/artifact_resource.rs @@ -467,6 +467,12 @@ mod tests { } impl Environment for TestEnvironment { + fn inspect_low_ports( + &self, + ) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/src/commands/mod.rs b/crates/cli/src/commands/mod.rs index ea7b518d..a476393b 100644 --- a/crates/cli/src/commands/mod.rs +++ b/crates/cli/src/commands/mod.rs @@ -744,6 +744,12 @@ mod tests { } impl Environment for AccessTrackingEnvironment { + fn inspect_low_ports( + &self, + ) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { self.record_access(); None diff --git a/crates/cli/src/commands/php.rs b/crates/cli/src/commands/php.rs index 03ea713b..02b54741 100644 --- a/crates/cli/src/commands/php.rs +++ b/crates/cli/src/commands/php.rs @@ -690,6 +690,12 @@ mod tests { struct UnsupportedPlatformEnvironment; impl Environment for UnsupportedPlatformEnvironment { + fn inspect_low_ports( + &self, + ) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/src/commands/ports.rs b/crates/cli/src/commands/ports.rs index 319239b3..6472a4a2 100644 --- a/crates/cli/src/commands/ports.rs +++ b/crates/cli/src/commands/ports.rs @@ -15,8 +15,6 @@ use crate::output::{Line, Mark, Output, Streams}; use super::pf_diagnostics::PfRoutingDiagnostic; -const LOW_PORTS: [u16; 2] = [80, 443]; - pub(crate) fn status( args: PortsStatusArgs, environment: &impl Environment, @@ -78,22 +76,21 @@ pub(crate) fn install( streams: &mut Streams<'_>, ) -> Result { let paths = pv_paths(environment)?; - let listening_ports = environment.loopback_tcp_listener_ports()?; - let low_port_conflicts = low_port_conflicts(&listening_ports); + let inspection = environment.inspect_low_ports()?; let output = &mut streams.out; - if !low_port_conflicts.is_empty() { + if inspection.ports.iter().any(|port| !port.available) { output.failure("Port redirect preparation failed")?; - for port in &low_port_conflicts { - output.detail(format!("Loopback TCP port {port} already has a listener."))?; + for port in inspection.ports.iter().filter(|port| !port.available) { + output.detail(port.conflict_message())?; + if port.owners.is_empty() { + output.hint( + "find it", + &format!("sudo lsof -nP -iTCP:{} -sTCP:LISTEN", port.port), + )?; + } } output.detail("Stop the conflicting service, then run `pv ports:install` again.")?; - if output.surface().decorated() - && let Some(port) = low_port_conflicts.first() - { - output.hint("find it", &format!("lsof -nP -iTCP:{port} -sTCP:LISTEN"))?; - } - return Ok(ExitCode::FAILURE); } @@ -350,18 +347,6 @@ pub(crate) fn uninstall( Ok(ExitCode::SUCCESS) } -fn low_port_conflicts(listening_ports: &std::collections::BTreeSet) -> Vec { - let mut conflicts = Vec::new(); - - for port in LOW_PORTS { - if listening_ports.contains(&port) { - conflicts.push(port); - } - } - - conflicts -} - fn pf_config_from_assignments(assignments: &GatewayPortAssignments) -> PfRedirectConfig { PfRedirectConfig::new(assignments.http.port, assignments.https.port) } diff --git a/crates/cli/src/environment.rs b/crates/cli/src/environment.rs index 3e9dc3d3..4d217eba 100644 --- a/crates/cli/src/environment.rs +++ b/crates/cli/src/environment.rs @@ -137,11 +137,7 @@ pub trait Environment { platform::loopback_tcp_port_available(port) } - fn loopback_tcp_listener_ports( - &self, - ) -> Result, platform::PlatformError> { - platform::loopback_tcp_listener_ports() - } + fn inspect_low_ports(&self) -> Result; fn install_pf_redirects( &self, @@ -276,6 +272,10 @@ pub(crate) fn app_update_manifest_url(environment: &impl Environment) -> String pub struct ProcessEnvironment; impl Environment for ProcessEnvironment { + fn inspect_low_ports(&self) -> Result { + platform::PrivilegedHelperClient.inspect_low_ports() + } + fn var_os(&self, key: &str) -> Option { process_var_os(key) } @@ -402,6 +402,12 @@ mod tests { } impl Environment for TestEnvironment { + fn inspect_low_ports( + &self, + ) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, key: &str) -> Option { self.vars.get(key).cloned() } diff --git a/crates/cli/tests/ca.rs b/crates/cli/tests/ca.rs index 750fd57f..f2d7dc6c 100644 --- a/crates/cli/tests/ca.rs +++ b/crates/cli/tests/ca.rs @@ -53,6 +53,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/composer.rs b/crates/cli/tests/composer.rs index 459eed12..d556577a 100644 --- a/crates/cli/tests/composer.rs +++ b/crates/cli/tests/composer.rs @@ -125,6 +125,10 @@ fn composer_exec_env(home: &Utf8Path, php_track: &str) -> anyhow::Result Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, key: &str) -> Option { self.vars.borrow().get(key).cloned() } diff --git a/crates/cli/tests/daemon.rs b/crates/cli/tests/daemon.rs index 1165602d..77611842 100644 --- a/crates/cli/tests/daemon.rs +++ b/crates/cli/tests/daemon.rs @@ -65,6 +65,10 @@ enum BootoutError { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/dns.rs b/crates/cli/tests/dns.rs index 7d79f967..77756bfe 100644 --- a/crates/cli/tests/dns.rs +++ b/crates/cli/tests/dns.rs @@ -39,6 +39,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/doctor.rs b/crates/cli/tests/doctor.rs index 856de786..aa4f4fe1 100644 --- a/crates/cli/tests/doctor.rs +++ b/crates/cli/tests/doctor.rs @@ -98,6 +98,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } @@ -222,12 +226,12 @@ fn doctor_accepts_helper_only_version_from_active_release_metadata() -> anyhow:: let paths = PvPaths::for_home(tempdir.path().join("home")); let environment = TestEnvironment::new(paths.home()); seed_required_checks(&paths, &environment, true)?; - environment.set_helper_status("1.1.0", 1); + environment.set_helper_status("2.1.0", 2); write_file( &paths .app_release_helper(env!("CARGO_PKG_VERSION")) .with_file_name("pv-helper.json"), - "{\n \"version\": \"1.1.0\",\n \"protocol_version\": 1,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", + "{\n \"version\": \"2.1.0\",\n \"protocol_version\": 2,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", )?; let health_server = spawn_health_server(&paths.daemon_socket())?; @@ -238,7 +242,7 @@ fn doctor_accepts_helper_only_version_from_active_release_metadata() -> anyhow:: assert!( output .stdout - .contains("available at version 1.1.0 with protocol 1") + .contains("available at version 2.1.0 with protocol 2") ); Ok(()) @@ -609,7 +613,7 @@ fn seed_required_checks( state::fs::write_sensitive_file(&release_helper, "pv helper\n")?; state::fs::write_sensitive_file( &release_helper.with_file_name("pv-helper.json"), - "{\n \"version\": \"1.0.0\",\n \"protocol_version\": 1,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", + "{\n \"version\": \"2.0.0\",\n \"protocol_version\": 2,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", )?; if include_manifest_cache { diff --git a/crates/cli/tests/init.rs b/crates/cli/tests/init.rs index 2795c783..3f930cfe 100644 --- a/crates/cli/tests/init.rs +++ b/crates/cli/tests/init.rs @@ -50,6 +50,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/jobs.rs b/crates/cli/tests/jobs.rs index 3908e86b..fb7e268b 100644 --- a/crates/cli/tests/jobs.rs +++ b/crates/cli/tests/jobs.rs @@ -32,6 +32,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/list.rs b/crates/cli/tests/list.rs index b4922bab..83d36dcb 100644 --- a/crates/cli/tests/list.rs +++ b/crates/cli/tests/list.rs @@ -33,6 +33,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/logs.rs b/crates/cli/tests/logs.rs index b6c506c0..07f03df6 100644 --- a/crates/cli/tests/logs.rs +++ b/crates/cli/tests/logs.rs @@ -33,6 +33,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/mailpit.rs b/crates/cli/tests/mailpit.rs index 1e434089..0a392f25 100644 --- a/crates/cli/tests/mailpit.rs +++ b/crates/cli/tests/mailpit.rs @@ -35,6 +35,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/php.rs b/crates/cli/tests/php.rs index cb14a841..612800fb 100644 --- a/crates/cli/tests/php.rs +++ b/crates/cli/tests/php.rs @@ -109,6 +109,10 @@ fn php_exec_env(home: &Utf8Path, track: &str) -> anyhow::Result Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/ports.rs b/crates/cli/tests/ports.rs index 64967c73..6cf2ece6 100644 --- a/crates/cli/tests/ports.rs +++ b/crates/cli/tests/ports.rs @@ -1,5 +1,5 @@ use std::cell::{Cell, RefCell}; -use std::collections::BTreeSet; +use std::collections::{BTreeMap, BTreeSet}; use std::ffi::OsString; use std::io; use std::path::PathBuf; @@ -9,7 +9,10 @@ use camino::Utf8Path; use camino_tempfile::tempdir; use cli::{Environment, run_with_environment}; use insta::assert_debug_snapshot; -use platform::{ActivePfRedirectInspection, PfConfReference, PfRedirectConfig}; +use platform::{ + ActivePfRedirectInspection, LowPortInspection, LowPortState, PfConfReference, PfRedirectConfig, + PortOwner as LowPortOwner, +}; use serde_json::Value; use state::{ Database, GATEWAY_HTTP_PREFERRED_PORT, GATEWAY_HTTPS_PREFERRED_PORT, PortOwner, PvPaths, @@ -23,6 +26,7 @@ struct TestEnvironment { pf_anchor_path: PathBuf, pf_conf_path: PathBuf, listening_ports: BTreeSet, + low_port_owners: BTreeMap>, active_pf_config: RefCell>, active_pf_inspections: RefCell, active_pf_read_fails_when_unloaded: bool, @@ -47,6 +51,7 @@ impl TestEnvironment { pf_anchor_path: pf_anchor_path.as_std_path().to_path_buf(), pf_conf_path: pf_conf_path.as_std_path().to_path_buf(), listening_ports: BTreeSet::new(), + low_port_owners: BTreeMap::new(), active_pf_config: RefCell::new(None), active_pf_inspections: RefCell::new(0), active_pf_read_fails_when_unloaded: false, @@ -64,6 +69,18 @@ impl TestEnvironment { self } + fn with_port_owner(mut self, port: u16, pid: u32, command: &str) -> Self { + self.listening_ports.insert(port); + self.low_port_owners + .entry(port) + .or_default() + .push(LowPortOwner { + pid, + command: command.to_owned(), + }); + self + } + fn with_active_pf_read_failing_when_unloaded(mut self) -> Self { self.active_pf_read_fails_when_unloaded = true; self @@ -130,8 +147,17 @@ impl Environment for TestEnvironment { !self.listening_ports.contains(&port) } - fn loopback_tcp_listener_ports(&self) -> Result, platform::PlatformError> { - Ok(self.listening_ports.clone()) + fn inspect_low_ports(&self) -> Result { + Ok(LowPortInspection { + ports: [80, 443] + .into_iter() + .map(|port| LowPortState { + port, + available: !self.listening_ports.contains(&port), + owners: self.low_port_owners.get(&port).cloned().unwrap_or_default(), + }) + .collect(), + }) } fn install_pf_redirects( @@ -548,7 +574,6 @@ fn ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts() assert_eq!(output.exit_code, ExitCode::FAILURE); assert!(output.stderr.is_empty()); - assert_no_privileged_guidance(&output.stdout); assert!(read_optional_file(&paths.pf_anchor_config())?.is_none()); assert!(read_optional_file(&paths.pf_conf_reference_config())?.is_none()); @@ -559,6 +584,70 @@ fn ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts() Ok(()) } +#[test] +fn ports_install_names_low_port_owners() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let environment = TestEnvironment::new( + &tempdir.path().join("home"), + &tempdir.path().join("work"), + &tempdir.path().join("pf.anchor"), + &tempdir.path().join("pf.conf"), + ) + .with_port_owner(80, 412, "nginx") + .with_port_owner(443, 501, "Python"); + let output = run_pv(&["ports:install"], &environment)?; + assert_eq!(output.exit_code, ExitCode::FAILURE); + assert!(environment.operations.borrow().is_empty()); + assert_debug_snapshot!(output); + Ok(()) +} + +#[test] +fn ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match() -> anyhow::Result<()> +{ + let tempdir = tempdir()?; + let home = tempdir.path().join("home"); + let system_anchor_path = tempdir.path().join("pf.anchor"); + let system_pf_conf_path = tempdir.path().join("pf.conf"); + let mut environment = TestEnvironment::new( + &home, + &tempdir.path().join("work"), + &system_anchor_path, + &system_pf_conf_path, + ); + assert_eq!( + run_pv(&["ports:install"], &environment)?.exit_code, + ExitCode::SUCCESS + ); + environment.operations.borrow_mut().clear(); + let paths = pv_paths(&home); + let assignments_before = Database::open(&paths)?.assigned_ports()?; + let prepared_before = read_required_file(&paths.pf_anchor_config())?; + let system_before = read_required_file(&system_anchor_path)?; + let inspections_before = *environment.active_pf_inspections.borrow(); + environment = environment.with_port_owner(80, 412, "nginx"); + + let output = run_pv(&["ports:install"], &environment)?; + + assert_eq!(output.exit_code, ExitCode::FAILURE); + assert!(environment.operations.borrow().is_empty()); + assert_eq!( + *environment.active_pf_inspections.borrow(), + inspections_before + ); + assert_eq!( + Database::open(&paths)?.assigned_ports()?, + assignments_before + ); + assert_eq!( + read_required_file(&paths.pf_anchor_config())?, + prepared_before + ); + assert_eq!(read_required_file(&system_anchor_path)?, system_before); + assert_debug_snapshot!(output); + Ok(()) +} + #[test] fn ports_install_preserves_existing_gateway_assignments_after_later_failure() -> anyhow::Result<()> { diff --git a/crates/cli/tests/presentation.rs b/crates/cli/tests/presentation.rs index c34454e3..4d1936b7 100644 --- a/crates/cli/tests/presentation.rs +++ b/crates/cli/tests/presentation.rs @@ -59,6 +59,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, key: &str) -> Option { (key == "NO_COLOR" && self.terminals.no_color_env).then(|| OsString::from("1")) } diff --git a/crates/cli/tests/project_env.rs b/crates/cli/tests/project_env.rs index 1e09d452..00820380 100644 --- a/crates/cli/tests/project_env.rs +++ b/crates/cli/tests/project_env.rs @@ -31,6 +31,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/project_open.rs b/crates/cli/tests/project_open.rs index 761ccbad..ff18f22f 100644 --- a/crates/cli/tests/project_open.rs +++ b/crates/cli/tests/project_open.rs @@ -52,6 +52,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/project_unlink.rs b/crates/cli/tests/project_unlink.rs index 37fcaa6a..973028a1 100644 --- a/crates/cli/tests/project_unlink.rs +++ b/crates/cli/tests/project_unlink.rs @@ -25,6 +25,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/rustfs.rs b/crates/cli/tests/rustfs.rs index 7a160b7e..959aaa6f 100644 --- a/crates/cli/tests/rustfs.rs +++ b/crates/cli/tests/rustfs.rs @@ -33,6 +33,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/setup.rs b/crates/cli/tests/setup.rs index 0703f7a7..d6158974 100644 --- a/crates/cli/tests/setup.rs +++ b/crates/cli/tests/setup.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeSet, VecDeque}; +use std::collections::VecDeque; use std::ffi::OsString; use std::io::{self, BufRead, BufReader, Write}; use std::os::unix::fs::PermissionsExt as _; @@ -15,13 +15,13 @@ use cli::{Environment, run_with_environment}; use insta::{assert_debug_snapshot, assert_snapshot}; use platform::{ HELPER_PROTOCOL_VERSION, KeychainCertificate, KeychainTrustResult, LAUNCH_AGENT_LABEL, - LaunchAgentConfig, LocalCaMetadata, PRIVILEGED_HELPER_VERSION, PfConfReference, - PfRedirectConfig, PrivilegedHelperStatus, ResolverConfig, generate_local_ca, + LaunchAgentConfig, LocalCaMetadata, LowPortInspection, LowPortState, PRIVILEGED_HELPER_VERSION, + PfConfReference, PfRedirectConfig, PrivilegedHelperStatus, ResolverConfig, generate_local_ca, }; use resources::{ResourceHttpClient, ResourcesError, TargetPlatform}; use serde_json::json; use sha2::{Digest, Sha256}; -use state::{Database, ManagedResourceDesiredState, PvPaths, StateError}; +use state::{Database, ManagedResourceDesiredState, PortOwner, PvPaths, StateError}; const MANIFEST_URL: &str = "https://artifacts.example.test/manifest.json"; @@ -115,6 +115,7 @@ struct TestEnvironment { helper_cleanup_warning: Mutex>, terminal_width: Mutex>, terminal_surfaces: Mutex>, + pf_apply_conflict: bool, } impl TestEnvironment { @@ -124,6 +125,7 @@ impl TestEnvironment { target_platform: TargetPlatform, ) -> Self { Self { + pf_apply_conflict: false, home: paths.home.as_std_path().to_path_buf(), current_dir: paths.current_dir.as_std_path().to_path_buf(), current_exe: paths.current_exe.as_std_path().to_path_buf(), @@ -357,8 +359,17 @@ impl Environment for TestEnvironment { true } - fn loopback_tcp_listener_ports(&self) -> Result, platform::PlatformError> { - Ok(BTreeSet::new()) + fn inspect_low_ports(&self) -> Result { + Ok(LowPortInspection { + ports: [80, 443] + .into_iter() + .map(|port| LowPortState { + port, + available: true, + owners: Vec::new(), + }) + .collect(), + }) } fn install_pf_redirects( @@ -368,6 +379,11 @@ impl Environment for TestEnvironment { system_anchor_path: &Utf8Path, system_pf_conf_path: &Utf8Path, ) -> Result<(), platform::PlatformError> { + if self.pf_apply_conflict { + return Err(platform::PlatformError::SystemIntegration( + "Loopback TCP port 80 is in use by nginx (pid 412).".to_owned(), + )); + } let anchor = state::fs::read_to_string(prepared_anchor_path) .map_err(|error| platform::PlatformError::SystemIntegration(error.to_string()))?; let reference = state::fs::read_to_string(prepared_reference_path) @@ -534,6 +550,34 @@ fn setup_no_path_configures_system_integrations_and_waits_for_reconciliation() - Ok(()) } +#[test] +fn setup_pf_apply_conflict_does_not_reinstall_the_helper() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let mut fixture = Fixture::new(tempdir.path()); + seed_online_setup_manifest(&fixture)?; + let environment = Arc::get_mut(&mut fixture.environment) + .ok_or_else(|| anyhow::anyhow!("setup fixture environment is shared"))?; + environment.pf_apply_conflict = true; + let output = run_pv(&["setup", "--no-path"], environment)?; + assert_eq!(output.exit_code, ExitCode::FAILURE); + let operations = environment.operations(); + assert!( + !operations + .iter() + .any(|operation| operation.starts_with("install helper ")) + ); + assert!( + !Database::open(&fixture.paths)? + .assigned_ports()? + .iter() + .any(|assignment| matches!(assignment.owner, PortOwner::Gateway(_))) + ); + with_normalized_tempdir(tempdir.path(), || { + assert_debug_snapshot!((output, operations)) + }); + Ok(()) +} + #[test] fn setup_records_default_resource_desired_tracks_before_reconciliation() -> anyhow::Result<()> { let tempdir = tempdir()?; @@ -975,10 +1019,10 @@ fn setup_installs_missing_helper_before_system_integrations() -> anyhow::Result< let operations = fixture.environment.operations(); assert_eq!(output.exit_code, ExitCode::SUCCESS); - assert!(output.stdout.contains("Installed privileged helper 1.0.0")); + assert!(output.stdout.contains("Installed privileged helper 2.0.0")); assert!(operations.first().is_some_and(|operation| { operation == &format!( - "install helper {}/bin/pv-helper prepared {}/home/.pv/config/helper version 1.0.0 protocol 1 sha256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "install helper {}/bin/pv-helper prepared {}/home/.pv/config/helper version 2.0.0 protocol 2 sha256 aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", tempdir.path(), tempdir.path() ) @@ -1018,7 +1062,7 @@ fn setup_repairs_from_active_release_helper_metadata() -> anyhow::Result<()> { let fixture = Fixture::new(tempdir.path()); seed_online_setup_manifest(&fixture)?; fixture.environment.set_helper_missing(); - let helper_bytes = b"pv helper 1.1.0\n"; + let helper_bytes = b"pv helper 2.1.0\n"; let helper_sha256 = format!("{:x}", Sha256::digest(helper_bytes)); let release_version = env!("CARGO_PKG_VERSION"); let app_source = tempdir.path().join("release-pv"); @@ -1030,7 +1074,7 @@ fn setup_repairs_from_active_release_helper_metadata() -> anyhow::Result<()> { state::fs::write_sensitive_file( &release_helper.with_file_name("pv-helper.json"), &format!( - "{{\n \"version\": \"1.1.0\",\n \"protocol_version\": 1,\n \"sha256\": \"{helper_sha256}\"\n}}\n" + "{{\n \"version\": \"2.1.0\",\n \"protocol_version\": 2,\n \"sha256\": \"{helper_sha256}\"\n}}\n" ), )?; layout.activate_release(release_version)?; @@ -1047,7 +1091,7 @@ fn setup_repairs_from_active_release_helper_metadata() -> anyhow::Result<()> { assert_eq!( operations.first(), Some(&format!( - "install helper {release_helper} prepared {}/config/helper version 1.1.0 protocol 1 sha256 {helper_sha256}", + "install helper {release_helper} prepared {}/config/helper version 2.1.0 protocol 2 sha256 {helper_sha256}", fixture.paths.root() )) ); @@ -1810,7 +1854,7 @@ fn seed_bundled_helper_metadata(fixture: &Fixture) -> anyhow::Result<()> { .with_file_name("pv-helper.json"); write_file( &path, - "{\n \"version\": \"1.0.0\",\n \"protocol_version\": 1,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", + "{\n \"version\": \"2.0.0\",\n \"protocol_version\": 2,\n \"sha256\": \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\"\n}\n", ) } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap index 7ea2e2f8..742eb32a 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_active_pf_redirects_are_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: low-port redirects are inactive\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP -, HTTPS -; observed: \n repair: pv ports:install\n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[fail] Port redirect config: low-port redirects are inactive\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP -, HTTPS -; observed: \n repair: pv ports:install\n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap index 242d7e20..0297172a 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_daemon_socket_is_stale.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: pv daemon:restart\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is present but daemon did not answer health check\n path: /home/.pv/run/pv.sock; error: I/O error: Socket operation on non-socket (os error 38)\n repair: pv daemon:restart\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap index c6850549..6d85d872 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_ca_trust_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: pv ca:trust\n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[fail] Local CA trust: local CA is not trusted in the System keychain\n fingerprint: \n repair: pv ca:trust\n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap index e53f7d30..e252e38e 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_when_system_resolver_is_missing.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: pv dns:install\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[fail] DNS config: system resolver config is missing\n path: /home/etc/resolver/test\n repair: pv dns:install\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap index befde8cf..f67dc3ce 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_fails_with_repair_commands.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: pv daemon:restart\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile system at : Gateway failed to start\n repair: pv daemon:restart\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: pv daemon:restart\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 8 passed, 0 warning(s), 3 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[fail] Daemon socket: daemon socket is missing\n path: /home/.pv/run/pv.sock\n repair: pv daemon:restart\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile system at : Gateway failed to start\n repair: pv daemon:restart\n[fail] Runtime states: 1 degraded or failed runtime observation(s)\n gateway Gateway failed to start\n repair: pv daemon:restart\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 8 passed, 0 warning(s), 3 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_failing.snap b/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_failing.snap index 42a95bef..5b55fd08 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_failing.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_failing.snap @@ -8,7 +8,7 @@ expression: failing.stdout SYSTEM ✓ State layout 9 PV-owned directories have user-only permissions ✓ Database read-only open succeeded; 12 migrations applied -✓ Privileged helper available at version 1.0.0 with protocol 1 +✓ Privileged helper available at version 2.0.0 with protocol 2 ROUTING ✓ DNS config system resolver uses port 35353 diff --git a/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_healthy.snap b/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_healthy.snap index 16fbbe9a..c0b5cf25 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_healthy.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_on_a_terminal_healthy.snap @@ -8,7 +8,7 @@ expression: healthy.stdout SYSTEM ✓ State layout 9 PV-owned directories have user-only permissions ✓ Database read-only open succeeded; 12 migrations applied -✓ Privileged helper available at version 1.0.0 with protocol 1 +✓ Privileged helper available at version 2.0.0 with protocol 2 ROUTING ✓ DNS config system resolver uses port 35353 diff --git a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap index 15b28a4e..189e7549 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_passes_when_required_checks_pass.snap @@ -9,7 +9,7 @@ expression: snapshot 0, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 11 passed, 0 warning(s), 0 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 11 passed, 0 warning(s), 0 failed\n", stderr: "", }, RunOutput { @@ -18,7 +18,7 @@ expression: snapshot 0, ), ), - stdout: "{\"sections\":[{\"title\":\"System\",\"checks\":[{\"status\":\"pass\",\"name\":\"State layout\",\"message\":\"9 PV-owned directories have user-only permissions\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Database\",\"message\":\"read-only open succeeded; 12 migrations applied\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Privileged helper\",\"message\":\"available at version 1.0.0 with protocol 1\",\"detail\":null,\"repair\":null}]},{\"title\":\"Routing\",\"checks\":[{\"status\":\"pass\",\"name\":\"DNS config\",\"message\":\"system resolver uses port 35353\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Port redirect config\",\"message\":\"low-port routing is active\",\"detail\":\"evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \",\"repair\":null,\"routing\":{\"state\":\"active\",\"evidence\":\"pfctl\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":48080,\"active_https_port\":48443,\"observed_at\":\"\"}},{\"status\":\"pass\",\"name\":\"Local CA trust\",\"message\":\"system trust matches fingerprint \",\"detail\":null,\"repair\":null}]},{\"title\":\"Daemon & jobs\",\"checks\":[{\"status\":\"pass\",\"name\":\"Daemon LaunchAgent\",\"message\":\"PV-owned LaunchAgent is installed\",\"detail\":\"path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\",\"repair\":null},{\"status\":\"pass\",\"name\":\"Daemon socket\",\"message\":\"daemon answered health check\",\"detail\":\"path: /home/.pv/run/pv.sock\",\"repair\":null},{\"status\":\"pass\",\"name\":\"Recent jobs\",\"message\":\"no unresolved failed jobs\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Runtime states\",\"message\":\"no degraded or failed runtime observations\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Artifact manifest cache\",\"message\":\"cached manifest is present\",\"detail\":\"path: /home/.pv/downloads/manifest.json\",\"repair\":null}]}]}\n", + stdout: "{\"sections\":[{\"title\":\"System\",\"checks\":[{\"status\":\"pass\",\"name\":\"State layout\",\"message\":\"9 PV-owned directories have user-only permissions\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Database\",\"message\":\"read-only open succeeded; 12 migrations applied\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Privileged helper\",\"message\":\"available at version 2.0.0 with protocol 2\",\"detail\":null,\"repair\":null}]},{\"title\":\"Routing\",\"checks\":[{\"status\":\"pass\",\"name\":\"DNS config\",\"message\":\"system resolver uses port 35353\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Port redirect config\",\"message\":\"low-port routing is active\",\"detail\":\"evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \",\"repair\":null,\"routing\":{\"state\":\"active\",\"evidence\":\"pfctl\",\"expected_http_port\":48080,\"expected_https_port\":48443,\"active_http_port\":48080,\"active_https_port\":48443,\"observed_at\":\"\"}},{\"status\":\"pass\",\"name\":\"Local CA trust\",\"message\":\"system trust matches fingerprint \",\"detail\":null,\"repair\":null}]},{\"title\":\"Daemon & jobs\",\"checks\":[{\"status\":\"pass\",\"name\":\"Daemon LaunchAgent\",\"message\":\"PV-owned LaunchAgent is installed\",\"detail\":\"path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\",\"repair\":null},{\"status\":\"pass\",\"name\":\"Daemon socket\",\"message\":\"daemon answered health check\",\"detail\":\"path: /home/.pv/run/pv.sock\",\"repair\":null},{\"status\":\"pass\",\"name\":\"Recent jobs\",\"message\":\"no unresolved failed jobs\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Runtime states\",\"message\":\"no degraded or failed runtime observations\",\"detail\":null,\"repair\":null},{\"status\":\"pass\",\"name\":\"Artifact manifest cache\",\"message\":\"cached manifest is present\",\"detail\":\"path: /home/.pv/downloads/manifest.json\",\"repair\":null}]}]}\n", stderr: "", }, ) diff --git a/crates/cli/tests/snapshots/doctor__doctor_tracks_failure_repair_and_identical_recurrence.snap b/crates/cli/tests/snapshots/doctor__doctor_tracks_failure_repair_and_identical_recurrence.snap index 18e6b0ab..54e1f670 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_tracks_failure_repair_and_identical_recurrence.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_tracks_failure_repair_and_identical_recurrence.snap @@ -9,7 +9,7 @@ expression: snapshot 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile project:project_1 at : Gateway failed to start\n repair: pv daemon:restart\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile project:project_1 at : Gateway failed to start\n repair: pv daemon:restart\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", }, RunOutput { @@ -18,7 +18,7 @@ expression: snapshot 0, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 11 passed, 0 warning(s), 0 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 11 passed, 0 warning(s), 0 failed\n", stderr: "", }, RunOutput { @@ -27,7 +27,7 @@ expression: snapshot 1, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile project:project_1 at : Gateway failed to start\n repair: pv daemon:restart\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[fail] Recent jobs: 1 unresolved failed job(s)\n reconcile project:project_1 at : Gateway failed to start\n repair: pv daemon:restart\n[pass] Runtime states: no degraded or failed runtime observations\n[pass] Artifact manifest cache: cached manifest is present\n path: /home/.pv/downloads/manifest.json\nSummary: 10 passed, 0 warning(s), 1 failed\n", stderr: "", }, ) diff --git a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap index f207b587..9da1379e 100644 --- a/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap +++ b/crates/cli/tests/snapshots/doctor__doctor_warnings_do_not_fail.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 1.0.0 with protocol 1\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: pv setup\nSummary: 10 passed, 1 warning(s), 0 failed\n", + stdout: "PV doctor\n\nSystem\n[pass] State layout: 9 PV-owned directories have user-only permissions\n[pass] Database: read-only open succeeded; 12 migrations applied\n[pass] Privileged helper: available at version 2.0.0 with protocol 2\n\nRouting\n[pass] DNS config: system resolver uses port 35353\n[pass] Port redirect config: low-port routing is active\n evidence: pfctl; expected: HTTP 48080, HTTPS 48443; active: HTTP 48080, HTTPS 48443; observed: \n[pass] Local CA trust: system trust matches fingerprint \n\nDaemon & jobs\n[pass] Daemon LaunchAgent: PV-owned LaunchAgent is installed\n path: /home/Library/LaunchAgents/com.prvious.pv.daemon.plist\n[pass] Daemon socket: daemon answered health check\n path: /home/.pv/run/pv.sock\n[pass] Recent jobs: no unresolved failed jobs\n[pass] Runtime states: no degraded or failed runtime observations\n[warn] Artifact manifest cache: cached artifact manifest is missing\n path: /home/.pv/downloads/manifest.json\n repair: pv setup\nSummary: 10 passed, 1 warning(s), 0 failed\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap b/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap index 0b6a4d87..1ee8ca31 100644 --- a/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap +++ b/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "Port redirect preparation failed\n Loopback TCP port 80 already has a listener.\n Stop the conflicting service, then run `pv ports:install` again.\n", + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use; PV could not identify the process.\n find it: sudo lsof -nP -iTCP:80 -sTCP:LISTEN\n Stop the conflicting service, then run `pv ports:install` again.\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap b/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap new file mode 100644 index 00000000..81652f69 --- /dev/null +++ b/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap @@ -0,0 +1,13 @@ +--- +source: crates/cli/tests/ports.rs +expression: output +--- +RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use by nginx (pid 412).\n Loopback TCP port 443 is in use by Python (pid 501).\n Stop the conflicting service, then run `pv ports:install` again.\n", + stderr: "", +} diff --git a/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap b/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap new file mode 100644 index 00000000..6df70d56 --- /dev/null +++ b/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap @@ -0,0 +1,13 @@ +--- +source: crates/cli/tests/ports.rs +expression: output +--- +RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use by nginx (pid 412).\n Stop the conflicting service, then run `pv ports:install` again.\n", + stderr: "", +} diff --git a/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_plain.snap b/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_plain.snap index 8932c993..9991b527 100644 --- a/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_plain.snap +++ b/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_plain.snap @@ -4,7 +4,7 @@ expression: output.stdout --- PV setup Ensured PV state layout: /home/.pv -Privileged helper: current 1.0.0 (protocol 1) +Privileged helper: current 2.0.0 (protocol 2) Shell profile integration skipped by --no-path. Run `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration. Prepared PV DNS resolver config diff --git a/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_terminal.snap b/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_terminal.snap index 28b2408a..3418d81c 100644 --- a/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_terminal.snap +++ b/crates/cli/tests/snapshots/setup__setup_manifest_missing_default_terminal.snap @@ -9,7 +9,7 @@ expression: output.stdout ◇ Ensured PV state layout: /home/.pv │ ✓ Administrator helper installed -│ 1.0.0 (protocol 1) +│ 2.0.0 (protocol 2) │ ○ Shell profile integration skipped by --no-path. │ ↳ Activate PV manually in this terminal: diff --git a/crates/cli/tests/snapshots/setup__setup_no_path_configures_system_integrations_and_waits_for_reconciliation.snap b/crates/cli/tests/snapshots/setup__setup_no_path_configures_system_integrations_and_waits_for_reconciliation.snap index ca0dbc4a..0562ea6c 100644 --- a/crates/cli/tests/snapshots/setup__setup_no_path_configures_system_integrations_and_waits_for_reconciliation.snap +++ b/crates/cli/tests/snapshots/setup__setup_no_path_configures_system_integrations_and_waits_for_reconciliation.snap @@ -9,7 +9,7 @@ expression: "(output, PfRedirectConfig::parse_anchor(&prepared_anchor),\nPfConfR 0, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 1.0.0 (protocol 1)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\nReconciliation slot acquired after <1s\n", }, Some( diff --git a/crates/cli/tests/snapshots/setup__setup_non_interactive_fails_before_shell_profile_mutation.snap b/crates/cli/tests/snapshots/setup__setup_non_interactive_fails_before_shell_profile_mutation.snap index 53c8120d..ef88be80 100644 --- a/crates/cli/tests/snapshots/setup__setup_non_interactive_fails_before_shell_profile_mutation.snap +++ b/crates/cli/tests/snapshots/setup__setup_non_interactive_fails_before_shell_profile_mutation.snap @@ -9,7 +9,7 @@ expression: "(output, profile_after_setup, fixture.environment.operations())" 1, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 1.0.0 (protocol 1)\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\n", stderr: "error: Shell profile integration requires update; rerun without --non-interactive or use --no-path: /home/.zprofile\n", }, "export EXISTING=1\n", diff --git a/crates/cli/tests/snapshots/setup__setup_on_a_terminal.snap b/crates/cli/tests/snapshots/setup__setup_on_a_terminal.snap index ab989473..faff1cc0 100644 --- a/crates/cli/tests/snapshots/setup__setup_on_a_terminal.snap +++ b/crates/cli/tests/snapshots/setup__setup_on_a_terminal.snap @@ -9,7 +9,7 @@ expression: setup.stdout ◇ Ensured PV state layout: /home/.pv │ ✓ Administrator helper installed -│ 1.0.0 (protocol 1) +│ 2.0.0 (protocol 2) │ ◇ Backed up shell profile: /home/.zprofile..pv.bak │ diff --git a/crates/cli/tests/snapshots/setup__setup_pf_apply_conflict_does_not_reinstall_the_helper.snap b/crates/cli/tests/snapshots/setup__setup_pf_apply_conflict_does_not_reinstall_the_helper.snap new file mode 100644 index 00000000..efe7d565 --- /dev/null +++ b/crates/cli/tests/snapshots/setup__setup_pf_apply_conflict_does_not_reinstall_the_helper.snap @@ -0,0 +1,18 @@ +--- +source: crates/cli/tests/setup.rs +expression: "(output, operations)" +--- +( + RunOutput { + exit_code: ExitCode( + unix_exit_status( + 1, + ), + ), + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nPV stopped during port redirect setup.\n", + stderr: "error: system integration operation failed: Loopback TCP port 80 is in use by nginx (pid 412).\n", + }, + [ + "install resolver /home/.pv/config/resolver/test -> /etc/resolver/test", + ], +) diff --git a/crates/cli/tests/snapshots/setup__setup_repairs_from_active_release_helper_metadata.snap b/crates/cli/tests/snapshots/setup__setup_repairs_from_active_release_helper_metadata.snap index 1f43488d..0d508614 100644 --- a/crates/cli/tests/snapshots/setup__setup_repairs_from_active_release_helper_metadata.snap +++ b/crates/cli/tests/snapshots/setup__setup_repairs_from_active_release_helper_metadata.snap @@ -9,11 +9,11 @@ expression: "(output, operations)" 0, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nInstalled privileged helper 1.1.0 (protocol 1)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nInstalled privileged helper 2.1.0 (protocol 2)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\nReconciliation slot acquired after <1s\n", }, [ - "install helper /home/.pv/bin/releases/0.2.5/pv-helper prepared /home/.pv/config/helper version 1.1.0 protocol 1 sha256 ", + "install helper /home/.pv/bin/releases/0.2.5/pv-helper prepared /home/.pv/config/helper version 2.1.0 protocol 2 sha256 ", "install resolver /home/.pv/config/resolver/test -> /etc/resolver/test", "install pf /home/.pv/config/pf/com.prvious.pv /home/.pv/config/pf/pf.conf -> /etc/pf.anchors/com.prvious.pv /etc/pf.conf", "trust ", diff --git a/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step.snap b/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step.snap index b20b876b..531921f4 100644 --- a/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step.snap +++ b/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step.snap @@ -9,7 +9,7 @@ expression: decorated.stdout ◇ Ensured PV state layout: /home/.pv │ ✓ Administrator helper installed -│ 1.0.0 (protocol 1) +│ 2.0.0 (protocol 2) │ ○ Shell profile integration skipped by --no-path. │ ↳ Activate PV manually in this terminal: diff --git a/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step_plain.snap b/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step_plain.snap index 4675ee83..9e2a3b77 100644 --- a/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step_plain.snap +++ b/crates/cli/tests/snapshots/setup__setup_stops_at_a_failed_required_step_plain.snap @@ -4,7 +4,7 @@ expression: plain.stdout --- PV setup Ensured PV state layout: /home/.pv -Privileged helper: current 1.0.0 (protocol 1) +Privileged helper: current 2.0.0 (protocol 2) Shell profile integration skipped by --no-path. Run `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration. Prepared PV DNS resolver config diff --git a/crates/cli/tests/snapshots/setup__setup_uses_cached_manifest_with_warning_when_refresh_fails.snap b/crates/cli/tests/snapshots/setup__setup_uses_cached_manifest_with_warning_when_refresh_fails.snap index fe486119..425307b6 100644 --- a/crates/cli/tests/snapshots/setup__setup_uses_cached_manifest_with_warning_when_refresh_fails.snap +++ b/crates/cli/tests/snapshots/setup__setup_uses_cached_manifest_with_warning_when_refresh_fails.snap @@ -9,7 +9,7 @@ expression: "(output, fixture.environment.operations())" 0, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 1.0.0 (protocol 1)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\nShell profile integration skipped by --no-path.\nRun `pv env --shell zsh`, `pv env --shell bash`, or `pv env --shell fish` for manual shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", stderr: "warning: artifact manifest refresh failed (HTTP request failed for `https://artifacts.example.test/manifest.json`: offline); using cached manifest at \"/home/.pv/downloads/manifest.json\"\nWaiting for the reconciliation slot (elapsed: 0s)\nReconciliation slot acquired after <1s\n", }, [ diff --git a/crates/cli/tests/snapshots/setup__setup_yes_creates_and_uninstall_removes_shell_profile_block.snap b/crates/cli/tests/snapshots/setup__setup_yes_creates_and_uninstall_removes_shell_profile_block.snap index 931136e7..32a6f7be 100644 --- a/crates/cli/tests/snapshots/setup__setup_yes_creates_and_uninstall_removes_shell_profile_block.snap +++ b/crates/cli/tests/snapshots/setup__setup_yes_creates_and_uninstall_removes_shell_profile_block.snap @@ -9,7 +9,7 @@ expression: "(setup, profile_after_setup, second_setup, profile_after_second_set 0, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 1.0.0 (protocol 1)\nBacked up shell profile: /home/.zprofile..pv.bak\nUpdated shell profile integration: /home/.zprofile\nOpen a new terminal, or run `pv env --shell zsh` for current-session shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\nBacked up shell profile: /home/.zprofile..pv.bak\nUpdated shell profile integration: /home/.zprofile\nOpen a new terminal, or run `pv env --shell zsh` for current-session shell integration.\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nInstalled system resolver config: /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nInstalled system pf redirect config\nPrepared PV local CA\n certificate: /home/.pv/certificates/ca.pem\n private key: /home/.pv/certificates/ca-key.pem\n fingerprint: \nSystem keychain trust: not trusted\n fingerprint: \nTrusted PV local CA in the System keychain.\nLaunchAgent installed: /Library/LaunchAgents/com.prvious.pv.daemon.plist\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\nReconciliation slot acquired after <1s\n", }, "export EXISTING=1\n\n# >>> PV ENV\nif [ -x \"$HOME/.pv/bin/pv\" ]; then\n eval \"$(\"$HOME/.pv/bin/pv\" env --shell zsh)\"\nfi\n# <<< PV ENV\n", @@ -19,7 +19,7 @@ expression: "(setup, profile_after_setup, second_setup, profile_after_second_set 0, ), ), - stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 1.0.0 (protocol 1)\nShell profile integration already current: /home/.zprofile\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nSystem resolver config already matches PV on port : /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nSystem pf redirect config already matches PV\nPrepared PV local CA\n existing local CA is current\nSystem keychain trust: current\n fingerprint: \nSystem keychain trust already matches PV.\nLaunchAgent already installed\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", + stdout: "PV setup\nEnsured PV state layout: /home/.pv\nPrivileged helper: current 2.0.0 (protocol 2)\nShell profile integration already current: /home/.zprofile\nPrepared PV DNS resolver config\n path: /home/.pv/config/resolver/test\n DNS resolver port: \nSystem resolver config already matches PV on port : /etc/resolver/test\nPrepared PV port redirect config\n anchor path: /home/.pv/config/pf/com.prvious.pv\n pf.conf reference path: /home/.pv/config/pf/pf.conf\n HTTP redirect: 127.0.0.1:80 -> 127.0.0.1:48080\n HTTPS redirect: 127.0.0.1:443 -> 127.0.0.1:48443\nSystem pf redirect config already matches PV\nPrepared PV local CA\n existing local CA is current\nSystem keychain trust: current\n fingerprint: \nSystem keychain trust already matches PV.\nLaunchAgent already installed\nDaemon started\nDaemon healthy\nSystem reconciliation completed: stub job completed\nPV setup complete\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\nReconciliation slot acquired after <1s\n", }, "export EXISTING=1\n\n# >>> PV ENV\nif [ -x \"$HOME/.pv/bin/pv\" ]; then\n eval \"$(\"$HOME/.pv/bin/pv\" env --shell zsh)\"\nfi\n# <<< PV ENV\n", diff --git a/crates/cli/tests/snapshots/update__update_tests__update_accepts_protocol_mismatch_health_after_activation.snap b/crates/cli/tests/snapshots/update__update_tests__update_accepts_protocol_mismatch_health_after_activation.snap index a1faed46..0bf05973 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_accepts_protocol_mismatch_health_after_activation.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_accepts_protocol_mismatch_health_after_activation.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV update\nPrivileged helper: current 1.0.0 (protocol 1)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", + stdout: "PV update\nPrivileged helper: current 2.0.0 (protocol 2)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_json_reports_app_and_managed_resource_updates.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_json_reports_app_and_managed_resource_updates.snap index 50e765b7..3fc1e8e9 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_json_reports_app_and_managed_resource_updates.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_json_reports_app_and_managed_resource_updates.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "{\"app\":{\"status\":\"update_available\",\"current_version\":\"0.2.5\",\"latest_version\":\"0.3.0\",\"platform\":\"darwin-arm64\",\"asset\":{\"url\":\"https://downloads.example.test/pv/0.3.0/pv-darwin-arm64\",\"sha256\":\"\",\"size\":12345678},\"helper\":{\"status\":\"current\",\"current_version\":\"1.0.0\",\"latest_version\":\"1.0.0\",\"current_protocol_version\":1,\"latest_protocol_version\":1,\"url\":\"https://downloads.example.test/pv/0.3.0/pv-helper-1.0.0-darwin-arm64\",\"sha256\":\"\",\"size\":16,\"reason\":null},\"reason\":null},\"managed_resources\":[{\"status\":\"update_available\",\"resource\":\"redis\",\"track\":\"8.8\",\"current_artifact_version\":\"8.8.0-pv1\",\"current_artifact_path\":\"/resources/redis/8.8/releases/8.8.0-pv1\",\"latest_artifact_version\":\"8.8.1-pv1\",\"current_revocation\":null,\"latest_revocation\":null,\"blocked_by\":null,\"reason\":null}]}\n", + stdout: "{\"app\":{\"status\":\"update_available\",\"current_version\":\"0.2.5\",\"latest_version\":\"0.3.0\",\"platform\":\"darwin-arm64\",\"asset\":{\"url\":\"https://downloads.example.test/pv/0.3.0/pv-darwin-arm64\",\"sha256\":\"\",\"size\":12345678},\"helper\":{\"status\":\"current\",\"current_version\":\"2.0.0\",\"latest_version\":\"2.0.0\",\"current_protocol_version\":2,\"latest_protocol_version\":2,\"url\":\"https://downloads.example.test/pv/0.3.0/pv-helper-2.0.0-darwin-arm64\",\"sha256\":\"\",\"size\":16,\"reason\":null},\"reason\":null},\"managed_resources\":[{\"status\":\"update_available\",\"resource\":\"redis\",\"track\":\"8.8\",\"current_artifact_version\":\"8.8.0-pv1\",\"current_artifact_path\":\"/resources/redis/8.8/releases/8.8.0-pv1\",\"latest_artifact_version\":\"8.8.1-pv1\",\"current_revocation\":null,\"latest_revocation\":null,\"blocked_by\":null,\"reason\":null}]}\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_on_a_terminal_renders_status_rows.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_on_a_terminal_renders_status_rows.snap index 0ed6c7b9..2a485468 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_on_a_terminal_renders_status_rows.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_on_a_terminal_renders_status_rows.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "[pv] update --check v0.2.5\n────────────────────────────────────────────────────────────────────────────────────────────────────\n↑ PV application update 0.2.5 → 0.3.0 (darwin-arm64)\n✓ Privileged helper current 1.0.0\n protocol 1\n\nMANAGED RESOURCES\n↑ redis 8.8 8.8.0-pv1 → 8.8.1-pv1\n\n↑ 2 updates available\n ↳ run pv update\n", + stdout: "[pv] update --check v0.2.5\n────────────────────────────────────────────────────────────────────────────────────────────────────\n↑ PV application update 0.2.5 → 0.3.0 (darwin-arm64)\n✓ Privileged helper current 2.0.0\n protocol 2\n\nMANAGED RESOURCES\n↑ redis 8.8 8.8.0-pv1 → 8.8.1-pv1\n\n↑ 2 updates available\n ↳ run pv update\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_app_and_managed_resource_updates.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_app_and_managed_resource_updates.snap index 6eb7eee8..6b830aa2 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_app_and_managed_resource_updates.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_app_and_managed_resource_updates.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources:\n redis 8.8: update available 8.8.0-pv1 -> 8.8.1-pv1\n", + stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources:\n redis 8.8: update available 8.8.0-pv1 -> 8.8.1-pv1\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_blocked_managed_resource.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_blocked_managed_resource.snap index 46c02f78..f1f1fc0b 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_blocked_managed_resource.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_blocked_managed_resource.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources:\n redis 8.8: blocked 8.8.0-pv1 (requires PV 0.5.0, current PV 0.1.0)\n", + stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources:\n redis 8.8: blocked 8.8.0-pv1 (requires PV 0.5.0, current PV 0.1.0)\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_current_managed_resource.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_current_managed_resource.snap index 5f357a9a..2581aeb8 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_current_managed_resource.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_current_managed_resource.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources:\n redis 8.8: current 8.8.0-pv1\n", + stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources:\n redis 8.8: current 8.8.0-pv1\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_revoked_managed_resource.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_revoked_managed_resource.snap index 5955def9..69c9edd3 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_revoked_managed_resource.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_revoked_managed_resource.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources:\n redis 8.8: revoked 8.8.0-pv1 (security vulnerability); replacement 8.8.1-pv1\n", + stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources:\n redis 8.8: revoked 8.8.0-pv1 (security vulnerability); replacement 8.8.1-pv1\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_unavailable_managed_resource.snap b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_unavailable_managed_resource.snap index b8d5d47f..017fd411 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_check_reports_unavailable_managed_resource.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_check_reports_unavailable_managed_resource.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources:\n redis 8.8: unavailable 8.8.0-pv1 (no installable artifact)\n", + stdout: "PV application: update available 0.2.5 -> 0.3.0 (darwin-arm64)\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources:\n redis 8.8: unavailable 8.8.0-pv1 (no installable artifact)\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_downloads_and_activates_new_app_then_reexecs_managed_resource_continuation.snap b/crates/cli/tests/snapshots/update__update_tests__update_downloads_and_activates_new_app_then_reexecs_managed_resource_continuation.snap index e61b7fc5..a26eb7ac 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_downloads_and_activates_new_app_then_reexecs_managed_resource_continuation.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_downloads_and_activates_new_app_then_reexecs_managed_resource_continuation.snap @@ -9,7 +9,7 @@ expression: snapshot 0, ), ), - stdout: "PV update\nPrivileged helper: current 1.0.0 (protocol 1)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", + stdout: "PV update\nPrivileged helper: current 2.0.0 (protocol 2)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", stderr: "", }, [ diff --git a/crates/cli/tests/snapshots/update__update_tests__update_normalizes_stale_launch_agent_without_restarting_when_current.snap b/crates/cli/tests/snapshots/update__update_tests__update_normalizes_stale_launch_agent_without_restarting_when_current.snap index 0af7a18e..c5ac1de7 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_normalizes_stale_launch_agent_without_restarting_when_current.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_normalizes_stale_launch_agent_without_restarting_when_current.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV update\nPV application: current 0.2.5\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources: current\n", + stdout: "PV update\nPV application: current 0.2.5\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources: current\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\n", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_forwards_no_color_to_the_continuation.snap b/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_forwards_no_color_to_the_continuation.snap index bd65797a..a3d64159 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_forwards_no_color_to_the_continuation.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_forwards_no_color_to_the_continuation.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "[pv] update\n\n┌ PV update\n│\n◇ Privileged helper: current 1.0.0 (protocol 1)\n│\n◇ PV application: updated 0.2.5 -> 0.3.0\n│\n◇ Daemon restarted and healthy\n", + stdout: "[pv] update\n\n┌ PV update\n│\n◇ Privileged helper: current 2.0.0 (protocol 2)\n│\n◇ PV application: updated 0.2.5 -> 0.3.0\n│\n◇ Daemon restarted and healthy\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_renders_a_flow.snap b/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_renders_a_flow.snap index c405b9b3..0bdf3aa1 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_renders_a_flow.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_on_a_terminal_renders_a_flow.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "[pv] update\n\n┌ PV update\n│\n◇ PV application: current 0.2.5\n│\n◇ Privileged helper: current 1.0.0 (protocol 1)\n│\n◇ Managed Resources: updated 2 artifact(s)\n│\n└ Managed Resources reconciled: system ok\n", + stdout: "[pv] update\n\n┌ PV update\n│\n◇ PV application: current 0.2.5\n│\n◇ Privileged helper: current 2.0.0 (protocol 2)\n│\n◇ Managed Resources: updated 2 artifact(s)\n│\n└ Managed Resources reconciled: system ok\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\n", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_rejects_registered_helper_identity_that_cannot_be_rolled_back.snap b/crates/cli/tests/snapshots/update__update_tests__update_rejects_registered_helper_identity_that_cannot_be_rolled_back.snap index 3d2cc639..33079fe7 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_rejects_registered_helper_identity_that_cannot_be_rolled_back.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_rejects_registered_helper_identity_that_cannot_be_rolled_back.snap @@ -9,5 +9,5 @@ RunOutput { ), ), stdout: "PV update\n", - stderr: "error: privileged-helper rollback preflight failed: registered helper 0.9.0 (protocol 1) does not match active release helper 1.0.0 (protocol 1); run `pv setup` before updating\n", + stderr: "error: privileged-helper rollback preflight failed: registered helper 0.9.0 (protocol 2) does not match active release helper 2.0.0 (protocol 2); run `pv setup` before updating\n", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_reports_current_app_without_restarting_daemon.snap b/crates/cli/tests/snapshots/update__update_tests__update_reports_current_app_without_restarting_daemon.snap index 0af7a18e..c5ac1de7 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_reports_current_app_without_restarting_daemon.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_reports_current_app_without_restarting_daemon.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV update\nPV application: current 0.2.5\nPrivileged helper: current 1.0.0 (protocol 1)\nManaged Resources: current\n", + stdout: "PV update\nPV application: current 0.2.5\nPrivileged helper: current 2.0.0 (protocol 2)\nManaged Resources: current\n", stderr: "Waiting for the reconciliation slot (elapsed: 0s)\n", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_reports_reexec_failure_without_rolling_back_updated_app.snap b/crates/cli/tests/snapshots/update__update_tests__update_reports_reexec_failure_without_rolling_back_updated_app.snap index a5330d43..0a6a3890 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_reports_reexec_failure_without_rolling_back_updated_app.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_reports_reexec_failure_without_rolling_back_updated_app.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "PV update\nPrivileged helper: current 1.0.0 (protocol 1)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", + stdout: "PV update\nPrivileged helper: current 2.0.0 (protocol 2)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", stderr: "error: PV application update succeeded, but Managed Resource update continuation failed to start: exec failed. Run `pv update` again to update Managed Resources.\n", } diff --git a/crates/cli/tests/snapshots/update__update_tests__update_warns_when_pruning_old_app_release_fails.snap b/crates/cli/tests/snapshots/update__update_tests__update_warns_when_pruning_old_app_release_fails.snap index 408aebb2..3f325d95 100644 --- a/crates/cli/tests/snapshots/update__update_tests__update_warns_when_pruning_old_app_release_fails.snap +++ b/crates/cli/tests/snapshots/update__update_tests__update_warns_when_pruning_old_app_release_fails.snap @@ -8,6 +8,6 @@ RunOutput { 0, ), ), - stdout: "PV update\nPrivileged helper: current 1.0.0 (protocol 1)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", + stdout: "PV update\nPrivileged helper: current 2.0.0 (protocol 2)\nPV application: updated 0.2.5 -> 0.3.0\nDaemon restarted and healthy\n", stderr: "warning: failed to prune old PV app releases: filesystem error at \"/bin/releases/0.0.8\": Not a directory (os error 20)\n", } diff --git a/crates/cli/tests/status.rs b/crates/cli/tests/status.rs index 20c9ab81..2aa60eae 100644 --- a/crates/cli/tests/status.rs +++ b/crates/cli/tests/status.rs @@ -63,6 +63,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/support/resource_cli.rs b/crates/cli/tests/support/resource_cli.rs index d2bf5ca2..8a0b55ac 100644 --- a/crates/cli/tests/support/resource_cli.rs +++ b/crates/cli/tests/support/resource_cli.rs @@ -54,6 +54,10 @@ impl TestEnvironment { } impl Environment for TestEnvironment { + fn inspect_low_ports(&self) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } diff --git a/crates/cli/tests/update.rs b/crates/cli/tests/update.rs index 958146e9..7b9644d2 100644 --- a/crates/cli/tests/update.rs +++ b/crates/cli/tests/update.rs @@ -22,7 +22,8 @@ mod update_tests { use cli::{Environment, run_with_environment}; use insta::{Settings, assert_debug_snapshot}; use platform::{ - LAUNCH_AGENT_LABEL, LaunchAgentConfig, PRIVILEGED_HELPER_VERSION, PrivilegedHelperStatus, + HELPER_PROTOCOL_VERSION, LAUNCH_AGENT_LABEL, LaunchAgentConfig, PRIVILEGED_HELPER_VERSION, + PrivilegedHelperStatus, }; use resources::{ResourceHttpClient, ResourcesError}; use serde_json::json; @@ -215,6 +216,12 @@ mod update_tests { } impl Environment for TestEnvironment { + fn inspect_low_ports( + &self, + ) -> Result { + Err(platform::PlatformError::PrivilegedHelperUnavailable) + } + fn var_os(&self, _key: &str) -> Option { None } @@ -597,9 +604,12 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("\"protocol_version\": 1", "\"protocol_version\": 2") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ) + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)); let output = run_pv(&["update", "--check"], &environment)?; @@ -937,8 +947,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)) .with_missing_helper(); @@ -956,7 +966,7 @@ mod update_tests { assert!( output .stdout - .contains("Privileged helper: updated to 1.1.0 (protocol 1)") + .contains("Privileged helper: updated to 2.1.0 (protocol 2)") ); assert_eq!( serde_json::from_str::(&state::fs::read_to_string( @@ -965,8 +975,8 @@ mod update_tests { .with_file_name("pv-helper.json"), )?)?, json!({ - "version": "1.1.0", - "protocol_version": 1, + "version": "2.1.0", + "protocol_version": HELPER_PROTOCOL_VERSION, "sha256": HELPER_BINARY_SHA256, }) ); @@ -988,9 +998,12 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("\"protocol_version\": 1", "\"protocol_version\": 2") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ) + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)); let output = run_pv(&["update"], &environment)?; @@ -1104,8 +1117,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)) .with_helper_replacement_error("authentication cancelled"); @@ -1121,7 +1134,7 @@ mod update_tests { .app_release_helper(CURRENT_APP_VERSION) .with_file_name("pv-helper.json"), )?; - assert!(metadata.contains("\"version\": \"1.0.0\"")); + assert!(metadata.contains("\"version\": \"2.0.0\"")); assert_eq!(environment.operations().len(), 1); Ok(()) @@ -1141,8 +1154,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let release_helper = paths.app_release_helper(CURRENT_APP_VERSION); let release_helper_parent = release_helper .parent() @@ -1173,7 +1186,7 @@ mod update_tests { state::fs::path_is_file(&metadata_path)?, "output: {output:?}; operations: {operations:?}" ); - assert!(state::fs::read_to_string(&metadata_path)?.contains("\"version\": \"1.0.0\"")); + assert!(state::fs::read_to_string(&metadata_path)?.contains("\"version\": \"2.0.0\"")); assert_eq!(operations.len(), 2); assert!( state::fs::read_dir_paths(paths.downloads())? @@ -1204,7 +1217,10 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"protocol_version\": 1", "\"protocol_version\": 2"); + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)) .with_helper_status("2.0.0", 2); @@ -1243,8 +1259,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new(&home, ScriptedClient::new().with_text(&manifest)); let output = run_pv(&["update"], &environment)?; @@ -1257,7 +1273,7 @@ mod update_tests { ); let operations = environment.operations(); assert_eq!(operations.len(), 1); - assert!(operations[0].contains("downloads/pv-helper-1.1.0-")); + assert!(operations[0].contains("downloads/pv-helper-2.1.0-")); assert!(operations[0].ends_with(&format!("sha256 {HELPER_BINARY_SHA256}"))); assert!( state::fs::read_to_string( @@ -1265,7 +1281,7 @@ mod update_tests { .app_release_helper(CURRENT_APP_VERSION) .with_file_name("pv-helper.json") )? - .contains("\"version\": \"1.1.0\"") + .contains("\"version\": \"2.1.0\"") ); assert!(output.stdout.contains("app manifest 0.1.0 is older")); @@ -1388,9 +1404,12 @@ mod update_tests { write_launch_agent(&paths, &paths.active_pv_binary())?; let daemon = FakeDaemon::start(&paths, vec![health_response()])?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("\"protocol_version\": 1", "\"protocol_version\": 2") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ) + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -1414,8 +1433,8 @@ mod update_tests { .with_file_name("pv-helper.json") )?)?, json!({ - "version": "1.1.0", - "protocol_version": 2, + "version": "2.1.0", + "protocol_version": HELPER_PROTOCOL_VERSION + 1, "sha256": HELPER_BINARY_SHA256, }) ); @@ -1438,11 +1457,10 @@ mod update_tests { "command": "health" })] ); - assert!( - output - .stdout - .contains("Privileged helper: updated to 1.1.0 (protocol 2)") - ); + assert!(output.stdout.contains(&format!( + "Privileged helper: updated to 2.1.0 (protocol {})", + HELPER_PROTOCOL_VERSION + 1 + ))); Ok(()) } @@ -1457,9 +1475,12 @@ mod update_tests { write_launch_agent(&paths, &paths.active_pv_binary())?; let daemon = FakeDaemon::start(&paths, vec![health_response()])?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("\"protocol_version\": 1", "\"protocol_version\": 2") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ) + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -1976,8 +1997,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", u64::try_from(APP_BINARY.len())?, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"), + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"), ) .with_download(APP_BINARY), ); @@ -2016,8 +2037,8 @@ mod update_tests { ScriptedClient::new() .with_text( &app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"), + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"), ) .with_download_error(ResourcesError::HttpRequestFailed { url: APP_BINARY_URL.to_string(), @@ -2337,8 +2358,8 @@ mod update_tests { ], )?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -2400,8 +2421,8 @@ mod update_tests { Duration::from_millis(100), )?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -2476,9 +2497,12 @@ mod update_tests { vec![daemon_error_response("updated daemon boot failed")], )?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("\"protocol_version\": 1", "\"protocol_version\": 2") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace( + &format!("\"protocol_version\": {HELPER_PROTOCOL_VERSION}"), + &format!("\"protocol_version\": {}", HELPER_PROTOCOL_VERSION + 1), + ) + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -2499,8 +2523,8 @@ mod update_tests { assert_eq!( environment.helper_status.borrow().as_ref(), Some(&PrivilegedHelperStatus { - version: "1.1.0".to_string(), - protocol_version: 2, + version: "2.1.0".to_string(), + protocol_version: HELPER_PROTOCOL_VERSION + 1, owner_uid: 501, }) ); @@ -2538,8 +2562,8 @@ mod update_tests { vec![daemon_error_response("updated daemon boot failed")], )?; let manifest = app_manifest("0.3.0", APP_BINARY_SHA256, u64::try_from(APP_BINARY.len())?) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0"); + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0"); let environment = TestEnvironment::new( &home, ScriptedClient::new() @@ -3384,9 +3408,9 @@ mod update_tests { "sha256": "{sha256}", "size": {size}, "helper": {{ - "version": "1.0.0", - "protocol_version": 1, - "url": "https://downloads.example.test/pv/{version}/pv-helper-1.0.0-darwin-arm64", + "version": "2.0.0", + "protocol_version": {HELPER_PROTOCOL_VERSION}, + "url": "https://downloads.example.test/pv/{version}/pv-helper-2.0.0-darwin-arm64", "sha256": "{HELPER_BINARY_SHA256}", "size": {} }} @@ -3397,9 +3421,9 @@ mod update_tests { "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": {size}, "helper": {{ - "version": "1.0.0", - "protocol_version": 1, - "url": "https://downloads.example.test/pv/{version}/pv-helper-1.0.0-darwin-amd64", + "version": "2.0.0", + "protocol_version": {HELPER_PROTOCOL_VERSION}, + "url": "https://downloads.example.test/pv/{version}/pv-helper-2.0.0-darwin-amd64", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "size": {} }} @@ -3418,8 +3442,8 @@ mod update_tests { "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", 12_345_678, ) - .replace("\"version\": \"1.0.0\"", "\"version\": \"1.1.0\"") - .replace("pv-helper-1.0.0", "pv-helper-1.1.0") + .replace("\"version\": \"2.0.0\"", "\"version\": \"2.1.0\"") + .replace("pv-helper-2.0.0", "pv-helper-2.1.0") } const APP_MANIFEST: &str = r#" @@ -3436,9 +3460,9 @@ mod update_tests { "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "size": 12345678, "helper": { - "version": "1.0.0", - "protocol_version": 1, - "url": "https://downloads.example.test/pv/0.3.0/pv-helper-1.0.0-darwin-arm64", + "version": "2.0.0", + "protocol_version": 2, + "url": "https://downloads.example.test/pv/0.3.0/pv-helper-2.0.0-darwin-arm64", "sha256": "f15b9ec9f06fc9e7e92af6e7cdfe82ae574bdc11f09bf796e44280989b1378d2", "size": 16 } @@ -3449,9 +3473,9 @@ mod update_tests { "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "size": 12345678, "helper": { - "version": "1.0.0", - "protocol_version": 1, - "url": "https://downloads.example.test/pv/0.3.0/pv-helper-1.0.0-darwin-amd64", + "version": "2.0.0", + "protocol_version": 2, + "url": "https://downloads.example.test/pv/0.3.0/pv-helper-2.0.0-darwin-amd64", "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", "size": 16 } diff --git a/crates/platform/src/ca.rs b/crates/platform/src/ca.rs index c90cc294..18075c0c 100644 --- a/crates/platform/src/ca.rs +++ b/crates/platform/src/ca.rs @@ -433,7 +433,6 @@ fn repair_reason_from_ca_error(error: PlatformError) -> CaRepairReason { | PlatformError::PrivilegedHelperInstallation(_) | PlatformError::PrivilegedHelperAuthentication(_) => CaRepairReason::InvalidCaShape, #[cfg(target_os = "macos")] - PlatformError::ListenerInspection { .. } - | PlatformError::ProcessIdentityInspection { .. } => CaRepairReason::InvalidCaShape, + PlatformError::ProcessIdentityInspection { .. } => CaRepairReason::InvalidCaShape, } } diff --git a/crates/platform/src/capability.rs b/crates/platform/src/capability.rs index 304875a9..16f37509 100644 --- a/crates/platform/src/capability.rs +++ b/crates/platform/src/capability.rs @@ -7,7 +7,6 @@ pub enum PlatformCapability { BrowserHandoff, DaemonIpc, DaemonRegistration, - ListenerInspection, LowPortFrontend, ProcessContainment, ProcessInspection, @@ -22,7 +21,6 @@ impl PlatformCapability { Self::BrowserHandoff => "browser handoff", Self::DaemonIpc => "daemon IPC", Self::DaemonRegistration => "daemon registration", - Self::ListenerInspection => "listener inspection", Self::LowPortFrontend => "low-port frontend", Self::ProcessContainment => "process containment", Self::ProcessInspection => "process inspection", diff --git a/crates/platform/src/command.rs b/crates/platform/src/command.rs index af1f982a..1d47597c 100644 --- a/crates/platform/src/command.rs +++ b/crates/platform/src/command.rs @@ -48,6 +48,7 @@ pub(crate) fn run_system_command_output_with_timeout( program: &str, args: &[&str], wait: Duration, + empty_exit_code: Option, ) -> Result { let command = format!("{program} {}", args.join(" ")); let output = command_output_with_timeout(program, args, wait).map_err(|source| { @@ -57,6 +58,13 @@ pub(crate) fn run_system_command_output_with_timeout( } })?; + if empty_exit_code.is_some() + && output.status.code() == empty_exit_code + && output.stdout.is_empty() + && output.stderr.is_empty() + { + return Ok(String::new()); + } system_command_output(command, output) } @@ -235,6 +243,7 @@ mod tests { "/bin/sh", &["-c", "printf bounded"], Duration::from_secs(1), + None, )?; assert_eq!(output, "bounded"); @@ -242,12 +251,36 @@ mod tests { Ok(()) } + #[test] + fn bounded_command_accepts_only_the_requested_empty_exit_status() { + let verdicts = [ + ("empty exit 1", "exit 1"), + ("nonempty stdout", "printf owner; exit 1"), + ("nonempty stderr", "printf failure >&2; exit 1"), + ("different exit status", "exit 2"), + ] + .into_iter() + .map(|(name, script)| { + let result = run_system_command_output_with_timeout( + "/bin/sh", + &["-c", script], + Duration::from_secs(1), + Some(1), + ) + .map_err(|error| error.to_string()); + (name, result) + }) + .collect::>(); + insta::assert_debug_snapshot!(verdicts); + } + #[test] fn bounded_command_drains_large_output() -> anyhow::Result<()> { let output = run_system_command_output_with_timeout( "/bin/sh", &["-c", "yes e | head -c 262144 >&2; yes o | head -c 262144"], Duration::from_secs(5), + None, )?; assert_eq!(output.len(), 262_144); @@ -268,6 +301,7 @@ mod tests { pid_path.as_str(), ], Duration::from_millis(100), + None, ); let Err(PlatformError::SystemIntegrationCommand { source, .. }) = result else { diff --git a/crates/platform/src/error.rs b/crates/platform/src/error.rs index c7b12fbb..6907db0e 100644 --- a/crates/platform/src/error.rs +++ b/crates/platform/src/error.rs @@ -107,13 +107,6 @@ pub enum PlatformError { #[error("PV privileged helper authentication failed: {0}")] PrivilegedHelperAuthentication(String), - #[cfg(target_os = "macos")] - #[error("could not inspect TCP listeners: {source}")] - ListenerInspection { - #[source] - source: Box, - }, - #[cfg(target_os = "macos")] #[error("could not inspect process identity: {source}")] ProcessIdentityInspection { diff --git a/crates/platform/src/helper.rs b/crates/platform/src/helper.rs index 55b85069..2f0bc8f5 100644 --- a/crates/platform/src/helper.rs +++ b/crates/platform/src/helper.rs @@ -17,12 +17,12 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use crate::{ - ActivePfRedirectInspection, KeychainCertificate, PfRedirectConfig, PlatformError, - ResolverConfig, ResolverFileState, + ActivePfRedirectInspection, KeychainCertificate, LowPortInspection, PfRedirectConfig, + PlatformError, ResolverConfig, ResolverFileState, }; -pub const HELPER_PROTOCOL_VERSION: u32 = 1; -pub const PRIVILEGED_HELPER_VERSION: &str = "1.0.0"; +pub const HELPER_PROTOCOL_VERSION: u32 = 2; +pub const PRIVILEGED_HELPER_VERSION: &str = "2.0.0"; pub const HELPER_EXECUTABLE_PATH: &str = "/Library/PrivilegedHelperTools/com.prvious.pv.helper"; pub const HELPER_LAUNCH_DAEMON_PATH: &str = "/Library/LaunchDaemons/com.prvious.pv.helper.plist"; pub const HELPER_METADATA_PATH: &str = "/Library/Application Support/PV/helper.json"; @@ -152,9 +152,9 @@ enum HelperOperation { DnsInspect { expected_port: Option }, DnsApply { port: u16 }, DnsRemove, + LowPortInspect, PfInspect, PfApply { http_port: u16, https_port: u16 }, - PfReload, PfRemove, CaInspect, CaApply { fingerprint: String }, @@ -199,6 +199,7 @@ enum HelperPayload { Empty, Status(PrivilegedHelperStatus), ResolverState(ResolverFileState), + LowPorts(LowPortInspection), PfInspection(ActivePfRedirectInspection), CaCertificates(Vec), } @@ -239,6 +240,13 @@ impl PrivilegedHelperClient { expect_empty(self.call(HelperOperation::DnsRemove)?) } + pub fn inspect_low_ports(&self) -> Result { + match self.call(HelperOperation::LowPortInspect)? { + HelperPayload::LowPorts(inspection) => Ok(inspection), + payload => Err(unexpected_payload("low-port inspection", &payload)), + } + } + pub fn inspect_pf(&self) -> Result { match self.call(HelperOperation::PfInspect)? { HelperPayload::PfInspection(inspection) => Ok(inspection), @@ -253,10 +261,6 @@ impl PrivilegedHelperClient { })?) } - pub fn reload_pf(&self) -> Result<(), PlatformError> { - expect_empty(self.call(HelperOperation::PfReload)?) - } - pub fn remove_pf(&self) -> Result<(), PlatformError> { expect_empty(self.call(HelperOperation::PfRemove)?) } @@ -1539,6 +1543,9 @@ fn dispatch_request( crate::resolver::remove_resolver_config_privileged()?; Ok(HelperPayload::Empty) } + HelperOperation::LowPortInspect => Ok(HelperPayload::LowPorts( + crate::low_port::inspect_loopback_ports(&[80, 443])?, + )), HelperOperation::PfInspect => Ok(HelperPayload::PfInspection( crate::pf::inspect_active_pf_redirects_privileged()?, )), @@ -1553,15 +1560,21 @@ fn dispatch_request( message: "Gateway HTTP and HTTPS ports must be different".to_string(), }); } + let inspection = crate::low_port::inspect_loopback_ports(&[80, 443])?; + let conflicts = inspection + .ports + .iter() + .filter(|port| !port.available) + .map(crate::LowPortState::conflict_message) + .collect::>(); + if !conflicts.is_empty() { + return Err(PlatformError::SystemIntegration(conflicts.join(" "))); + } crate::pf::apply_pf_redirects_privileged(&PfRedirectConfig::new( http_port, https_port, ))?; Ok(HelperPayload::Empty) } - HelperOperation::PfReload => { - crate::pf::reload_pf_redirects_privileged()?; - Ok(HelperPayload::Empty) - } HelperOperation::PfRemove => { crate::pf::remove_pf_redirects_privileged()?; Ok(HelperPayload::Empty) @@ -1839,6 +1852,8 @@ fn helper_error_code(error: &PlatformError) -> HelperErrorCode { mod tests { use std::io::{Cursor, ErrorKind}; #[cfg(target_os = "macos")] + use std::net::TcpListener; + #[cfg(target_os = "macos")] use std::os::unix::fs::symlink; #[cfg(target_os = "macos")] use std::thread; @@ -1854,9 +1869,9 @@ mod tests { #[cfg(target_os = "macos")] use super::{ HELPER_SOCKET_NAME, HELPER_STANDARD_ERROR_PATH, HelperLaunchDaemonPlist, HelperPayload, - PRIVILEGED_HELPER_VERSION, call_helper, lock_machine_helper_lifecycle_file, - probe_helper_lifecycle, render_launch_daemon_plist, restore_helper_file, - serve_next_helper_connection, validate_root_owned_regular_file, + PRIVILEGED_HELPER_VERSION, call_helper, dispatch_request, + lock_machine_helper_lifecycle_file, probe_helper_lifecycle, render_launch_daemon_plist, + restore_helper_file, serve_next_helper_connection, validate_root_owned_regular_file, }; use super::{ HelperRequest, MAX_MESSAGE_BYTES, PrivilegedHelperMetadata, helper_artifacts_present, @@ -2074,7 +2089,7 @@ mod tests { #[test] fn protocol_rejects_unknown_fields() { let mut message = Cursor::new( - br#"{"protocol_version":1,"operation":{"name":"status"},"command":"/bin/sh"} + br#"{"protocol_version":2,"operation":{"name":"status"},"command":"/bin/sh"} "#, ); @@ -2084,6 +2099,92 @@ mod tests { )); } + #[cfg(target_os = "macos")] + #[test] + #[ignore = "requires root and a free loopback port 80"] + fn pf_apply_rejects_a_low_port_conflict_as_root() -> anyhow::Result<()> { + if !rustix::process::geteuid().is_root() { + return Err(anyhow!("run this test as root")); + } + let listener = TcpListener::bind(("127.0.0.1", 80))?; + let metadata = PrivilegedHelperMetadata { + owner_uid: rustix::process::getuid().as_raw(), + helper_version: PRIVILEGED_HELPER_VERSION.to_owned(), + protocol_version: HELPER_PROTOCOL_VERSION, + }; + let result = dispatch_request( + HelperRequest { + protocol_version: HELPER_PROTOCOL_VERSION, + operation: HelperOperation::PfApply { + http_port: 48080, + https_port: 48443, + }, + }, + &metadata, + ); + drop(listener); + let Err(PlatformError::SystemIntegration(message)) = result else { + return Err(anyhow!("PfApply did not reject the held low port")); + }; + assert!(message.contains(&format!("(pid {})", std::process::id()))); + Ok(()) + } + + #[test] + fn protocol_rejects_the_removed_pf_reload_operation() { + let mut message = Cursor::new( + br#"{"protocol_version":2,"operation":{"name":"pf_reload"}} +"#, + ); + assert!(matches!( + read_message::(&mut message), + Err(PlatformError::PrivilegedHelperProtocol(_)) + )); + } + + #[cfg(target_os = "macos")] + #[test] + #[expect( + clippy::disallowed_methods, + reason = "helper test fixture needs a blocking Unix socket server" + )] + fn low_port_inspection_round_trips_through_the_helper_socket() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let socket_path = tempdir.path().join("helper.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path)?; + let metadata = PrivilegedHelperMetadata { + owner_uid: rustix::process::getuid().as_raw(), + helper_version: PRIVILEGED_HELPER_VERSION.to_owned(), + protocol_version: HELPER_PROTOCOL_VERSION, + }; + let server = thread::spawn(move || serve_next_helper_connection(&listener, &metadata)); + let payload = call_helper(&socket_path, HelperOperation::LowPortInspect)?; + server + .join() + .map_err(|_error| anyhow!("helper fixture thread panicked"))??; + let HelperPayload::LowPorts(inspection) = payload else { + return Err(anyhow!( + "helper returned an unexpected low-port inspection payload" + )); + }; + assert_eq!( + inspection + .ports + .iter() + .map(|state| state.port) + .collect::>(), + [80, 443] + ); + assert!( + inspection + .ports + .iter() + .filter(|state| state.available) + .all(|state| state.owners.is_empty()) + ); + Ok(()) + } + #[cfg(target_os = "macos")] #[test] fn connection_authenticates_before_reading_a_request() -> anyhow::Result<()> { diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 64b11ee2..8317a469 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -5,7 +5,7 @@ mod command; mod error; mod helper; mod launch_agent; -mod listener; +mod low_port; mod pf; mod port; mod process; @@ -32,7 +32,7 @@ pub use launch_agent::{ bootout_launch_agent, bootstrap_launch_agent, inspect_launch_agent_file, kickstart_launch_agent, launch_agent_path, remove_launch_agent_file, write_launch_agent_file, }; -pub use listener::{loopback_tcp_listener_ports, loopback_tcp_port_has_listener}; +pub use low_port::{LowPortInspection, LowPortState, PortOwner}; pub use pf::{ ActivePfRedirectInspection, PfConfReference, PfFileState, PfRedirectConfig, SYSTEM_PF_ANCHOR_PATH, SYSTEM_PF_CONF_PATH, active_pf_redirect_config, diff --git a/crates/platform/src/listener.rs b/crates/platform/src/listener.rs deleted file mode 100644 index 9c106957..00000000 --- a/crates/platform/src/listener.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::collections::BTreeSet; - -use crate::PlatformError; - -#[cfg(target_os = "linux")] -#[path = "listener/linux.rs"] -mod implementation; -#[cfg(target_os = "macos")] -#[path = "listener/macos.rs"] -mod implementation; -#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] -#[path = "listener/unsupported.rs"] -mod implementation; -#[cfg(target_os = "windows")] -#[path = "listener/windows.rs"] -mod implementation; - -pub fn loopback_tcp_listener_ports() -> Result, PlatformError> { - implementation::loopback_tcp_listener_ports() -} - -pub fn loopback_tcp_port_has_listener(port: u16) -> Result { - Ok(loopback_tcp_listener_ports()?.contains(&port)) -} diff --git a/crates/platform/src/listener/linux.rs b/crates/platform/src/listener/linux.rs deleted file mode 100644 index eedf144e..00000000 --- a/crates/platform/src/listener/linux.rs +++ /dev/null @@ -1,8 +0,0 @@ -use std::collections::BTreeSet; - -use crate::capability::unsupported; -use crate::{PlatformCapability, PlatformError}; - -pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - Err(unsupported(PlatformCapability::ListenerInspection)?) -} diff --git a/crates/platform/src/listener/macos.rs b/crates/platform/src/listener/macos.rs deleted file mode 100644 index b60d7a1a..00000000 --- a/crates/platform/src/listener/macos.rs +++ /dev/null @@ -1,14 +0,0 @@ -use std::collections::BTreeSet; - -use crate::PlatformError; - -#[path = "macos/kernel_table.rs"] -mod kernel_table; - -pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - kernel_table::loopback_tcp_listener_ports().map_err(|source| { - PlatformError::ListenerInspection { - source: Box::new(source), - } - }) -} diff --git a/crates/platform/src/listener/macos/kernel_table.rs b/crates/platform/src/listener/macos/kernel_table.rs deleted file mode 100644 index fa1dddac..00000000 --- a/crates/platform/src/listener/macos/kernel_table.rs +++ /dev/null @@ -1,749 +0,0 @@ -use std::collections::BTreeSet; -use std::ffi::CStr; -use std::io; -use std::net::{Ipv4Addr, Ipv6Addr}; -use std::ptr; -use std::thread; -use std::time::Duration; - -use thiserror::Error; - -const TCP_PCBLIST_NAME: &CStr = c"net.inet.tcp.pcblist_n"; -const MAX_ATTEMPTS: usize = 10; -const SNAPSHOT_RETRY_DELAY: Duration = Duration::from_millis(1); - -// These offsets follow Apple's private `xinpcb_n`, `xtcpcb_n`, and `xinpgen` -// layouts. They are unchanged in the published XNU sources for macOS 13, 14, -// 15, and 26; parsing remains bounds-checked because the ABI is not public. -const XINPGEN_LENGTH: usize = 24; -const XGEN_HEADER_LENGTH: usize = 8; -const XINPCB_MINIMUM_LENGTH: usize = 104; -const XTCPCB_MINIMUM_LENGTH: usize = 40; - -const XSO_INPCB: u32 = 0x010; -const XSO_TCPCB: u32 = 0x020; -const INP_IPV4: u8 = 0x1; -const INP_IPV6: u8 = 0x2; -const TCPS_LISTEN: u32 = 1; - -const INPCB_LOCAL_PORT_OFFSET: usize = 18; -const INPCB_GENERATION_OFFSET: usize = 28; -const INPCB_VERSION_FLAGS_OFFSET: usize = 44; -const INPCB_LOCAL_ADDRESS_OFFSET: usize = 64; -const INPCB_LOCAL_ADDRESS_LENGTH: usize = 16; -const INPCB_IPV4_ADDRESS_OFFSET: usize = INPCB_LOCAL_ADDRESS_OFFSET + 12; -const TCPCB_STATE_OFFSET: usize = 36; - -#[derive(Debug, Error)] -pub(super) enum KernelTableError { - #[error(transparent)] - Fetch(#[from] FetchError), - - #[error(transparent)] - Parse(#[from] ParseError), - - #[error("TCP listener snapshot changed during {attempts} consecutive inspections")] - SnapshotUnstable { attempts: usize }, -} - -#[derive(Debug, Error)] -pub(super) enum FetchError { - #[error("could not query the macOS TCP PCB table size: {0}")] - Size(#[source] io::Error), - - #[error("could not read the macOS TCP PCB table: {0}")] - Read(#[source] io::Error), - - #[error("the macOS TCP PCB table grew during {attempts} consecutive reads")] - TableGrowth { attempts: usize }, - - #[error( - "the macOS TCP PCB table reported {actual} bytes after receiving a {capacity}-byte buffer" - )] - InvalidReturnedLength { capacity: usize, actual: usize }, -} - -#[derive(Debug, Error, Eq, PartialEq)] -pub(super) enum ParseError { - #[error("TCP PCB table is too short: expected at least {minimum} bytes, received {actual}")] - TableTooShort { minimum: usize, actual: usize }, - - #[error("TCP PCB table {position} envelope has length {actual}; expected {expected}")] - InvalidEnvelopeLength { - position: &'static str, - expected: usize, - actual: usize, - }, - - #[error("TCP PCB record header is truncated at byte {offset}")] - TruncatedRecordHeader { offset: usize }, - - #[error("TCP PCB record at byte {offset} has invalid length {length}")] - InvalidRecordLength { offset: usize, length: usize }, - - #[error( - "TCP PCB record at byte {offset} extends {padded_length} bytes beyond the table boundary" - )] - TruncatedRecord { offset: usize, padded_length: usize }, - - #[error( - "TCP PCB record kind {kind:#x} at byte {offset} is too short: expected at least {minimum} bytes, received {actual}" - )] - KnownRecordTooShort { - offset: usize, - kind: u32, - minimum: usize, - actual: usize, - }, - - #[error("TCP state record at byte {offset} has no preceding internet PCB record")] - MissingInternetPcb { offset: usize }, - - #[error("TCP PCB table ended with an incomplete listener record")] - IncompleteRecord, - - #[error("TCP PCB table has no trailing snapshot envelope")] - MissingTrailer, - - #[error( - "TCP PCB snapshot changed from count/gen/socket-gen {header_count}/{header_generation}/{header_socket_generation} to {trailer_count}/{trailer_generation}/{trailer_socket_generation}" - )] - SnapshotChanged { - header_count: u32, - header_generation: u64, - header_socket_generation: u64, - trailer_count: u32, - trailer_generation: u64, - trailer_socket_generation: u64, - }, -} - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct SnapshotEnvelope { - count: u32, - generation: u64, - socket_generation: u64, -} - -#[derive(Clone, Copy, Debug)] -struct InternetPcb { - generation: u64, - version_flags: u8, - local_port: u16, - local_address: [u8; INPCB_LOCAL_ADDRESS_LENGTH], -} - -pub(super) fn loopback_tcp_listener_ports() -> Result, KernelTableError> { - for attempt in 1..=MAX_ATTEMPTS { - let table = fetch_tcp_table()?; - - match parse_tcp_table(&table) { - Ok(ports) => return Ok(ports), - Err(ParseError::SnapshotChanged { .. }) if attempt < MAX_ATTEMPTS => { - thread::sleep(SNAPSHOT_RETRY_DELAY); - } - Err(ParseError::SnapshotChanged { .. }) => {} - Err(error) => return Err(error.into()), - } - } - - Err(KernelTableError::SnapshotUnstable { - attempts: MAX_ATTEMPTS, - }) -} - -fn fetch_tcp_table() -> Result, FetchError> { - fetch_tcp_table_with(query_tcp_table) -} - -fn fetch_tcp_table_with( - mut query: impl FnMut(Option<&mut [u8]>) -> io::Result, -) -> Result, FetchError> { - for _attempt in 1..=MAX_ATTEMPTS { - let capacity = query(None).map_err(FetchError::Size)?; - let mut table = vec![0; capacity]; - - match query(Some(&mut table)) { - Ok(actual) if actual <= capacity => { - table.truncate(actual); - return Ok(table); - } - Ok(actual) => { - return Err(FetchError::InvalidReturnedLength { capacity, actual }); - } - Err(error) if error.raw_os_error() == Some(libc::ENOMEM) => {} - Err(error) => return Err(FetchError::Read(error)), - } - } - - Err(FetchError::TableGrowth { - attempts: MAX_ATTEMPTS, - }) -} - -fn query_tcp_table(buffer: Option<&mut [u8]>) -> io::Result { - let mut length = buffer.as_ref().map_or(0, |bytes| bytes.len()); - let pointer = buffer.map_or(ptr::null_mut(), |bytes| bytes.as_mut_ptr().cast()); - - // SAFETY: TCP_PCBLIST_NAME is NUL-terminated, `length` points to valid writable - // storage, and `pointer` is either null for the size query or covers `length` - // writable bytes for the data query. Both new-value arguments are null/zero - // because this is a read-only sysctl request. - let status = unsafe { - libc::sysctlbyname( - TCP_PCBLIST_NAME.as_ptr(), - pointer, - &mut length, - ptr::null_mut(), - 0, - ) - }; - - if status == 0 { - Ok(length) - } else { - Err(io::Error::last_os_error()) - } -} - -fn parse_tcp_table(table: &[u8]) -> Result, ParseError> { - if table.len() < XINPGEN_LENGTH { - return Err(ParseError::TableTooShort { - minimum: XINPGEN_LENGTH, - actual: table.len(), - }); - } - - let header = parse_envelope(table, "leading")?; - if table.len() == XINPGEN_LENGTH && header.count == 0 { - return Ok(BTreeSet::new()); - } - - let mut offset = XINPGEN_LENGTH; - let mut pending_internet_pcb = None; - let mut ports = BTreeSet::new(); - - while offset < table.len() { - let remaining = table.len() - offset; - if remaining == XINPGEN_LENGTH { - let trailer = parse_envelope(&table[offset..], "trailing")?; - - if pending_internet_pcb.is_some() { - return Err(ParseError::IncompleteRecord); - } - // `xig_sogen` is global across all socket families, so unrelated - // socket allocation or release can change it while this TCP PCB snapshot - // remains coherent. Apple netstat likewise checks the PCB-specific generation. - if header.generation != trailer.generation || header.count != trailer.count { - return Err(ParseError::SnapshotChanged { - header_count: header.count, - header_generation: header.generation, - header_socket_generation: header.socket_generation, - trailer_count: trailer.count, - trailer_generation: trailer.generation, - trailer_socket_generation: trailer.socket_generation, - }); - } - - return Ok(ports); - } - if remaining < XGEN_HEADER_LENGTH { - return Err(ParseError::TruncatedRecordHeader { offset }); - } - - let length = read_native_u32(table, offset) as usize; - if length <= XINPGEN_LENGTH { - return Err(ParseError::InvalidRecordLength { offset, length }); - } - let padded_length = - length - .checked_add(7) - .map(|value| value & !7) - .ok_or(ParseError::TruncatedRecord { - offset, - padded_length: length, - })?; - if padded_length > remaining { - return Err(ParseError::TruncatedRecord { - offset, - padded_length, - }); - } - - let record = &table[offset..offset + length]; - let kind = read_native_u32(record, 4); - match kind { - XSO_INPCB => { - if pending_internet_pcb.is_some() { - return Err(ParseError::IncompleteRecord); - } - pending_internet_pcb = Some(parse_internet_pcb(record, offset)?); - } - XSO_TCPCB => { - let internet_pcb = pending_internet_pcb - .take() - .ok_or(ParseError::MissingInternetPcb { offset })?; - let state = parse_tcp_state(record, offset)?; - - if internet_pcb.generation <= header.generation - && state == TCPS_LISTEN - && occupies_loopback(internet_pcb) - { - ports.insert(internet_pcb.local_port); - } - } - _ => {} - } - - offset += padded_length; - } - - Err(ParseError::MissingTrailer) -} - -fn parse_envelope(bytes: &[u8], position: &'static str) -> Result { - let actual = read_native_u32(bytes, 0) as usize; - if actual != XINPGEN_LENGTH { - return Err(ParseError::InvalidEnvelopeLength { - position, - expected: XINPGEN_LENGTH, - actual, - }); - } - - Ok(SnapshotEnvelope { - count: read_native_u32(bytes, 4), - generation: read_native_u64(bytes, 8), - socket_generation: read_native_u64(bytes, 16), - }) -} - -fn parse_internet_pcb(record: &[u8], offset: usize) -> Result { - require_record_length(record, offset, XSO_INPCB, XINPCB_MINIMUM_LENGTH)?; - - let mut local_address = [0; INPCB_LOCAL_ADDRESS_LENGTH]; - local_address.copy_from_slice( - &record - [INPCB_LOCAL_ADDRESS_OFFSET..INPCB_LOCAL_ADDRESS_OFFSET + INPCB_LOCAL_ADDRESS_LENGTH], - ); - - Ok(InternetPcb { - generation: read_native_u64(record, INPCB_GENERATION_OFFSET), - version_flags: record[INPCB_VERSION_FLAGS_OFFSET], - local_port: read_network_u16(record, INPCB_LOCAL_PORT_OFFSET), - local_address, - }) -} - -fn parse_tcp_state(record: &[u8], offset: usize) -> Result { - require_record_length(record, offset, XSO_TCPCB, XTCPCB_MINIMUM_LENGTH)?; - Ok(read_native_u32(record, TCPCB_STATE_OFFSET)) -} - -fn require_record_length( - record: &[u8], - offset: usize, - kind: u32, - minimum: usize, -) -> Result<(), ParseError> { - if record.len() < minimum { - return Err(ParseError::KnownRecordTooShort { - offset, - kind, - minimum, - actual: record.len(), - }); - } - - Ok(()) -} - -fn occupies_loopback(internet_pcb: InternetPcb) -> bool { - let ipv4_address = Ipv4Addr::from([ - internet_pcb.local_address[INPCB_IPV4_ADDRESS_OFFSET - INPCB_LOCAL_ADDRESS_OFFSET], - internet_pcb.local_address[INPCB_IPV4_ADDRESS_OFFSET - INPCB_LOCAL_ADDRESS_OFFSET + 1], - internet_pcb.local_address[INPCB_IPV4_ADDRESS_OFFSET - INPCB_LOCAL_ADDRESS_OFFSET + 2], - internet_pcb.local_address[INPCB_IPV4_ADDRESS_OFFSET - INPCB_LOCAL_ADDRESS_OFFSET + 3], - ]); - let ipv4_occupies_loopback = internet_pcb.version_flags & INP_IPV4 != 0 - && (ipv4_address.is_loopback() || ipv4_address.is_unspecified()); - - let ipv6_address = Ipv6Addr::from(internet_pcb.local_address); - let ipv6_occupies_loopback = internet_pcb.version_flags & INP_IPV6 != 0 - && (ipv6_address.is_loopback() || ipv6_address.is_unspecified()); - - ipv4_occupies_loopback || ipv6_occupies_loopback -} - -fn read_native_u32(bytes: &[u8], offset: usize) -> u32 { - u32::from_ne_bytes([ - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - ]) -} - -fn read_native_u64(bytes: &[u8], offset: usize) -> u64 { - u64::from_ne_bytes([ - bytes[offset], - bytes[offset + 1], - bytes[offset + 2], - bytes[offset + 3], - bytes[offset + 4], - bytes[offset + 5], - bytes[offset + 6], - bytes[offset + 7], - ]) -} - -fn read_network_u16(bytes: &[u8], offset: usize) -> u16 { - u16::from_be_bytes([bytes[offset], bytes[offset + 1]]) -} - -#[cfg(test)] -mod tests { - use std::collections::{BTreeSet, VecDeque}; - use std::io; - use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; - - use anyhow::Result; - use insta::assert_debug_snapshot; - - use crate::command::run_system_command_output; - - use super::{ - FetchError, INP_IPV4, INP_IPV6, MAX_ATTEMPTS, TCPS_LISTEN, XINPCB_MINIMUM_LENGTH, - XSO_INPCB, XSO_TCPCB, XTCPCB_MINIMUM_LENGTH, fetch_tcp_table_with, parse_tcp_table, - }; - - const SNAPSHOT_COUNT: u32 = 8; - const SNAPSHOT_GENERATION: u64 = 100; - const SOCKET_GENERATION: u64 = 200; - - #[test] - fn pcb_fixture_covers_address_families_states_generations_and_unknown_records() -> Result<()> { - let mut fixture = Fixture::new(); - fixture.push_listener( - INP_IPV4, - IpFixture::V4(Ipv4Addr::LOCALHOST), - 45_000, - SNAPSHOT_GENERATION, - TCPS_LISTEN, - ); - fixture.push_listener( - INP_IPV4, - IpFixture::V4(Ipv4Addr::UNSPECIFIED), - 45_001, - SNAPSHOT_GENERATION, - TCPS_LISTEN, - ); - fixture.push_listener( - INP_IPV6, - IpFixture::V6(Ipv6Addr::LOCALHOST), - 45_002, - SNAPSHOT_GENERATION, - TCPS_LISTEN, - ); - fixture.push_listener( - INP_IPV6, - IpFixture::V6(Ipv6Addr::UNSPECIFIED), - 45_003, - SNAPSHOT_GENERATION, - TCPS_LISTEN, - ); - fixture.push_listener( - INP_IPV4, - IpFixture::V4(Ipv4Addr::new(192, 168, 1, 5)), - 45_004, - SNAPSHOT_GENERATION, - TCPS_LISTEN, - ); - fixture.push_listener( - INP_IPV4, - IpFixture::V4(Ipv4Addr::LOCALHOST), - 45_005, - SNAPSHOT_GENERATION, - 4, - ); - fixture.push_listener( - INP_IPV6, - IpFixture::V6(Ipv6Addr::LOCALHOST), - 45_006, - SNAPSHOT_GENERATION + 1, - TCPS_LISTEN, - ); - fixture.push_unknown_record(); - - assert_debug_snapshot!(parse_tcp_table(&fixture.finish())?); - - Ok(()) - } - - #[test] - fn empty_pcb_fixture_matches_xnu_single_envelope_shape() { - let fixtures = [ - ( - "zero count", - parse_tcp_table(&envelope(0, SNAPSHOT_GENERATION, SOCKET_GENERATION)), - ), - ( - "nonzero count", - parse_tcp_table(&envelope( - SNAPSHOT_COUNT, - SNAPSHOT_GENERATION, - SOCKET_GENERATION, - )), - ), - ]; - - assert_debug_snapshot!(fixtures); - } - - #[test] - fn malformed_pcb_fixtures_return_deterministic_typed_errors() { - let mut invalid_envelope = Fixture::new().finish(); - invalid_envelope[0..4].copy_from_slice(&16_u32.to_ne_bytes()); - - let mut invalid_record_length = Fixture::new().finish(); - invalid_record_length.splice(24..24, record(16, 0x400)); - - let mut truncated_record = Fixture::new().finish(); - truncated_record.splice(24..24, record(32, 0x400)); - truncated_record[24..28].copy_from_slice(&1_024_u32.to_ne_bytes()); - - let mut incomplete_record_fixture = Fixture::new(); - incomplete_record_fixture.push_internet_pcb( - INP_IPV4, - IpFixture::V4(Ipv4Addr::LOCALHOST), - 45_000, - SNAPSHOT_GENERATION, - ); - - let mut changed_snapshot = Fixture::new().finish(); - let trailer_offset = changed_snapshot.len() - 24; - changed_snapshot[trailer_offset + 8..trailer_offset + 16] - .copy_from_slice(&(SNAPSHOT_GENERATION + 1).to_ne_bytes()); - - let fixtures = [ - ("empty", Vec::new()), - ("invalid envelope", invalid_envelope), - ("invalid record length", invalid_record_length), - ("truncated record", truncated_record), - ("incomplete record", incomplete_record_fixture.finish()), - ("changed snapshot", changed_snapshot), - ]; - let errors = fixtures - .into_iter() - .map(|(name, fixture)| (name, parse_tcp_table(&fixture))) - .collect::>(); - - assert_debug_snapshot!(errors); - } - - #[test] - fn pcb_fetch_retries_table_growth_and_uses_reported_length() -> Result<()> { - let mut steps = VecDeque::from([ - QueryStep::Size(4), - QueryStep::Growth, - QueryStep::Size(8), - QueryStep::Data(vec![1, 2, 3]), - ]); - let table = fetch_tcp_table_with(|buffer| { - let Some(step) = steps.pop_front() else { - return Err(io::Error::other("unexpected query")); - }; - - match (step, buffer) { - (QueryStep::Size(size), None) => Ok(size), - (QueryStep::Growth, Some(_)) => Err(io::Error::from_raw_os_error(libc::ENOMEM)), - (QueryStep::Data(data), Some(buffer)) => { - buffer[..data.len()].copy_from_slice(&data); - Ok(data.len()) - } - _ => Err(io::Error::other("query shape did not match fixture")), - } - })?; - - assert_eq!(table, [1, 2, 3]); - assert!(steps.is_empty()); - - Ok(()) - } - - #[test] - fn pcb_fetch_bounds_repeated_table_growth() { - let mut calls = 0; - let result = fetch_tcp_table_with(|buffer| { - calls += 1; - if buffer.is_some() { - Err(io::Error::from_raw_os_error(libc::ENOMEM)) - } else { - Ok(4) - } - }); - - assert!(matches!( - result, - Err(FetchError::TableGrowth { attempts }) if attempts == MAX_ATTEMPTS - )); - assert_eq!(calls, MAX_ATTEMPTS * 2); - } - - #[test] - fn live_kernel_table_repeatedly_detects_all_controlled_listener_classes() -> Result<()> { - let ipv4_loopback = TcpListener::bind((Ipv4Addr::LOCALHOST, 0))?; - let ipv4_wildcard = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?; - let ipv6_loopback = TcpListener::bind((Ipv6Addr::LOCALHOST, 0))?; - let ipv6_wildcard = TcpListener::bind((Ipv6Addr::UNSPECIFIED, 0))?; - let expected = [ - ("ipv4 loopback", ipv4_loopback.local_addr()?.port()), - ("ipv4 wildcard", ipv4_wildcard.local_addr()?.port()), - ("ipv6 loopback", ipv6_loopback.local_addr()?.port()), - ("ipv6 wildcard", ipv6_wildcard.local_addr()?.port()), - ]; - let mut detections = expected.map(|(name, _port)| (name, 0, 0)); - - for _sample in 0..10 { - let kernel_ports = crate::loopback_tcp_listener_ports()?; - let netstat_output = - run_system_command_output("/usr/sbin/netstat", &["-anv", "-p", "tcp"])?; - let netstat_ports = controlled_netstat_listener_ports(&netstat_output, &expected); - - for (index, (_name, port)) in expected.iter().enumerate() { - if kernel_ports.contains(port) { - detections[index].1 += 1; - } - if netstat_ports.contains(port) { - detections[index].2 += 1; - } - } - } - - assert_debug_snapshot!(detections); - - Ok(()) - } - - fn controlled_netstat_listener_ports(output: &str, expected: &[(&str, u16)]) -> BTreeSet { - output - .lines() - .filter_map(|line| { - let columns = line.split_whitespace().collect::>(); - let [ - protocol, - _recv_queue, - _send_queue, - local_address, - _foreign_address, - state, - .., - ] = columns.as_slice() - else { - return None; - }; - if !protocol.starts_with("tcp") || *state != "LISTEN" { - return None; - } - - let (_address, port) = local_address.rsplit_once('.')?; - let port = port.parse::().ok()?; - - expected - .iter() - .any(|(_name, expected_port)| *expected_port == port) - .then_some(port) - }) - .collect() - } - - #[derive(Debug)] - enum QueryStep { - Size(usize), - Data(Vec), - Growth, - } - - enum IpFixture { - V4(Ipv4Addr), - V6(Ipv6Addr), - } - - struct Fixture { - bytes: Vec, - } - - impl Fixture { - fn new() -> Self { - Self { - bytes: envelope(SNAPSHOT_COUNT, SNAPSHOT_GENERATION, SOCKET_GENERATION), - } - } - - fn push_listener( - &mut self, - version_flags: u8, - address: IpFixture, - port: u16, - generation: u64, - state: u32, - ) { - self.push_internet_pcb(version_flags, address, port, generation); - - let mut tcp_record = record(XTCPCB_MINIMUM_LENGTH, XSO_TCPCB); - tcp_record[36..40].copy_from_slice(&state.to_ne_bytes()); - self.bytes.extend(tcp_record); - } - - fn push_internet_pcb( - &mut self, - version_flags: u8, - address: IpFixture, - port: u16, - generation: u64, - ) { - let mut internet_record = record(XINPCB_MINIMUM_LENGTH, XSO_INPCB); - internet_record[18..20].copy_from_slice(&port.to_be_bytes()); - internet_record[28..36].copy_from_slice(&generation.to_ne_bytes()); - internet_record[44] = version_flags; - match address { - IpFixture::V4(address) => { - internet_record[76..80].copy_from_slice(&address.octets()); - } - IpFixture::V6(address) => { - internet_record[64..80].copy_from_slice(&address.octets()); - } - } - self.bytes.extend(internet_record); - } - - fn push_unknown_record(&mut self) { - self.bytes.extend(record(32, 0x400)); - } - - fn finish(mut self) -> Vec { - self.bytes.extend(envelope( - SNAPSHOT_COUNT, - SNAPSHOT_GENERATION, - SOCKET_GENERATION, - )); - self.bytes - } - } - - fn envelope(count: u32, generation: u64, socket_generation: u64) -> Vec { - let mut bytes = Vec::with_capacity(24); - bytes.extend(24_u32.to_ne_bytes()); - bytes.extend(count.to_ne_bytes()); - bytes.extend(generation.to_ne_bytes()); - bytes.extend(socket_generation.to_ne_bytes()); - bytes - } - - fn record(length: usize, kind: u32) -> Vec { - let mut bytes = vec![0; length]; - bytes[0..4].copy_from_slice(&(length as u32).to_ne_bytes()); - bytes[4..8].copy_from_slice(&kind.to_ne_bytes()); - bytes - } -} diff --git a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__empty_pcb_fixture_matches_xnu_single_envelope_shape.snap b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__empty_pcb_fixture_matches_xnu_single_envelope_shape.snap deleted file mode 100644 index 888d8752..00000000 --- a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__empty_pcb_fixture_matches_xnu_single_envelope_shape.snap +++ /dev/null @@ -1,18 +0,0 @@ ---- -source: crates/platform/src/listener/macos/kernel_table.rs -expression: fixtures ---- -[ - ( - "zero count", - Ok( - {}, - ), - ), - ( - "nonzero count", - Err( - MissingTrailer, - ), - ), -] diff --git a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap deleted file mode 100644 index 1049c3e7..00000000 --- a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__live_kernel_table_repeatedly_detects_all_controlled_listener_classes.snap +++ /dev/null @@ -1,26 +0,0 @@ ---- -source: crates/platform/src/listener/macos/kernel_table.rs -expression: detections ---- -[ - ( - "ipv4 loopback", - 10, - 10, - ), - ( - "ipv4 wildcard", - 10, - 10, - ), - ( - "ipv6 loopback", - 10, - 10, - ), - ( - "ipv6 wildcard", - 10, - 10, - ), -] diff --git a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__malformed_pcb_fixtures_return_deterministic_typed_errors.snap b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__malformed_pcb_fixtures_return_deterministic_typed_errors.snap deleted file mode 100644 index e06e9cf4..00000000 --- a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__malformed_pcb_fixtures_return_deterministic_typed_errors.snap +++ /dev/null @@ -1,62 +0,0 @@ ---- -source: crates/platform/src/listener/macos/kernel_table.rs -expression: errors ---- -[ - ( - "empty", - Err( - TableTooShort { - minimum: 24, - actual: 0, - }, - ), - ), - ( - "invalid envelope", - Err( - InvalidEnvelopeLength { - position: "leading", - expected: 24, - actual: 16, - }, - ), - ), - ( - "invalid record length", - Err( - InvalidRecordLength { - offset: 24, - length: 16, - }, - ), - ), - ( - "truncated record", - Err( - TruncatedRecord { - offset: 24, - padded_length: 1024, - }, - ), - ), - ( - "incomplete record", - Err( - IncompleteRecord, - ), - ), - ( - "changed snapshot", - Err( - SnapshotChanged { - header_count: 8, - header_generation: 100, - header_socket_generation: 200, - trailer_count: 8, - trailer_generation: 101, - trailer_socket_generation: 200, - }, - ), - ), -] diff --git a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__pcb_fixture_covers_address_families_states_generations_and_unknown_records.snap b/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__pcb_fixture_covers_address_families_states_generations_and_unknown_records.snap deleted file mode 100644 index 526ef823..00000000 --- a/crates/platform/src/listener/macos/snapshots/platform__listener__implementation__kernel_table__tests__pcb_fixture_covers_address_families_states_generations_and_unknown_records.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/platform/src/listener/macos/kernel_table.rs -expression: parse_tcp_table(&fixture.finish())? ---- -{ - 45000, - 45001, - 45002, - 45003, -} diff --git a/crates/platform/src/listener/unsupported.rs b/crates/platform/src/listener/unsupported.rs deleted file mode 100644 index eedf144e..00000000 --- a/crates/platform/src/listener/unsupported.rs +++ /dev/null @@ -1,8 +0,0 @@ -use std::collections::BTreeSet; - -use crate::capability::unsupported; -use crate::{PlatformCapability, PlatformError}; - -pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - Err(unsupported(PlatformCapability::ListenerInspection)?) -} diff --git a/crates/platform/src/listener/windows.rs b/crates/platform/src/listener/windows.rs deleted file mode 100644 index eedf144e..00000000 --- a/crates/platform/src/listener/windows.rs +++ /dev/null @@ -1,8 +0,0 @@ -use std::collections::BTreeSet; - -use crate::capability::unsupported; -use crate::{PlatformCapability, PlatformError}; - -pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - Err(unsupported(PlatformCapability::ListenerInspection)?) -} diff --git a/crates/platform/src/low_port.rs b/crates/platform/src/low_port.rs new file mode 100644 index 00000000..6c18bff8 --- /dev/null +++ b/crates/platform/src/low_port.rs @@ -0,0 +1,192 @@ +#[cfg(target_os = "macos")] +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +#[cfg(target_os = "macos")] +use crate::command::run_system_command_output_with_timeout; +#[cfg(target_os = "macos")] +use crate::{PlatformError, loopback_tcp_port_available}; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LowPortInspection { + pub ports: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct LowPortState { + pub port: u16, + pub available: bool, + pub owners: Vec, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct PortOwner { + pub pid: u32, + pub command: String, +} + +impl LowPortState { + pub fn conflict_message(&self) -> String { + if self.owners.is_empty() { + return format!( + "Loopback TCP port {} is in use; PV could not identify the process.", + self.port + ); + } + let owners = self + .owners + .iter() + .map(|owner| format!("{} (pid {})", owner.command, owner.pid)) + .collect::>() + .join(", "); + format!("Loopback TCP port {} is in use by {owners}.", self.port) + } +} + +/// Root decides availability with a bind probe; `lsof` supplies diagnostics only. +#[cfg(target_os = "macos")] +pub(crate) fn inspect_loopback_ports(ports: &[u16]) -> Result { + let mut inspection = LowPortInspection { + ports: ports + .iter() + .map(|&port| LowPortState { + port, + available: loopback_tcp_port_available(port), + owners: Vec::new(), + }) + .collect(), + }; + if inspection.ports.iter().all(|port| port.available) { + return Ok(inspection); + } + let mut arguments = vec!["-nP".to_owned()]; + arguments.extend(ports.iter().map(|port| format!("-iTCP:{port}"))); + arguments.extend(["-sTCP:LISTEN".to_owned(), "-F".to_owned(), "pcn".to_owned()]); + let arguments = arguments.iter().map(String::as_str).collect::>(); + let output = run_system_command_output_with_timeout( + "/usr/sbin/lsof", + &arguments, + Duration::from_secs(2), + Some(1), + )?; + parse_lsof_owners(&output, &mut inspection.ports); + Ok(inspection) +} + +#[cfg(any(target_os = "macos", test))] +fn parse_lsof_owners(output: &str, ports: &mut [LowPortState]) { + let mut pid = None; + let mut command = None; + for line in output.lines() { + if let Some(value) = line.strip_prefix('p') { + pid = value.parse::().ok().filter(|&pid| pid > 0); + command = None; + } else if let Some(value) = line.strip_prefix('c') { + command = (!value.is_empty()).then_some(value); + } else if let Some(value) = line.strip_prefix('n') + && let Some(pid) = pid + && let Some(command) = command + && let Some((address, port)) = value.rsplit_once(':') + && matches!(address, "*" | "127.0.0.1" | "[::]") + && let Ok(port) = port.parse::() + && let Some(state) = ports + .iter_mut() + .find(|state| state.port == port && !state.available) + // Bound diagnostics so the helper reply stays within its 16 KiB frame. + && state.owners.len() < 8 + && !state.owners.iter().any(|owner| owner.pid == pid) + { + state.owners.push(PortOwner { + pid, + command: command.to_owned(), + }); + } + } +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "macos")] + use std::net::TcpListener; + + use insta::assert_debug_snapshot; + + #[cfg(target_os = "macos")] + use super::inspect_loopback_ports; + use super::{LowPortInspection, LowPortState, parse_lsof_owners}; + + #[test] + fn lsof_fields_name_and_deduplicate_owners_without_deciding_availability() { + for (name, fields) in [ + ("named_owner", "p412\ncnginx\nf5\nn127.0.0.1:80\n"), + ( + "several_pids", + "p412\ncnginx\nn*:80\np501\ncPython\nn127.0.0.1:443\n", + ), + ( + "nginx_master_workers", + "p412\ncnginx\nn*:80\nn*:80\nn*:443\np413\ncnginx\nn*:80\nn*:443\np414\ncnginx\nn*:80\n", + ), + ("empty_output", ""), + ( + "unrelated_or_incomplete_fields", + "p412\ncnginx\nn192.168.1.10:80\nn[::1]:443\nn*:48080\npinvalid\ncPython\nn*:80\np501\nn*:443\n", + ), + ] { + let mut inspection = LowPortInspection { + ports: [80, 443] + .into_iter() + .map(|port| LowPortState { + port, + available: false, + owners: Vec::new(), + }) + .collect(), + }; + parse_lsof_owners(fields, &mut inspection.ports); + assert!(inspection.ports.iter().all(|port| !port.available)); + assert_debug_snapshot!(name, inspection); + } + } + + #[test] + fn lsof_owners_are_capped_and_free_ports_have_no_owners() { + let fields = (1..=10) + .map(|pid| format!("p{pid}\ncnginx\nn*:80\nn*:443\n")) + .collect::(); + let mut states = vec![ + LowPortState { + port: 80, + available: false, + owners: Vec::new(), + }, + LowPortState { + port: 443, + available: true, + owners: Vec::new(), + }, + ]; + parse_lsof_owners(&fields, &mut states); + assert_eq!(states[0].owners.len(), 8); + assert!(states[1].owners.is_empty()); + assert_debug_snapshot!(states); + } + + #[cfg(target_os = "macos")] + #[test] + fn inspection_finds_the_process_holding_a_high_port() -> anyhow::Result<()> { + let listener = TcpListener::bind(("127.0.0.1", 0))?; + let port = listener.local_addr()?.port(); + let inspection = inspect_loopback_ports(&[port])?; + assert_eq!(inspection.ports.len(), 1); + assert!(!inspection.ports[0].available); + assert!( + inspection.ports[0] + .owners + .iter() + .any(|owner| owner.pid == std::process::id()) + ); + Ok(()) + } +} diff --git a/crates/platform/src/pf.rs b/crates/platform/src/pf.rs index 4ee1b54f..f1cbd180 100644 --- a/crates/platform/src/pf.rs +++ b/crates/platform/src/pf.rs @@ -282,7 +282,7 @@ fn pfctl_permission_denied(error: &PlatformError) -> bool { pub fn inspect_active_pf_redirects_unprivileged() -> Result { inspect_active_pf_redirects_unprivileged_with_runner(&mut |program, args| { - run_system_command_output_with_timeout(program, args, PFCTL_INSPECTION_TIMEOUT) + run_system_command_output_with_timeout(program, args, PFCTL_INSPECTION_TIMEOUT, None) }) } @@ -717,31 +717,6 @@ pub(crate) fn apply_pf_redirects_privileged( ) } -#[cfg(target_os = "macos")] -pub(crate) fn reload_pf_redirects_privileged() -> Result<(), PlatformError> { - let system_anchor_path = Utf8Path::new(SYSTEM_PF_ANCHOR_PATH); - let system_pf_conf_path = Utf8Path::new(SYSTEM_PF_CONF_PATH); - match inspect_pf_anchor_file(system_anchor_path, None) { - PfFileState::Current { .. } => {} - state => { - return Err(PlatformError::SystemIntegration(format!( - "PV PF anchor is not reloadable: {state:?}" - ))); - } - } - match inspect_pf_conf_reference(system_pf_conf_path, None) { - PfFileState::Current { .. } => {} - state => { - return Err(PlatformError::SystemIntegration(format!( - "PV pf.conf reference is not reloadable: {state:?}" - ))); - } - } - crate::helper::validate_root_owned_file_if_present(system_anchor_path)?; - crate::helper::validate_root_owned_file_if_present(system_pf_conf_path)?; - reload_pf_with_runner(system_pf_conf_path, &mut run_system_command) -} - #[cfg(target_os = "macos")] pub(crate) fn remove_pf_redirects_privileged() -> Result<(), PlatformError> { let system_anchor_path = Utf8Path::new(SYSTEM_PF_ANCHOR_PATH); diff --git a/crates/platform/src/snapshots/platform__command__tests__bounded_command_accepts_only_the_requested_empty_exit_status.snap b/crates/platform/src/snapshots/platform__command__tests__bounded_command_accepts_only_the_requested_empty_exit_status.snap new file mode 100644 index 00000000..6da904e3 --- /dev/null +++ b/crates/platform/src/snapshots/platform__command__tests__bounded_command_accepts_only_the_requested_empty_exit_status.snap @@ -0,0 +1,30 @@ +--- +source: crates/platform/src/command.rs +expression: verdicts +--- +[ + ( + "empty exit 1", + Ok( + "", + ), + ), + ( + "nonempty stdout", + Err( + "system integration command `/bin/sh -c printf owner; exit 1` exited with exit status: 1", + ), + ), + ( + "nonempty stderr", + Err( + "system integration command `/bin/sh -c printf failure >&2; exit 1` exited with exit status: 1: failure", + ), + ), + ( + "different exit status", + Err( + "system integration command `/bin/sh -c exit 2` exited with exit status: 2", + ), + ), +] diff --git a/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap b/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap new file mode 100644 index 00000000..f5165df9 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap @@ -0,0 +1,18 @@ +--- +source: crates/platform/src/low_port.rs +expression: inspection +--- +LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], +} diff --git a/crates/platform/src/snapshots/platform__low_port__tests__lsof_owners_are_capped_and_free_ports_have_no_owners.snap b/crates/platform/src/snapshots/platform__low_port__tests__lsof_owners_are_capped_and_free_ports_have_no_owners.snap new file mode 100644 index 00000000..1b1567cd --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__lsof_owners_are_capped_and_free_ports_have_no_owners.snap @@ -0,0 +1,49 @@ +--- +source: crates/platform/src/low_port.rs +expression: states +--- +[ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 1, + command: "nginx", + }, + PortOwner { + pid: 2, + command: "nginx", + }, + PortOwner { + pid: 3, + command: "nginx", + }, + PortOwner { + pid: 4, + command: "nginx", + }, + PortOwner { + pid: 5, + command: "nginx", + }, + PortOwner { + pid: 6, + command: "nginx", + }, + PortOwner { + pid: 7, + command: "nginx", + }, + PortOwner { + pid: 8, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: true, + owners: [], + }, +] diff --git a/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap b/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap new file mode 100644 index 00000000..ab4c1170 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap @@ -0,0 +1,23 @@ +--- +source: crates/platform/src/low_port.rs +expression: inspection +--- +LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], +} diff --git a/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap b/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap new file mode 100644 index 00000000..bda37797 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap @@ -0,0 +1,40 @@ +--- +source: crates/platform/src/low_port.rs +expression: inspection +--- +LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + PortOwner { + pid: 413, + command: "nginx", + }, + PortOwner { + pid: 414, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + PortOwner { + pid: 413, + command: "nginx", + }, + ], + }, + ], +} diff --git a/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap b/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap new file mode 100644 index 00000000..6a69a650 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap @@ -0,0 +1,28 @@ +--- +source: crates/platform/src/low_port.rs +expression: inspection +--- +LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [ + PortOwner { + pid: 501, + command: "Python", + }, + ], + }, + ], +} diff --git a/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap b/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap new file mode 100644 index 00000000..f5165df9 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap @@ -0,0 +1,18 @@ +--- +source: crates/platform/src/low_port.rs +expression: inspection +--- +LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], +} diff --git a/crates/platform/tests/resolver_config.rs b/crates/platform/tests/resolver_config.rs index 944e589c..7d679334 100644 --- a/crates/platform/tests/resolver_config.rs +++ b/crates/platform/tests/resolver_config.rs @@ -1,13 +1,9 @@ use std::fmt::Debug; use std::io::Cursor; -#[cfg(target_os = "macos")] -use std::net::{Ipv4Addr, Ipv6Addr, TcpListener}; use anyhow::Result; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; -#[cfg(target_os = "macos")] -use platform::loopback_tcp_listener_ports; use platform::{ CaFileState, CaRepairReason, GeneratedLocalCa, KeychainCertificate, KeychainTrustResult, LocalCaMetadata, PfConfReference, PfRedirectConfig, ResolverConfig, SystemTrustInspector, @@ -260,43 +256,6 @@ fn pf_conf_reference_inspection_reports_missing_current_stale_conflict_and_unrea Ok(()) } -#[test] -#[cfg(target_os = "macos")] -fn pf_loopback_tcp_listener_ports_include_ipv4_wildcard_listener() -> Result<()> { - let listener = TcpListener::bind((Ipv4Addr::UNSPECIFIED, 0))?; - let port = listener.local_addr()?.port(); - let ports = loopback_tcp_listener_ports()?; - let detection = vec![("ipv4 wildcard listener detected", ports.contains(&port))]; - - assert_debug_snapshot!(detection); - - Ok(()) -} - -#[test] -#[cfg(target_os = "macos")] -fn pf_loopback_tcp_listener_ports_include_ipv6_loopback_and_wildcard_listeners() -> Result<()> { - let loopback_listener = TcpListener::bind((Ipv6Addr::LOCALHOST, 0))?; - let wildcard_listener = TcpListener::bind((Ipv6Addr::UNSPECIFIED, 0))?; - let loopback_port = loopback_listener.local_addr()?.port(); - let wildcard_port = wildcard_listener.local_addr()?.port(); - let ports = loopback_tcp_listener_ports()?; - let detections = vec![ - ( - "ipv6 loopback listener detected", - ports.contains(&loopback_port), - ), - ( - "ipv6 wildcard listener detected", - ports.contains(&wildcard_port), - ), - ]; - - assert_debug_snapshot!(detections); - - Ok(()) -} - #[test] fn local_ca_generation_produces_matching_pv_root_certificate_and_key() -> Result<()> { let generated: GeneratedLocalCa = generate_local_ca()?; diff --git a/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv4_wildcard_listener.snap b/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv4_wildcard_listener.snap deleted file mode 100644 index f0f17484..00000000 --- a/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv4_wildcard_listener.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/platform/tests/resolver_config.rs -expression: detection ---- -[ - ( - "ipv4 wildcard listener detected", - true, - ), -] diff --git a/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv6_loopback_and_wildcard_listeners.snap b/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv6_loopback_and_wildcard_listeners.snap deleted file mode 100644 index f58a99aa..00000000 --- a/crates/platform/tests/snapshots/resolver_config__pf_loopback_tcp_listener_ports_include_ipv6_loopback_and_wildcard_listeners.snap +++ /dev/null @@ -1,14 +0,0 @@ ---- -source: crates/platform/tests/resolver_config.rs -expression: detections ---- -[ - ( - "ipv6 loopback listener detected", - true, - ), - ( - "ipv6 wildcard listener detected", - true, - ), -] diff --git a/crates/platform/tests/unsupported_listener.rs b/crates/platform/tests/unsupported_listener.rs deleted file mode 100644 index 3767cfea..00000000 --- a/crates/platform/tests/unsupported_listener.rs +++ /dev/null @@ -1,32 +0,0 @@ -#![cfg(not(target_os = "macos"))] - -use platform::{ - PlatformCapability, PlatformError, PlatformTarget, loopback_tcp_listener_ports, - loopback_tcp_port_has_listener, -}; - -#[test] -fn public_listener_inspection_rejects_unsupported_platform_before_inspection() -> anyhow::Result<()> -{ - let target = PlatformTarget::current()?; - - let ports_result = loopback_tcp_listener_ports(); - assert!(matches!( - ports_result, - Err(PlatformError::Unsupported { - capability: PlatformCapability::ListenerInspection, - target: error_target, - }) if error_target == target - )); - - let port_result = loopback_tcp_port_has_listener(45_000); - assert!(matches!( - port_result, - Err(PlatformError::Unsupported { - capability: PlatformCapability::ListenerInspection, - target: error_target, - }) if error_target == target - )); - - Ok(()) -} diff --git a/crates/privileged-helper/Cargo.toml b/crates/privileged-helper/Cargo.toml index e039bb3b..75f4defa 100644 --- a/crates/privileged-helper/Cargo.toml +++ b/crates/privileged-helper/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "pv-privileged-helper" -version = "1.0.0" +version = "2.0.0" edition.workspace = true publish.workspace = true From 4abd9b9c301269c6da0f1e17033a012e284d4b05 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 8 Oct 2026 01:26:33 -0400 Subject: [PATCH 2/4] fix(helper): preserve owners for single-port conflicts --- crates/platform/src/low_port.rs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/platform/src/low_port.rs b/crates/platform/src/low_port.rs index 6c18bff8..70579ee0 100644 --- a/crates/platform/src/low_port.rs +++ b/crates/platform/src/low_port.rs @@ -60,13 +60,16 @@ pub(crate) fn inspect_loopback_ports(ports: &[u16]) -> Result>(); + let port_list = ports + .iter() + .map(u16::to_string) + .collect::>() + .join(","); + // One selector avoids failure when only some requested ports have listeners. + let selector = format!("-iTCP:{port_list}"); let output = run_system_command_output_with_timeout( "/usr/sbin/lsof", - &arguments, + &["-nP", &selector, "-sTCP:LISTEN", "-F", "pcn"], Duration::from_secs(2), Some(1), )?; @@ -175,11 +178,14 @@ mod tests { #[cfg(target_os = "macos")] #[test] - fn inspection_finds_the_process_holding_a_high_port() -> anyhow::Result<()> { + fn inspection_finds_one_held_port_when_the_other_is_free() -> anyhow::Result<()> { let listener = TcpListener::bind(("127.0.0.1", 0))?; let port = listener.local_addr()?.port(); - let inspection = inspect_loopback_ports(&[port])?; - assert_eq!(inspection.ports.len(), 1); + let unused_listener = TcpListener::bind(("127.0.0.1", 0))?; + let unused_port = unused_listener.local_addr()?.port(); + drop(unused_listener); + let inspection = inspect_loopback_ports(&[port, unused_port])?; + assert_eq!(inspection.ports.len(), 2); assert!(!inspection.ports[0].available); assert!( inspection.ports[0] @@ -187,6 +193,8 @@ mod tests { .iter() .any(|owner| owner.pid == std::process::id()) ); + assert!(inspection.ports[1].available); + assert!(inspection.ports[1].owners.is_empty()); Ok(()) } } From b31631ad2f04aabaa87b685831364049780acc66 Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 8 Oct 2026 12:38:18 -0400 Subject: [PATCH 3/4] fix(helper): wait for launchd socket after bootstrap --- DESIGN.md | 2 +- crates/platform/src/helper.rs | 102 +++++++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 4 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9ee5ce9e..708cd9f1 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -316,7 +316,7 @@ The socket is restricted to the installing account, and the helper verifies the `sudo` is used only by foreground helper lifecycle operations: initial install, replacement, repair, and removal. Initial setup therefore requires one administrator authentication, while later typed DNS, PF, and CA repairs continue without new prompts after sudo timestamp expiry or reboot. The helper installation path verifies the candidate checksum and ad-hoc code signature before replacing the root-owned executable and launchd registration. -Helper install, replacement, and removal hold a persistent-file advisory lock under `/Library/PrivilegedHelperTools` for the full root-owned lifecycle transaction. This machine-wide lock serializes the fixed candidate, rollback, executable, metadata, plist, and launchd paths across macOS accounts. Setup, update, and uninstall also hold a persistent per-account lock at `~/.pv-helper-lifecycle.lock` across helper-dependent integration and state changes; keeping it outside `~/.pv` lets `pv uninstall --prune` remain serialized while removing that tree. +Helper install, replacement, and removal hold a persistent-file advisory lock under `/Library/PrivilegedHelperTools` for the full root-owned lifecycle transaction. This machine-wide lock serializes the fixed candidate, rollback, executable, metadata, plist, and launchd paths across macOS accounts. After registering or restoring the launchd job, the lifecycle operation waits up to five seconds for its control socket to become available before failing. Authentication and invalid-response errors fail immediately. Setup, update, and uninstall also hold a persistent per-account lock at `~/.pv-helper-lifecycle.lock` across helper-dependent integration and state changes; keeping it outside `~/.pv` lets `pv uninstall --prune` remain serialized while removing that tree. `pv doctor` remains unprivileged and read-only. It reports helper availability, helper version, and helper protocol, uses narrowly scoped helper inspections when direct DNS, PF, or CA inspection is blocked, and continues reporting all other checks when the helper is unavailable. diff --git a/crates/platform/src/helper.rs b/crates/platform/src/helper.rs index 2f0bc8f5..0c9b2891 100644 --- a/crates/platform/src/helper.rs +++ b/crates/platform/src/helper.rs @@ -7,7 +7,7 @@ use std::io::Write; #[cfg(target_os = "macos")] use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _}; #[cfg(target_os = "macos")] -use std::time::Duration; +use std::time::{Duration, Instant}; use camino::Utf8Path; #[cfg(target_os = "macos")] @@ -469,7 +469,7 @@ fn install_privileged_helper_macos( "system", HELPER_LAUNCH_DAEMON_PATH, ])?; - let status = probe_helper_lifecycle(Utf8Path::new(HELPER_SOCKET_PATH))?; + let status = wait_for_helper_lifecycle(Utf8Path::new(HELPER_SOCKET_PATH))?; if status.version != helper_version || status.protocol_version != protocol_version || status.owner_uid != owner_uid @@ -633,7 +633,7 @@ fn rollback_helper_installation( ]) { rollback_errors.push(error.to_string()); } else if let Some(expected_status) = &installed_state.previous_status { - match probe_helper_lifecycle(Utf8Path::new(HELPER_SOCKET_PATH)) { + match wait_for_helper_lifecycle(Utf8Path::new(HELPER_SOCKET_PATH)) { Ok(status) if status == *expected_status => {} Ok(status) => rollback_errors.push(format!( "restored helper identity {status:?} did not match previous identity {expected_status:?}" @@ -1249,6 +1249,24 @@ fn probe_helper_lifecycle(socket_path: &Utf8Path) -> Result Result { + let started_at = Instant::now(); + loop { + match probe_helper_lifecycle(socket_path) { + Err(PlatformError::PrivilegedHelperUnavailable) + if started_at.elapsed() < HELPER_IO_TIMEOUT => + { + std::thread::sleep(Duration::from_millis(25)); + } + result => return result, + } + } +} + #[cfg(any(target_os = "macos", test))] fn parse_helper_lifecycle_response( response: &[u8], @@ -1850,6 +1868,8 @@ fn helper_error_code(error: &PlatformError) -> HelperErrorCode { #[cfg(test)] mod tests { + #[cfg(target_os = "macos")] + use std::io::Write as _; use std::io::{Cursor, ErrorKind}; #[cfg(target_os = "macos")] use std::net::TcpListener; @@ -1872,6 +1892,7 @@ mod tests { PRIVILEGED_HELPER_VERSION, call_helper, dispatch_request, lock_machine_helper_lifecycle_file, probe_helper_lifecycle, render_launch_daemon_plist, restore_helper_file, serve_next_helper_connection, validate_root_owned_regular_file, + wait_for_helper_lifecycle, }; use super::{ HelperRequest, MAX_MESSAGE_BYTES, PrivilegedHelperMetadata, helper_artifacts_present, @@ -2086,6 +2107,81 @@ mod tests { Ok(()) } + #[cfg(target_os = "macos")] + #[test] + #[expect( + clippy::disallowed_methods, + reason = "helper test fixture needs a blocking Unix socket server" + )] + fn helper_bootstrap_waits_for_delayed_socket_creation() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let socket_path = tempdir.path().join("helper.sock"); + let server_path = socket_path.clone(); + let owner_uid = rustix::process::getuid().as_raw(); + let server = thread::spawn(move || { + thread::sleep(Duration::from_millis(75)); + let listener = std::os::unix::net::UnixListener::bind(server_path)?; + serve_next_helper_connection( + &listener, + &PrivilegedHelperMetadata { + owner_uid, + helper_version: PRIVILEGED_HELPER_VERSION.to_owned(), + protocol_version: HELPER_PROTOCOL_VERSION, + }, + ) + }); + let status = wait_for_helper_lifecycle(&socket_path)?; + server + .join() + .map_err(|_error| anyhow!("helper fixture thread panicked"))??; + assert_eq!(status.version, PRIVILEGED_HELPER_VERSION); + assert_eq!(status.protocol_version, HELPER_PROTOCOL_VERSION); + assert_eq!(status.owner_uid, owner_uid); + Ok(()) + } + + #[cfg(target_os = "macos")] + #[test] + fn helper_bootstrap_wait_stops_when_the_socket_never_appears() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let result = wait_for_helper_lifecycle(&tempdir.path().join("missing.sock")); + assert!(matches!( + result, + Err(PlatformError::PrivilegedHelperUnavailable) + )); + Ok(()) + } + + #[cfg(target_os = "macos")] + #[test] + #[expect( + clippy::disallowed_methods, + reason = "helper test fixture needs a blocking Unix socket server" + )] + fn helper_bootstrap_wait_rejects_an_invalid_response() -> anyhow::Result<()> { + let tempdir = tempdir()?; + let socket_path = tempdir.path().join("helper.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket_path)?; + let server = thread::spawn(move || -> Result<(), PlatformError> { + let (mut stream, _address) = listener + .accept() + .map_err(PlatformError::PrivilegedHelperIo)?; + read_frame(&mut stream)?; + stream + .write_all(b"invalid\n") + .map_err(PlatformError::PrivilegedHelperIo) + }); + let result = wait_for_helper_lifecycle(&socket_path); + server + .join() + .map_err(|_error| anyhow!("helper fixture thread panicked"))??; + assert!(matches!( + result, + Err(PlatformError::PrivilegedHelperInstallation(_)) + )); + Ok(()) + } + #[test] fn protocol_rejects_unknown_fields() { let mut message = Cursor::new( From b64bd7aff9cc3da8b069d4961d0655bb406eba2f Mon Sep 17 00:00:00 2001 From: Clovis Muneza Date: Thu, 8 Oct 2026 14:36:16 -0400 Subject: [PATCH 4/4] test(helper): gate root port conflicts and clarify listener diagnostics --- .github/workflows/ci.yml | 24 +++ Cargo.lock | 1 + DESIGN.md | 2 +- ...ict_before_writing_prepared_artifacts.snap | 2 +- ...__ports_install_names_low_port_owners.snap | 2 +- ...conflict_when_redirects_already_match.snap | 2 +- crates/platform/Cargo.toml | 3 + crates/platform/src/helper.rs | 172 ++++++++++++++++-- crates/platform/src/low_port.rs | 18 +- ...latform__helper__tests__root_port_443.snap | 26 +++ ...platform__helper__tests__root_port_80.snap | 26 +++ ...atform__low_port__tests__empty_output.snap | 34 ++-- ...__tests__mixed_ipv4_and_ipv6_wildcard.snap | 33 ++++ ...latform__low_port__tests__named_owner.snap | 44 +++-- ...low_port__tests__nginx_master_workers.snap | 78 ++++---- ...atform__low_port__tests__several_pids.snap | 54 +++--- ...tests__unrelated_or_incomplete_fields.snap | 34 ++-- 17 files changed, 423 insertions(+), 132 deletions(-) create mode 100644 crates/platform/src/snapshots/platform__helper__tests__root_port_443.snap create mode 100644 crates/platform/src/snapshots/platform__helper__tests__root_port_80.snap create mode 100644 crates/platform/src/snapshots/platform__low_port__tests__mixed_ipv4_and_ipv6_wildcard.snap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88c70ad5..2b35a9f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,30 @@ jobs: enabled = true EOF + - name: Check root low-port conflicts against a separate process + timeout-minutes: 10 + shell: bash + run: | + set -euo pipefail + cargo build --locked --all-features -p daemon --example pv-fake + cargo nextest list --locked --all-features -p platform --lib --run-ignored only \ + -E 'test(=helper::tests::pf_apply_rejects_a_low_port_conflict_as_root)' \ + --message-format json > "$RUNNER_TEMP/low-port-tests.json" + test_binary=$(python3 - "$RUNNER_TEMP/low-port-tests.json" <<'PY' + import json + import sys + with open(sys.argv[1]) as source: + tests = json.load(source) + suite = tests["rust-suites"]["platform"] + selected = [name for name, case in suite["testcases"].items() + if case["filter-match"]["status"] == "matches"] + assert selected == ["helper::tests::pf_apply_rejects_a_low_port_conflict_as_root"], selected + assert suite["testcases"][selected[0]]["ignored"] + print(suite["binary-path"]) + PY + ) + sudo -n "$test_binary" --ignored --exact helper::tests::pf_apply_rejects_a_low_port_conflict_as_root + - name: Run tests id: tests run: cargo nextest run --profile ci --user-config-file "$RUNNER_TEMP/nextest-user.toml" --workspace --all-features --locked diff --git a/Cargo.lock b/Cargo.lock index 9789b70c..b5b80a33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2897,6 +2897,7 @@ dependencies = [ "libc", "nix 0.31.3", "plist", + "pv-fake", "raunch", "rcgen", "rustix", diff --git a/DESIGN.md b/DESIGN.md index 708cd9f1..df40b3f6 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -162,7 +162,7 @@ The Gateway listens as the user on high loopback ports only. PV prefers uncommon PV v1 does not expose Projects on the LAN or through tunnels. LAN access or tunnel integrations such as Cloudflare Tunnels may be considered later. -Before installing redirects, `pv setup` and `pv ports:install` ask the privileged helper whether loopback ports `80` and `443` are free, and the helper checks again before applying pf rules. The helper decides with a root bind probe on `127.0.0.1` and `0.0.0.0` that ignores closing connections. If either port is in use, PV fails with a clear conflict instead of silently taking over traffic, and names the owning process and pid when `lsof` can identify it. +Before installing redirects, `pv setup` and `pv ports:install` ask the privileged helper whether loopback ports `80` and `443` are free, and the helper checks again before applying pf rules. The helper decides with a root bind probe on `127.0.0.1` and `0.0.0.0` that ignores closing connections. If either port is unavailable, PV fails with a clear conflict instead of silently taking over traffic. `lsof` supplies listener names and pids as diagnostic information only: its wildcard address does not distinguish an IPv6-only listener from a dual-stack listener, so it cannot prove which process caused an IPv4 bind failure. ### PF Health and Recovery diff --git a/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap b/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap index 1ee8ca31..8c626375 100644 --- a/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap +++ b/crates/cli/tests/snapshots/ports__ports_install_fails_on_low_port_conflict_before_writing_prepared_artifacts.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use; PV could not identify the process.\n find it: sudo lsof -nP -iTCP:80 -sTCP:LISTEN\n Stop the conflicting service, then run `pv ports:install` again.\n", + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is unavailable; PV could not identify a listening process.\n find it: sudo lsof -nP -iTCP:80 -sTCP:LISTEN\n Stop the conflicting service, then run `pv ports:install` again.\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap b/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap index 81652f69..52c72053 100644 --- a/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap +++ b/crates/cli/tests/snapshots/ports__ports_install_names_low_port_owners.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use by nginx (pid 412).\n Loopback TCP port 443 is in use by Python (pid 501).\n Stop the conflicting service, then run `pv ports:install` again.\n", + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412).\n Loopback TCP port 443 is unavailable. TCP listeners reported on this port: Python (pid 501).\n Stop the conflicting service, then run `pv ports:install` again.\n", stderr: "", } diff --git a/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap b/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap index 6df70d56..28586425 100644 --- a/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap +++ b/crates/cli/tests/snapshots/ports__ports_install_refuses_a_new_low_port_conflict_when_redirects_already_match.snap @@ -8,6 +8,6 @@ RunOutput { 1, ), ), - stdout: "Port redirect preparation failed\n Loopback TCP port 80 is in use by nginx (pid 412).\n Stop the conflicting service, then run `pv ports:install` again.\n", + stdout: "Port redirect preparation failed\n Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412).\n Stop the conflicting service, then run `pv ports:install` again.\n", stderr: "", } diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index 7505960e..111483e1 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -36,3 +36,6 @@ security-framework = { workspace = true } anyhow = { workspace = true } camino-tempfile = { workspace = true } insta = { workspace = true } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +pv-fake = { path = "../pv-fake" } diff --git a/crates/platform/src/helper.rs b/crates/platform/src/helper.rs index 0c9b2891..a93045da 100644 --- a/crates/platform/src/helper.rs +++ b/crates/platform/src/helper.rs @@ -1872,17 +1872,23 @@ mod tests { use std::io::Write as _; use std::io::{Cursor, ErrorKind}; #[cfg(target_os = "macos")] - use std::net::TcpListener; - #[cfg(target_os = "macos")] use std::os::unix::fs::symlink; #[cfg(target_os = "macos")] + use std::process::{Child, Stdio}; + #[cfg(target_os = "macos")] use std::thread; #[cfg(unix)] use std::time::Duration; + #[cfg(target_os = "macos")] + use std::time::Instant; #[cfg(target_os = "macos")] use anyhow::anyhow; use camino_tempfile::tempdir; + #[cfg(target_os = "macos")] + use insta::{Settings, assert_debug_snapshot}; + #[cfg(target_os = "macos")] + use pv_fake::{EventKind, FakeSettings, InstalledFake, Persona, TcpHolderConfig}; #[cfg(unix)] use super::{HELPER_PROTOCOL_VERSION, HelperOperation, write_message}; @@ -2197,35 +2203,165 @@ mod tests { #[cfg(target_os = "macos")] #[test] - #[ignore = "requires root and a free loopback port 80"] + #[ignore = "requires root and free loopback ports 80 and 443; run in every macOS CI lane"] fn pf_apply_rejects_a_low_port_conflict_as_root() -> anyhow::Result<()> { if !rustix::process::geteuid().is_root() { return Err(anyhow!("run this test as root")); } - let listener = TcpListener::bind(("127.0.0.1", 80))?; let metadata = PrivilegedHelperMetadata { owner_uid: rustix::process::getuid().as_raw(), helper_version: PRIVILEGED_HELPER_VERSION.to_owned(), protocol_version: HELPER_PROTOCOL_VERSION, }; - let result = dispatch_request( - HelperRequest { - protocol_version: HELPER_PROTOCOL_VERSION, - operation: HelperOperation::PfApply { - http_port: 48080, - https_port: 48443, + for port in [80, 443] { + let free = crate::low_port::inspect_loopback_ports(&[80, 443])?; + assert!(free.ports.iter().all(|state| state.available)); + let before = crate::pf::inspect_active_pf_redirects_unprivileged()?; + let tempdir = tempdir()?; + let fake = pv_fake::install_with_settings( + &tempdir.path().join("tcp-holder"), + Persona::TcpHolder, + FakeSettings { + tcp_holder: Some(TcpHolderConfig { + address: format!("127.0.0.1:{port}").parse()?, + reuse_address: true, + reuse_port: false, + ipv6_only: false, + listen: true, + }), + ..FakeSettings::default() }, - }, - &metadata, - ); - drop(listener); - let Err(PlatformError::SystemIntegration(message)) = result else { - return Err(anyhow!("PfApply did not reject the held low port")); - }; - assert!(message.contains(&format!("(pid {})", std::process::id()))); + )?; + let mut holder = LowPortHolder( + HolderCommand::new(fake.executable()) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .spawn()?, + ); + holder.wait_ready(&fake)?; + let pid = holder.0.id(); + assert_ne!(pid, std::process::id()); + let result = (|| -> anyhow::Result<()> { + let inspection = dispatch_request( + HelperRequest { + protocol_version: HELPER_PROTOCOL_VERSION, + operation: HelperOperation::LowPortInspect, + }, + &metadata, + )?; + let HelperPayload::LowPorts(inspection) = inspection else { + return Err(anyhow!("unexpected LowPortInspect response")); + }; + let held = inspection + .ports + .iter() + .find(|state| state.port == port) + .ok_or_else(|| anyhow!("inspection omitted port {port}"))?; + assert!(!held.available); + assert_eq!(held.owners.len(), 1); + assert_eq!(held.owners[0].pid, pid); + assert!( + inspection + .ports + .iter() + .filter(|state| state.port != port) + .all(|state| state.available && state.owners.is_empty()) + ); + let result = dispatch_request( + HelperRequest { + protocol_version: HELPER_PROTOCOL_VERSION, + operation: HelperOperation::PfApply { + http_port: 48080, + https_port: 48443, + }, + }, + &metadata, + ); + let Err(PlatformError::SystemIntegration(message)) = result else { + return Err(anyhow!( + "PfApply did not reject the held low port: {result:?}" + )); + }; + assert_eq!(message, held.conflict_message()); + let mut settings = Settings::clone_current(); + settings.add_filter(&format!(r"\b{pid}\b"), ""); + settings.bind(|| { + assert_debug_snapshot!(format!("root_port_{port}"), (inspection, message)) + }); + assert_eq!( + crate::pf::inspect_active_pf_redirects_unprivileged()?, + before + ); + Ok(()) + })(); + let cleanup = holder.stop(); + if let Err(error) = cleanup { + return Err(anyhow!( + "root low-port check: {result:?}; holder cleanup failed: {error:#}" + )); + } + result?; + let free = crate::low_port::inspect_loopback_ports(&[80, 443])?; + assert!( + free.ports + .iter() + .all(|state| state.available && state.owners.is_empty()) + ); + } Ok(()) } + #[cfg(target_os = "macos")] + #[expect( + clippy::disallowed_types, + reason = "root acceptance test owns and reaps its separate TCP holder" + )] + type HolderCommand = std::process::Command; + + #[cfg(target_os = "macos")] + struct LowPortHolder(Child); + + #[cfg(target_os = "macos")] + impl LowPortHolder { + fn wait_ready(&mut self, fake: &InstalledFake) -> anyhow::Result<()> { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + if fake + .events()? + .iter() + .any(|event| matches!(event.kind, EventKind::TcpReady { .. })) + { + return Ok(()); + } + if let Some(status) = self.0.try_wait()? { + return Err(anyhow!("TCP holder exited before readiness: {status}")); + } + if Instant::now() >= deadline { + return Err(anyhow!("TCP holder did not report readiness")); + } + thread::sleep(Duration::from_millis(10)); + } + } + + fn stop(&mut self) -> anyhow::Result<()> { + if self.0.try_wait()?.is_none() { + self.0.kill()?; + } + self.0.wait()?; + Ok(()) + } + } + + #[cfg(target_os = "macos")] + impl Drop for LowPortHolder { + fn drop(&mut self) { + if let Err(error) = self.stop() { + let _write_result = + writeln!(std::io::stderr(), "TCP holder cleanup failed: {error:#}"); + } + } + } + #[test] fn protocol_rejects_the_removed_pf_reload_operation() { let mut message = Cursor::new( diff --git a/crates/platform/src/low_port.rs b/crates/platform/src/low_port.rs index 70579ee0..f7aecaf1 100644 --- a/crates/platform/src/low_port.rs +++ b/crates/platform/src/low_port.rs @@ -30,7 +30,7 @@ impl LowPortState { pub fn conflict_message(&self) -> String { if self.owners.is_empty() { return format!( - "Loopback TCP port {} is in use; PV could not identify the process.", + "Loopback TCP port {} is unavailable; PV could not identify a listening process.", self.port ); } @@ -40,7 +40,10 @@ impl LowPortState { .map(|owner| format!("{} (pid {})", owner.command, owner.pid)) .collect::>() .join(", "); - format!("Loopback TCP port {} is in use by {owners}.", self.port) + format!( + "Loopback TCP port {} is unavailable. TCP listeners reported on this port: {owners}.", + self.port + ) } } @@ -132,6 +135,10 @@ mod tests { "p412\ncnginx\nn*:80\nn*:80\nn*:443\np413\ncnginx\nn*:80\nn*:443\np414\ncnginx\nn*:80\n", ), ("empty_output", ""), + ( + "mixed_ipv4_and_ipv6_wildcard", + "p412\ncnginx\nn127.0.0.1:80\np501\ncPython\nn*:80\n", + ), ( "unrelated_or_incomplete_fields", "p412\ncnginx\nn192.168.1.10:80\nn[::1]:443\nn*:48080\npinvalid\ncPython\nn*:80\np501\nn*:443\n", @@ -149,7 +156,12 @@ mod tests { }; parse_lsof_owners(fields, &mut inspection.ports); assert!(inspection.ports.iter().all(|port| !port.available)); - assert_debug_snapshot!(name, inspection); + let messages = inspection + .ports + .iter() + .map(LowPortState::conflict_message) + .collect::>(); + assert_debug_snapshot!(name, (inspection, messages)); } } diff --git a/crates/platform/src/snapshots/platform__helper__tests__root_port_443.snap b/crates/platform/src/snapshots/platform__helper__tests__root_port_443.snap new file mode 100644 index 00000000..df201d7c --- /dev/null +++ b/crates/platform/src/snapshots/platform__helper__tests__root_port_443.snap @@ -0,0 +1,26 @@ +--- +source: crates/platform/src/helper.rs +expression: "(inspection, message)" +--- +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: true, + owners: [], + }, + LowPortState { + port: 443, + available: false, + owners: [ + PortOwner { + pid: , + command: "tcp-holder", + }, + ], + }, + ], + }, + "Loopback TCP port 443 is unavailable. TCP listeners reported on this port: tcp-holder (pid ).", +) diff --git a/crates/platform/src/snapshots/platform__helper__tests__root_port_80.snap b/crates/platform/src/snapshots/platform__helper__tests__root_port_80.snap new file mode 100644 index 00000000..4cfc6345 --- /dev/null +++ b/crates/platform/src/snapshots/platform__helper__tests__root_port_80.snap @@ -0,0 +1,26 @@ +--- +source: crates/platform/src/helper.rs +expression: "(inspection, message)" +--- +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: , + command: "tcp-holder", + }, + ], + }, + LowPortState { + port: 443, + available: true, + owners: [], + }, + ], + }, + "Loopback TCP port 80 is unavailable. TCP listeners reported on this port: tcp-holder (pid ).", +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap b/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap index f5165df9..76796dd5 100644 --- a/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap +++ b/crates/platform/src/snapshots/platform__low_port__tests__empty_output.snap @@ -1,18 +1,24 @@ --- source: crates/platform/src/low_port.rs -expression: inspection +expression: "(inspection, messages)" --- -LowPortInspection { - ports: [ - LowPortState { - port: 80, - available: false, - owners: [], - }, - LowPortState { - port: 443, - available: false, - owners: [], - }, +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable; PV could not identify a listening process.", + "Loopback TCP port 443 is unavailable; PV could not identify a listening process.", ], -} +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__mixed_ipv4_and_ipv6_wildcard.snap b/crates/platform/src/snapshots/platform__low_port__tests__mixed_ipv4_and_ipv6_wildcard.snap new file mode 100644 index 00000000..34660ea0 --- /dev/null +++ b/crates/platform/src/snapshots/platform__low_port__tests__mixed_ipv4_and_ipv6_wildcard.snap @@ -0,0 +1,33 @@ +--- +source: crates/platform/src/low_port.rs +expression: "(inspection, messages)" +--- +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + PortOwner { + pid: 501, + command: "Python", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412), Python (pid 501).", + "Loopback TCP port 443 is unavailable; PV could not identify a listening process.", + ], +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap b/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap index ab4c1170..1af8d2d6 100644 --- a/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap +++ b/crates/platform/src/snapshots/platform__low_port__tests__named_owner.snap @@ -1,23 +1,29 @@ --- source: crates/platform/src/low_port.rs -expression: inspection +expression: "(inspection, messages)" --- -LowPortInspection { - ports: [ - LowPortState { - port: 80, - available: false, - owners: [ - PortOwner { - pid: 412, - command: "nginx", - }, - ], - }, - LowPortState { - port: 443, - available: false, - owners: [], - }, +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412).", + "Loopback TCP port 443 is unavailable; PV could not identify a listening process.", ], -} +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap b/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap index bda37797..1c2afac3 100644 --- a/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap +++ b/crates/platform/src/snapshots/platform__low_port__tests__nginx_master_workers.snap @@ -1,40 +1,46 @@ --- source: crates/platform/src/low_port.rs -expression: inspection +expression: "(inspection, messages)" --- -LowPortInspection { - ports: [ - LowPortState { - port: 80, - available: false, - owners: [ - PortOwner { - pid: 412, - command: "nginx", - }, - PortOwner { - pid: 413, - command: "nginx", - }, - PortOwner { - pid: 414, - command: "nginx", - }, - ], - }, - LowPortState { - port: 443, - available: false, - owners: [ - PortOwner { - pid: 412, - command: "nginx", - }, - PortOwner { - pid: 413, - command: "nginx", - }, - ], - }, +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + PortOwner { + pid: 413, + command: "nginx", + }, + PortOwner { + pid: 414, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + PortOwner { + pid: 413, + command: "nginx", + }, + ], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412), nginx (pid 413), nginx (pid 414).", + "Loopback TCP port 443 is unavailable. TCP listeners reported on this port: nginx (pid 412), nginx (pid 413).", ], -} +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap b/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap index 6a69a650..7e94175a 100644 --- a/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap +++ b/crates/platform/src/snapshots/platform__low_port__tests__several_pids.snap @@ -1,28 +1,34 @@ --- source: crates/platform/src/low_port.rs -expression: inspection +expression: "(inspection, messages)" --- -LowPortInspection { - ports: [ - LowPortState { - port: 80, - available: false, - owners: [ - PortOwner { - pid: 412, - command: "nginx", - }, - ], - }, - LowPortState { - port: 443, - available: false, - owners: [ - PortOwner { - pid: 501, - command: "Python", - }, - ], - }, +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [ + PortOwner { + pid: 412, + command: "nginx", + }, + ], + }, + LowPortState { + port: 443, + available: false, + owners: [ + PortOwner { + pid: 501, + command: "Python", + }, + ], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable. TCP listeners reported on this port: nginx (pid 412).", + "Loopback TCP port 443 is unavailable. TCP listeners reported on this port: Python (pid 501).", ], -} +) diff --git a/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap b/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap index f5165df9..76796dd5 100644 --- a/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap +++ b/crates/platform/src/snapshots/platform__low_port__tests__unrelated_or_incomplete_fields.snap @@ -1,18 +1,24 @@ --- source: crates/platform/src/low_port.rs -expression: inspection +expression: "(inspection, messages)" --- -LowPortInspection { - ports: [ - LowPortState { - port: 80, - available: false, - owners: [], - }, - LowPortState { - port: 443, - available: false, - owners: [], - }, +( + LowPortInspection { + ports: [ + LowPortState { + port: 80, + available: false, + owners: [], + }, + LowPortState { + port: 443, + available: false, + owners: [], + }, + ], + }, + [ + "Loopback TCP port 80 is unavailable; PV could not identify a listening process.", + "Loopback TCP port 443 is unavailable; PV could not identify a listening process.", ], -} +)