diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c279072..a0649826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,24 @@ permissions: jobs: rust: - name: Rust - runs-on: macos-14 + name: Rust (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: macOS 14 arm64 + runner: macos-14 + macos_major: "14" + architecture: arm64 + - name: macOS 15 Intel + runner: macos-15-intel + macos_major: "15" + architecture: x86_64 + - name: macOS 26 arm64 + runner: macos-26 + macos_major: "26" + architecture: arm64 + runs-on: ${{ matrix.runner }} steps: - name: Checkout @@ -19,6 +35,14 @@ jobs: with: persist-credentials: false + - name: Verify supported macOS host + env: + EXPECTED_ARCHITECTURE: ${{ matrix.architecture }} + EXPECTED_MACOS_MAJOR: ${{ matrix.macos_major }} + run: | + test "$(sw_vers -productVersion | cut -d. -f1)" = "$EXPECTED_MACOS_MAJOR" + test "$(uname -m)" = "$EXPECTED_ARCHITECTURE" + - name: Install Rust uses: dtolnay/rust-toolchain@stable diff --git a/Cargo.lock b/Cargo.lock index cacf8052..0c9132a5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2191,17 +2191,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "netstat-esr" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db13bc9e63aaed8fbd195b1835c1bdcd5d4bc834ba62f3bef459e88c00f0d08" -dependencies = [ - "bitflags 1.3.2", - "cc", - "libc", -] - [[package]] name = "nix" version = "0.31.3" @@ -2423,7 +2412,6 @@ dependencies = [ "getrandom 0.4.2", "insta", "libc", - "netstat-esr", "plist", "rcgen", "rustix", diff --git a/Cargo.toml b/Cargo.toml index 066d09ee..bcee5a13 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,6 @@ indicatif = { version = "0.18.6", default-features = false, features = ["unicode insta = { version = "1", features = ["filters"] } libc = "0.2" linemux = "0.3.0" -netstat = { package = "netstat-esr", version = "0.8.1" } object_store = { version = "0.13.2", default-features = false, features = ["aws"] } rcgen = { version = "0.14.8", features = ["x509-parser"] } redis = { version = "1.2.2", default-features = false, features = ["tokio-comp"] } diff --git a/DESIGN.md b/DESIGN.md index 05bdcd5c..cc8eb998 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -23,7 +23,11 @@ Per-project versions are supported too — add a pv.yml file with php: "8.4" in ## Platform Scope -PV v1 targets macOS 13 and newer. Stabilizing the macOS application remains the immediate product priority. +PV v1 supports macOS 14 and newer. Stabilizing the macOS application remains the immediate product priority. + +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. 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. @@ -893,7 +897,7 @@ PV v1 relies on HTTPS trust for the artifact manifest itself plus SHA-256 verifi Public v1 should support separate Managed Resource artifacts for both Apple Silicon and Intel macOS: `darwin-arm64` and `darwin-amd64`. PV v1 does not use universal macOS Managed Resource artifacts. If build complexity blocks progress, Apple Silicon-only is acceptable for an initial preview, but not as the intended public v1 scope. -Managed Resource artifact recipes set an explicit macOS deployment target of macOS 13.0 unless a later design decision raises PV's minimum supported macOS version. Recipes must not silently inherit a newer GitHub runner deployment target. +Managed Resource artifact recipes retain an explicit macOS deployment target of macOS 13.0 even though PV supports macOS 14 and newer. Keeping otherwise compatible artifacts runnable on macOS 13 does not make that operating system supported. Recipes must not silently inherit a newer GitHub runner deployment target, and raising an artifact deployment target requires a separate compatibility decision. The artifact manifest may use `platform: "any"` only for truly portable artifacts that do not contain platform-specific binaries. Composer is the expected v1 `platform: "any"` artifact because PV packages `composer.phar` inside a PV-owned archive. Native Managed Resource artifacts use explicit platform values such as `darwin-arm64` or `darwin-amd64`. diff --git a/crates/cli/tests/php.rs b/crates/cli/tests/php.rs index 95abc66b..9b37fa8a 100644 --- a/crates/cli/tests/php.rs +++ b/crates/cli/tests/php.rs @@ -35,7 +35,7 @@ impl TestEnvironment { home: home.as_std_path().to_path_buf(), current_dir: RefCell::new(current_dir.as_std_path().to_path_buf()), client, - target_platform: None, + target_platform: Some(TargetPlatform::DarwinArm64), target_platform_resolution_fails: false, exec_calls: RefCell::new(Vec::new()), } diff --git a/crates/cli/tests/update.rs b/crates/cli/tests/update.rs index c995bace..ef82bf0c 100644 --- a/crates/cli/tests/update.rs +++ b/crates/cli/tests/update.rs @@ -1621,6 +1621,7 @@ mod update_tests { while let Some(response) = responses.front() { match listener.accept() { Ok((mut stream, _address)) => { + stream.set_nonblocking(false)?; let mut request = String::new(); BufReader::new(stream.try_clone()?).read_line(&mut request)?; stream.write_all(format!("{response}\n").as_bytes())?; diff --git a/crates/daemon/src/managed_resources/tests.rs b/crates/daemon/src/managed_resources/tests.rs index 0f9f0f87..8c08597c 100644 --- a/crates/daemon/src/managed_resources/tests.rs +++ b/crates/daemon/src/managed_resources/tests.rs @@ -1,4 +1,5 @@ use std::collections::BTreeMap; +use std::fmt::Debug; use std::net::TcpListener; use std::os::unix::fs::PermissionsExt; use std::sync::{Arc, Mutex}; @@ -9,7 +10,7 @@ use crate::{ managed_resources::{ManagedResourceRuntimeAdapter, ManagedResourceRuntimeContext}, reconciliation::{ReconciliationQueue, ReconciliationScope}, }; -use anyhow::{Result, bail}; +use anyhow::{Result, anyhow, bail}; use camino::Utf8Path; use camino_tempfile::tempdir; use insta::{Settings, assert_debug_snapshot}; @@ -664,7 +665,10 @@ async fn mailpit_reconciliation_records_smtp_and_dashboard_env() -> Result<()> { let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - crate::project_env::reconcile_project_env(&paths, &project.id).await?; + mailpit_fixture_result( + &paths, + crate::project_env::reconcile_project_env(&paths, &project.id).await, + )?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -720,12 +724,15 @@ async fn mailpit_project_demand_installs_missing_fixture_track_before_start() -> let mailpit_port_guards = seed_mailpit_runtime_ports(&paths, FAKE_MAILPIT_TRACK)?; drop(mailpit_port_guards); - reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( + mailpit_fixture_result( &paths, - &project.id, - OFFLINE_TEST_MANIFEST_URL, - ) - .await?; + reconcile_project_env_with_mailpit_runtime_catalog_and_manifest_url( + &paths, + &project.id, + OFFLINE_TEST_MANIFEST_URL, + ) + .await, + )?; let snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -1971,6 +1978,14 @@ async fn demanded_resource_cleans_runtime_files_when_process_exits_after_readine drop(mailpit_port_guards); let result = reconcile_project_env_with_fast_exit_fake_runtime_catalog(&paths, &project.id).await; + if let Err(error) = &result + && !matches!( + error.downcast_ref::(), + Some(DaemonError::UnexpectedProtocolResponse { .. }) + ) + { + return Err(mailpit_fixture_error(&paths, error)); + } let failure_snapshot = { let database = Database::open(&paths)?; let runtime_states = database.runtime_observed_states()?; @@ -4862,6 +4877,26 @@ fn runtime_files_exist(paths: &PvPaths, track: &str) -> Result(paths: &PvPaths, result: Result) -> Result +where + E: Debug, +{ + match result { + Ok(value) => Ok(value), + Err(error) => Err(mailpit_fixture_error(paths, &error)), + } +} + +fn mailpit_fixture_error(paths: &PvPaths, error: &impl Debug) -> anyhow::Error { + let log_path = paths.resource_log("mailpit", FAKE_MAILPIT_TRACK); + match state::fs::read_to_string(&log_path) { + Ok(log) => anyhow!("Mailpit fixture failed with {error:#?}\n{log}"), + Err(log_error) => anyhow!( + "Mailpit fixture failed with {error:#?}; failed to read {log_path}: {log_error}" + ), + } +} + fn runtime_files_exist_for_resource( paths: &PvPaths, resource_name: &str, diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py index 04c5deb4..de038ea8 100644 --- a/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-hangs-on-port-server.py @@ -1,12 +1,18 @@ #!/usr/bin/env python3 import http.server import signal +import socketserver import sys +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + signal.signal(signal.SIGUSR1, signal.SIG_IGN) port = int(sys.argv[1]) -with http.server.ThreadingHTTPServer( - ("127.0.0.1", port), http.server.SimpleHTTPRequestHandler -) as server: +with Server(("127.0.0.1", port), http.server.SimpleHTTPRequestHandler) as server: server.serve_forever() diff --git a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py index c282595d..c1456279 100644 --- a/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py +++ b/crates/daemon/test-fixtures/gateway/fake-frankenphp-server.py @@ -2,6 +2,7 @@ import http.server import re import signal +import socketserver import ssl import sys import threading @@ -32,18 +33,23 @@ def log_message(self, format, *args): pass +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + http_port = int(required(r"^# PV_FAKE_PORT (\d+)$")) https_port = optional(r"^\s*https_port (\d+)$") cert_path = optional(r'^\s*cert "([^"]+)"$') key_path = optional(r'^\s*key "([^"]+)"$') -servers = [http.server.ThreadingHTTPServer(("127.0.0.1", http_port), Handler)] +servers = [Server(("127.0.0.1", http_port), Handler)] if https_port is not None and cert_path is not None and key_path is not None: context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) context.load_cert_chain(certfile=cert_path, keyfile=key_path) - https_server = http.server.ThreadingHTTPServer( - ("127.0.0.1", int(https_port)), Handler - ) + https_server = Server(("127.0.0.1", int(https_port)), Handler) https_server.socket = context.wrap_socket(https_server.socket, server_side=True) servers.append(https_server) diff --git a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py index 03e6ee0e..499b73ce 100644 --- a/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/fake-mailpit.py @@ -16,13 +16,20 @@ def handle(self): self.request.sendall(b"220 fake mailpit\r\n") +class HttpServer(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True daemon_threads = True smtp = TcpServer(("127.0.0.1", int(smtp_port)), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer( +dashboard = HttpServer( ("127.0.0.1", int(dashboard_port)), http.server.SimpleHTTPRequestHandler, ) diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py index 7ecdd0ef..f71e1567 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit-fast-exit.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import http.server import os +import socketserver import sys @@ -16,5 +17,12 @@ def log_message(self, _format, *_args): pass -server = http.server.ThreadingHTTPServer(("127.0.0.1", int(sys.argv[2])), Handler) +class Server(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + +server = Server(("127.0.0.1", int(sys.argv[2])), Handler) server.serve_forever() diff --git a/crates/daemon/test-fixtures/managed-resources/mailpit.py b/crates/daemon/test-fixtures/managed-resources/mailpit.py index aba95bbe..1a4a29a4 100644 --- a/crates/daemon/test-fixtures/managed-resources/mailpit.py +++ b/crates/daemon/test-fixtures/managed-resources/mailpit.py @@ -55,13 +55,20 @@ def handle(self): self.request.sendall(b"220 mailpit fixture\r\n") +class HttpServer(http.server.ThreadingHTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + + class TcpServer(socketserver.ThreadingMixIn, socketserver.TCPServer): allow_reuse_address = True daemon_threads = True smtp_server = TcpServer(host_port(smtp), SmtpHandler) -dashboard = http.server.ThreadingHTTPServer( +dashboard = HttpServer( host_port(listen), http.server.SimpleHTTPRequestHandler, ) diff --git a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in index 104b7f42..4255e2de 100644 --- a/crates/daemon/test-fixtures/managed-resources/rustfs.py.in +++ b/crates/daemon/test-fixtures/managed-resources/rustfs.py.in @@ -4,6 +4,7 @@ import http.server import os import posixpath import signal +import socketserver import sys import threading import urllib.parse @@ -132,6 +133,11 @@ class ConsoleHandler(http.server.BaseHTTPRequestHandler): class Server(http.server.ThreadingHTTPServer): allow_reuse_address = True + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + api = Server(split_address(api_address), RustfsHandler) console = Server(split_address(console_address), ConsoleHandler) diff --git a/crates/daemon/tests/fixture_contracts.rs b/crates/daemon/tests/fixture_contracts.rs index d2693844..154f0ce1 100644 --- a/crates/daemon/tests/fixture_contracts.rs +++ b/crates/daemon/tests/fixture_contracts.rs @@ -118,6 +118,15 @@ def serve_forever(self, *args, **kwargs): socketserver.BaseServer.serve_forever = serve_forever "#; +const FAILING_FQDN_SITECUSTOMIZE: &str = r#"import socket + + +def getfqdn(_name=""): + raise RuntimeError("fixture attempted an FQDN lookup") + + +socket.getfqdn = getfqdn +"#; #[expect( clippy::disallowed_types, @@ -428,7 +437,7 @@ fn single_server_fixture_exits_after_signal_status() -> Result<()> { } #[test] -fn multi_server_fixture_exits_after_signal_status() -> Result<()> { +fn multi_server_fixture_avoids_fqdn_lookup_and_exits_after_signal_status() -> Result<()> { for fixture in [ MultiServerFixture::FakeMailpit, MultiServerFixture::Mailpit, @@ -924,15 +933,19 @@ fn assert_multi_server_fixture_exits_after_signal( let second_port = second_port_reservation.local_addr()?.port(); let first_address = format!("127.0.0.1:{first_port}"); let second_address = format!("127.0.0.1:{second_port}"); + let sitecustomize = tempdir.path().join("sitecustomize.py"); let source = match fixture { MultiServerFixture::FakeMailpit => FAKE_MAILPIT_FIXTURE.to_owned(), MultiServerFixture::Mailpit => MAILPIT_FIXTURE.to_owned(), MultiServerFixture::Rustfs => render_rustfs_fixture(false)?, }; + state::fs::write_sensitive_file(&sitecustomize, FAILING_FQDN_SITECUSTOMIZE)?; materialize_fixture(&executable, &source)?; let mut command = FixtureCommand::new(executable.as_std_path()); - command.current_dir(tempdir.path()); + command + .current_dir(tempdir.path()) + .env("PYTHONPATH", tempdir.path()); match fixture { MultiServerFixture::FakeMailpit => { command.args([first_port.to_string(), second_port.to_string()]); diff --git a/crates/platform/Cargo.toml b/crates/platform/Cargo.toml index d6509d9b..5dd37078 100644 --- a/crates/platform/Cargo.toml +++ b/crates/platform/Cargo.toml @@ -24,7 +24,6 @@ x509-parser = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] libc = { workspace = true } -netstat = { workspace = true } rustix = { workspace = true } security-framework = { workspace = true } diff --git a/crates/platform/src/ca.rs b/crates/platform/src/ca.rs index df4dff92..8fbc2ad4 100644 --- a/crates/platform/src/ca.rs +++ b/crates/platform/src/ca.rs @@ -424,9 +424,6 @@ fn repair_reason_from_ca_error(error: PlatformError) -> CaRepairReason { | PlatformError::SystemIntegrationCommand { .. } | PlatformError::SystemIntegrationCommandStatus { .. } => CaRepairReason::InvalidCaShape, #[cfg(target_os = "macos")] - PlatformError::SocketTable(_) - | PlatformError::SocketTableCommand(_) - | PlatformError::SocketTableCommandStatus { .. } - | PlatformError::SocketTableCommandUtf8(_) => CaRepairReason::InvalidCaShape, + PlatformError::ListenerInspection { .. } => CaRepairReason::InvalidCaShape, } } diff --git a/crates/platform/src/error.rs b/crates/platform/src/error.rs index 53cd633c..86e8f507 100644 --- a/crates/platform/src/error.rs +++ b/crates/platform/src/error.rs @@ -82,18 +82,9 @@ pub enum PlatformError { SystemIntegrationCommandStatus { command: String, status: String }, #[cfg(target_os = "macos")] - #[error("could not inspect socket table: {0}")] - SocketTable(#[from] netstat::Error), - - #[cfg(target_os = "macos")] - #[error("could not run netstat for socket inspection: {0}")] - SocketTableCommand(#[source] io::Error), - - #[cfg(target_os = "macos")] - #[error("netstat socket inspection exited with {status}")] - SocketTableCommandStatus { status: String }, - - #[cfg(target_os = "macos")] - #[error("could not decode netstat socket table: {0}")] - SocketTableCommandUtf8(#[from] std::string::FromUtf8Error), + #[error("could not inspect TCP listeners: {source}")] + ListenerInspection { + #[source] + source: Box, + }, } diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index a7d017ba..6e85a1d8 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -44,13 +44,8 @@ pub use trust::{ #[cfg(test)] mod tests { - #[cfg(target_os = "macos")] - use insta::assert_debug_snapshot; - use crate::capability::require_capability_for; use crate::error::PlatformError; - #[cfg(target_os = "macos")] - use crate::listener::parse_netstat_tcp_listener_ports; use crate::{PlatformCapability, PlatformTarget}; #[test] @@ -99,22 +94,4 @@ mod tests { }) )); } - - #[test] - #[cfg(target_os = "macos")] - fn netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses() { - let output = r#" -Proto Recv-Q Send-Q Local Address Foreign Address (state) -tcp4 0 0 *.45000 *.* LISTEN -tcp4 0 0 127.0.0.1.45001 *.* LISTEN -tcp6 0 0 ::1.45002 *.* LISTEN -tcp6 0 0 ::.45003 *.* LISTEN -tcp4 0 0 192.168.1.5.45004 *.* LISTEN -tcp4 0 0 127.0.0.1.45005 127.0.0.1.12345 ESTABLISHED -udp4 0 0 127.0.0.1.45006 *.* -tcp4 0 0 127.0.0.1.notaport *.* LISTEN -"#; - - assert_debug_snapshot!(parse_netstat_tcp_listener_ports(output)); - } } diff --git a/crates/platform/src/listener.rs b/crates/platform/src/listener.rs index 74a15d8a..9c106957 100644 --- a/crates/platform/src/listener.rs +++ b/crates/platform/src/listener.rs @@ -15,9 +15,6 @@ mod implementation; #[path = "listener/windows.rs"] mod implementation; -#[cfg(all(target_os = "macos", test))] -pub(crate) use implementation::parse_netstat_tcp_listener_ports; - pub fn loopback_tcp_listener_ports() -> Result, PlatformError> { implementation::loopback_tcp_listener_ports() } diff --git a/crates/platform/src/listener/macos.rs b/crates/platform/src/listener/macos.rs index a15dece1..b60d7a1a 100644 --- a/crates/platform/src/listener/macos.rs +++ b/crates/platform/src/listener/macos.rs @@ -1,117 +1,14 @@ use std::collections::BTreeSet; -use std::net::IpAddr; - -use netstat::{AddressFamilyFlags, ProtocolFlags, ProtocolSocketInfo, TcpState, get_sockets_info}; use crate::PlatformError; -#[cfg_attr( - not(test), - expect( - dead_code, - reason = "kernel inspection remains acceptance-only until the supported macOS matrix passes" - ) -)] #[path = "macos/kernel_table.rs"] mod kernel_table; -#[expect( - clippy::disallowed_types, - reason = "macOS listener inspection owns read-only netstat invocation" -)] -type StdCommand = std::process::Command; - pub(super) fn loopback_tcp_listener_ports() -> Result, PlatformError> { - let mut ports = loopback_tcp_listener_ports_from_socket_table()?; - ports.extend(parse_netstat_tcp_listener_ports( - &netstat_tcp_socket_table()? - )); - - Ok(ports) -} - -fn loopback_tcp_listener_ports_from_socket_table() -> Result, PlatformError> { - let sockets = get_sockets_info( - AddressFamilyFlags::IPV4 | AddressFamilyFlags::IPV6, - ProtocolFlags::TCP, - )?; - let mut ports = BTreeSet::new(); - - for socket in sockets { - let ProtocolSocketInfo::Tcp(tcp) = socket.protocol_socket_info else { - continue; - }; - - if tcp.state == TcpState::Listen && tcp_listener_address_occupies_loopback(tcp.local_addr) { - ports.insert(tcp.local_port); + kernel_table::loopback_tcp_listener_ports().map_err(|source| { + PlatformError::ListenerInspection { + source: Box::new(source), } - } - - Ok(ports) -} - -fn netstat_tcp_socket_table() -> Result { - let output = StdCommand::new("/usr/sbin/netstat") - .args(["-anv", "-p", "tcp"]) - .output() - .map_err(PlatformError::SocketTableCommand)?; - - if !output.status.success() { - return Err(PlatformError::SocketTableCommandStatus { - status: output.status.to_string(), - }); - } - - Ok(String::from_utf8(output.stdout)?) -} - -pub(crate) fn parse_netstat_tcp_listener_ports(output: &str) -> BTreeSet { - let mut ports = BTreeSet::new(); - - for line in output.lines() { - let columns = line.split_whitespace().collect::>(); - let [ - protocol, - _recv_queue, - _send_queue, - local_address, - _foreign_address, - state, - .., - ] = columns.as_slice() - else { - continue; - }; - - if !protocol.starts_with("tcp") || *state != "LISTEN" { - continue; - } - - if let Some(port) = loopback_port_from_netstat_local_address(local_address) { - ports.insert(port); - } - } - - ports -} - -fn loopback_port_from_netstat_local_address(local_address: &str) -> Option { - let (address, port) = local_address.rsplit_once('.')?; - let port = port.parse::().ok()?; - - if address == "*" { - return Some(port); - } - - let address = address.parse::().ok()?; - - if tcp_listener_address_occupies_loopback(address) { - Some(port) - } else { - None - } -} - -fn tcp_listener_address_occupies_loopback(address: IpAddr) -> bool { - address.is_loopback() || address.is_unspecified() + }) } diff --git a/crates/platform/src/listener/macos/kernel_table.rs b/crates/platform/src/listener/macos/kernel_table.rs index 9e1d1faa..8d9c89e3 100644 --- a/crates/platform/src/listener/macos/kernel_table.rs +++ b/crates/platform/src/listener/macos/kernel_table.rs @@ -32,7 +32,7 @@ const INPCB_IPV4_ADDRESS_OFFSET: usize = INPCB_LOCAL_ADDRESS_OFFSET + 12; const TCPCB_STATE_OFFSET: usize = 36; #[derive(Debug, Error)] -enum KernelTableError { +pub(super) enum KernelTableError { #[error(transparent)] Fetch(#[from] FetchError), @@ -44,7 +44,7 @@ enum KernelTableError { } #[derive(Debug, Error)] -enum FetchError { +pub(super) enum FetchError { #[error("could not query the macOS TCP PCB table size: {0}")] Size(#[source] io::Error), @@ -61,7 +61,7 @@ enum FetchError { } #[derive(Debug, Error, Eq, PartialEq)] -enum ParseError { +pub(super) enum ParseError { #[error("TCP PCB table is too short: expected at least {minimum} bytes, received {actual}")] TableTooShort { minimum: usize, actual: usize }, @@ -130,7 +130,7 @@ struct InternetPcb { local_address: [u8; INPCB_LOCAL_ADDRESS_LENGTH], } -fn loopback_tcp_listener_ports() -> Result, KernelTableError> { +pub(super) fn loopback_tcp_listener_ports() -> Result, KernelTableError> { for _attempt in 1..=MAX_ATTEMPTS { let table = fetch_tcp_table()?; @@ -397,17 +397,18 @@ fn read_network_u16(bytes: &[u8], offset: usize) -> u16 { #[cfg(test)] mod tests { - use std::collections::VecDeque; + 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, - loopback_tcp_listener_ports, parse_tcp_table, + XSO_INPCB, XSO_TCPCB, XTCPCB_MINIMUM_LENGTH, fetch_tcp_table_with, parse_tcp_table, }; const SNAPSHOT_COUNT: u32 = 8; @@ -584,7 +585,6 @@ mod tests { } #[test] - #[ignore = "acceptance-only: depends on the live macOS TCP PCB table"] 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))?; @@ -596,14 +596,21 @@ mod tests { ("ipv6 loopback", ipv6_loopback.local_addr()?.port()), ("ipv6 wildcard", ipv6_wildcard.local_addr()?.port()), ]; - let mut detections = expected.map(|(name, _port)| (name, 0)); + let mut detections = expected.map(|(name, _port)| (name, 0, 0)); for _sample in 0..10 { - let ports = loopback_tcp_listener_ports()?; + 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 ports.contains(port) { + if kernel_ports.contains(port) { detections[index].1 += 1; } + if netstat_ports.contains(port) { + detections[index].2 += 1; + } } } @@ -612,6 +619,38 @@ mod tests { 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), 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 index f76f97bc..1049c3e7 100644 --- 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 @@ -6,17 +6,21 @@ expression: detections ( "ipv4 loopback", 10, + 10, ), ( "ipv4 wildcard", 10, + 10, ), ( "ipv6 loopback", 10, + 10, ), ( "ipv6 wildcard", 10, + 10, ), ] diff --git a/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap b/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap deleted file mode 100644 index 599e4fb4..00000000 --- a/crates/platform/src/snapshots/platform__tests__netstat_tcp_listener_port_parser_covers_loopback_and_wildcard_addresses.snap +++ /dev/null @@ -1,10 +0,0 @@ ---- -source: crates/platform/src/lib.rs -expression: parse_netstat_tcp_listener_ports(output) ---- -{ - 45000, - 45001, - 45002, - 45003, -} diff --git a/crates/pv-release/tests/smoke.rs b/crates/pv-release/tests/smoke.rs index 8e59c50f..1db524e3 100644 --- a/crates/pv-release/tests/smoke.rs +++ b/crates/pv-release/tests/smoke.rs @@ -3772,6 +3772,7 @@ import http.server import os import signal import socket +import socketserver import threading import time @@ -3798,8 +3799,14 @@ class Handler(http.server.BaseHTTPRequestHandler): def log_message(self, _format, *_args): pass +class Server(http.server.HTTPServer): + def server_bind(self): + # Avoid HTTPServer's unnecessary FQDN lookup for a loopback fixture. + socketserver.TCPServer.server_bind(self) + self.server_name, self.server_port = self.server_address[:2] + api_host, api_port = split_address(os.environ["PV_TEST_RUSTFS_ADDRESS"]) -server = http.server.HTTPServer((api_host, api_port), Handler) +server = Server((api_host, api_port), Handler) threading.Thread(target=server.serve_forever, daemon=True).start() console_address = os.environ.get("PV_TEST_RUSTFS_CONSOLE_ADDRESS") diff --git a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md index ede2f620..20c165ed 100644 --- a/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md +++ b/docs/superpowers/specs/2026-07-19-portable-platform-architecture-design.md @@ -12,7 +12,7 @@ The implementation will use compile-time-selected operating-system modules behin The implementation must update `DESIGN.md` to record these decisions: -- PV v1 targets macOS 13 and newer. +- PV v1 supports macOS 14 and newer. macOS 13 may remain binary-compatible, but it is untested and unsupported. - Stabilizing the macOS application remains the immediate product priority. - Linux and Windows are committed subsequent platforms. - New system boundaries must avoid unnecessary portability blockers. @@ -278,7 +278,7 @@ These phases define dependency and review order. They should be split into indep - Detect IPv4 loopback, IPv6 loopback, IPv4 wildcard, and IPv6 wildcard listeners relevant to PV. - Report TCP listen state and the owning process evidence when the operating system makes it available. - Define deterministic behavior for permission limitations and processes that exit during inspection. -- Pass fixtures on supported macOS versions and both supported macOS architectures before replacing production inspection. +- Pass fixtures on every supported macOS major version and both supported macOS architectures across the full representative CI matrix defined in `DESIGN.md` before replacing production inspection. - Remove both `netstat-esr` and `/usr/sbin/netstat` together after acceptance; do not add a third permanent source. ### Lifecycle Candidates diff --git a/scripts/ci/privileged-macos-rc.sh b/scripts/ci/privileged-macos-rc.sh index 139fe0b5..558d40ed 100644 --- a/scripts/ci/privileged-macos-rc.sh +++ b/scripts/ci/privileged-macos-rc.sh @@ -202,7 +202,7 @@ record_status setup required pv setup --yes --no-path collect_file gateway-caddyfile "$HOME/.pv/config/gateway/Caddyfile" collect_file gateway-runtime-pid "$HOME/.pv/run/gateway.pid" collect_file gateway-runtime-metadata "$HOME/.pv/run/gateway.json" -record_status gateway-listeners evidence sh -c 'lsof -nP -iTCP:48080 -sTCP:LISTEN || true; netstat -anv -p tcp | grep -E "48080|48443" || true' +record_status gateway-listeners evidence lsof -nP -iTCP:48080 -sTCP:LISTEN record_status gateway-loopback-nc evidence nc -vz -G 2 127.0.0.1 48080 record_status gateway-loopback-http evidence curl --show-error --silent --max-time 5 --write-out '\nhttp_code:%{http_code}\n' http://127.0.0.1:48080/