diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd28b95..4216c6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -270,9 +270,9 @@ jobs: features="tokio pac-engine-wasmtime" fi if [ "${{ matrix.os }}" = "ubuntu-latest" ]; then - dbus-run-session -- cargo test --features "$features" os_roundtrip -- --nocapture + dbus-run-session -- cargo test --features "$features" platform::imp::tests::os_ -- --nocapture else - cargo test --features "$features" os_roundtrip -- --nocapture + cargo test --features "$features" platform::imp::tests::os_ -- --nocapture fi shell: bash @@ -585,6 +585,31 @@ jobs: working-directory: bench/electron shell: bash + msrv: + name: Rust 1.94 MSRV + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install Rust 1.94 + run: | + rustup toolchain install 1.94.0 --profile minimal --no-self-update + rustup default 1.94.0 + - uses: Swatinem/rust-cache@v2 + with: + key: msrv-1.94 + # `--all-features` includes pac-engine-wasm2c, whose build script runs + # the pinned wasm2c. + - name: Install wasm2c (WABT 1.0.41) + run: | + mkdir -p "$HOME/wabt" + curl -sSL "https://github.com/WebAssembly/wabt/releases/download/1.0.41/wabt-1.0.41-linux-x64.tar.gz" \ + | tar xz --strip-components=1 -C "$HOME/wabt" + echo "$HOME/wabt/bin" >> "$GITHUB_PATH" + - name: Check root crate + run: cargo check --locked --all-features --all-targets + - name: Check Node addon + run: cargo check --manifest-path npm/native/Cargo.toml --locked --all-targets + lint: name: rustfmt + clippy + docs runs-on: macos-latest @@ -649,4 +674,3 @@ jobs: echo "::error::npm/ThirdPartyNotices.txt is out of date. Run: cargo about generate --manifest-path npm/native/Cargo.toml --config about.toml --locked --fail --output-file npm/ThirdPartyNotices.txt npm/about.hbs" exit 1 fi - diff --git a/CHANGELOG.md b/CHANGELOG.md index 41732f4..fee921e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Notable changes to this project will be documented in this file. +## 0.5.0 + +### Changed + +- Replaced Linux `dconf` and `gsettings` subprocesses with runtime-loaded, + in-process GIO configuration reads and change notifications. +- Raised the minimum supported Rust version to 1.94. + ## 0.4.0 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index ad7774f..9307663 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "addr2line" @@ -648,6 +648,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -729,11 +739,11 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "os-proxy-resolver" -version = "0.1.0" +version = "0.5.0" dependencies = [ "cc", "core-foundation", - "libc", + "libloading", "log", "psl", "rquickjs-sys", diff --git a/Cargo.toml b/Cargo.toml index 9ac559b..0424f58 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "os-proxy-resolver" -version = "0.1.0" +version = "0.5.0" edition = "2021" -rust-version = "1.77" +rust-version = "1.94" # All first-party code is MIT (see LICENSE.txt). The embedded PAC engine links # the MIT-licensed quickjs-ng via the `rquickjs-sys` crate; the PAC helper # functions are original, implemented from the public PAC specification. @@ -118,7 +118,7 @@ windows-sys = { version = "0.60", features = [ rquickjs-sys = { version = "0.12.1", optional = true } [target.'cfg(target_os = "linux")'.dependencies] -libc = "0.2" +libloading = "0.8" [dev-dependencies] tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } diff --git a/README.md b/README.md index 518a475..9da3625 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,13 @@ resolution. |---|---|---|---| | **Windows** | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DHCP/DNS WPAD; WinHTTP fallback when backend-less | registry change notification | | **macOS** | `SCDynamicStoreCopyProxies` | built-in [QuickJS] PAC engine + DNS WPAD | `SCDynamicStore` callback | -| **Linux** | GNOME `org.gnome.system.proxy` via `gsettings` | built-in [QuickJS] PAC engine + DNS WPAD | `dconf watch` / `gsettings monitor` | +| **Linux** | GNOME `org.gnome.system.proxy` via runtime-loaded GIO | built-in [QuickJS] PAC engine + DNS WPAD | in-process GSettings callback | + +Linux loads GLib/GIO at runtime rather than linking it into the library. On +headless systems without GIO or the GNOME proxy schema, OS configuration is +unavailable and resolution continues with the environment-variable layer. +Alpine users can install its `glib` runtime package to enable GSettings-backed +OS configuration. ### PAC backends @@ -210,6 +216,8 @@ is returned and retried. ## Building +The minimum supported Rust version is 1.94. + ```sh git clone cargo build # Windows: native WinHTTP PAC/WPAD diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index a7482ad..f0a96ff 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -55,8 +55,8 @@ instead. Overview of licenses used: - MIT License (109 crates) - Apache License 2.0 (27 crates) +- ISC License (19 crates) - Unicode License v3 (19 crates) -- ISC License (18 crates) - Community Data License Agreement Permissive 2.0 (2 crates) - BSD 3-Clause "New" or "Revised" License (1 crate) - zlib License (1 crate) @@ -1534,6 +1534,26 @@ CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ISC License +Used by: + - libloading 0.8.9 + +Copyright © 2015, Simonas Kazlauskas + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without +fee is hereby granted, provided that the above copyright notice and this permission notice appear +in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS +SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. + +-------------------------------------------------------------------------------- + +ISC License + Used by: - rustls-webpki 0.103.13 @@ -2933,7 +2953,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. MIT License Used by: - - os-proxy-resolver 0.1.0 + - os-proxy-resolver 0.5.0 MIT License @@ -3414,7 +3434,7 @@ Used by: - rquickjs-sys 0.12.1 The MIT License (MIT) - + Copyright (c) 2017-2026 Fabrice Bellard Copyright (c) 2017-2024 Charlie Gordon Copyright (c) 2023-2026 Ben Noordhuis diff --git a/npm/ThirdPartyNotices.txt b/npm/ThirdPartyNotices.txt index 7854950..1aa492e 100644 --- a/npm/ThirdPartyNotices.txt +++ b/npm/ThirdPartyNotices.txt @@ -556,7 +556,7 @@ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS -SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- @@ -2012,7 +2012,7 @@ USE OR OTHER DEALINGS IN THE SOFTWARE. MIT License Used by: - - os-proxy-resolver 0.1.0 + - os-proxy-resolver 0.5.0 MIT License @@ -2482,4 +2482,3 @@ SPDX-License-Identifier: Unicode-3.0 Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. - diff --git a/npm/native/Cargo.lock b/npm/native/Cargo.lock index 08e7f6d..e960f11 100644 --- a/npm/native/Cargo.lock +++ b/npm/native/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -355,11 +355,11 @@ checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" [[package]] name = "os-proxy-resolver" -version = "0.1.0" +version = "0.5.0" dependencies = [ "cc", "core-foundation", - "libc", + "libloading", "log", "psl", "system-configuration", @@ -370,7 +370,7 @@ dependencies = [ [[package]] name = "os-proxy-resolver-node" -version = "0.1.0" +version = "0.5.0" dependencies = [ "napi", "napi-build", diff --git a/npm/native/Cargo.toml b/npm/native/Cargo.toml index 0f47b5a..a3a119c 100644 --- a/npm/native/Cargo.toml +++ b/npm/native/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "os-proxy-resolver-node" -version = "0.1.0" +version = "0.5.0" edition = "2021" -rust-version = "1.77" +rust-version = "1.94" publish = false [lib] diff --git a/npm/platforms/darwin-arm64/package.json b/npm/platforms/darwin-arm64/package.json index 41ee891..204d2a1 100644 --- a/npm/platforms/darwin-arm64/package.json +++ b/npm/platforms/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-darwin-arm64", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on macOS arm64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/darwin-x64/package.json b/npm/platforms/darwin-x64/package.json index 63e06a6..40990b0 100644 --- a/npm/platforms/darwin-x64/package.json +++ b/npm/platforms/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-darwin-x64", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on macOS x64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/linux-arm-gnueabihf/package.json b/npm/platforms/linux-arm-gnueabihf/package.json index 4a6adb4..8bb3a11 100644 --- a/npm/platforms/linux-arm-gnueabihf/package.json +++ b/npm/platforms/linux-arm-gnueabihf/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-linux-arm-gnueabihf", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Linux armhf", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/linux-arm64-gnu/package.json b/npm/platforms/linux-arm64-gnu/package.json index 13ecf32..18125f3 100644 --- a/npm/platforms/linux-arm64-gnu/package.json +++ b/npm/platforms/linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-linux-arm64-gnu", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Linux arm64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/linux-arm64-musl/package.json b/npm/platforms/linux-arm64-musl/package.json index 5828627..0e7a50c 100644 --- a/npm/platforms/linux-arm64-musl/package.json +++ b/npm/platforms/linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-linux-arm64-musl", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Linux arm64 (musl)", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/linux-x64-gnu/package.json b/npm/platforms/linux-x64-gnu/package.json index be528e1..e43a3bc 100644 --- a/npm/platforms/linux-x64-gnu/package.json +++ b/npm/platforms/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-linux-x64-gnu", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Linux x64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/linux-x64-musl/package.json b/npm/platforms/linux-x64-musl/package.json index bfab652..26a7627 100644 --- a/npm/platforms/linux-x64-musl/package.json +++ b/npm/platforms/linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-linux-x64-musl", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Linux x64 (musl)", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/win32-arm64-msvc/package.json b/npm/platforms/win32-arm64-msvc/package.json index 5793a64..a48b4f5 100644 --- a/npm/platforms/win32-arm64-msvc/package.json +++ b/npm/platforms/win32-arm64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-win32-arm64-msvc", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Windows arm64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/npm/platforms/win32-x64-msvc/package.json b/npm/platforms/win32-x64-msvc/package.json index 80c3b8a..3b789a7 100644 --- a/npm/platforms/win32-x64-msvc/package.json +++ b/npm/platforms/win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver-win32-x64-msvc", - "version": "0.4.0", + "version": "0.5.0", "description": "Native binding for @vscode/os-proxy-resolver on Windows x64", "main": "os_proxy_resolver.node", "files": ["os_proxy_resolver.node", "LICENSE.txt", "ThirdPartyNotices.txt"], diff --git a/pac-wasm-guest/Cargo.lock b/pac-wasm-guest/Cargo.lock index b3a828a..20c101c 100644 --- a/pac-wasm-guest/Cargo.lock +++ b/pac-wasm-guest/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "aho-corasick" @@ -264,7 +264,7 @@ dependencies = [ [[package]] name = "pac-wasm-guest" -version = "0.1.0" +version = "0.5.0" dependencies = [ "javy", ] diff --git a/pac-wasm-guest/Cargo.toml b/pac-wasm-guest/Cargo.toml index 0785f84..d2d9670 100644 --- a/pac-wasm-guest/Cargo.toml +++ b/pac-wasm-guest/Cargo.toml @@ -6,9 +6,9 @@ # this directory for how to regenerate it. [package] name = "pac-wasm-guest" -version = "0.1.0" +version = "0.5.0" edition = "2021" -rust-version = "1.77" +rust-version = "1.94" license = "MIT" publish = false description = "QuickJS-NG (via Javy) as a wasm32-wasip1 module for os-proxy-resolver's sandboxed PAC backend" diff --git a/package.json b/package.json index 5f3f686..4eccf4b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/os-proxy-resolver", - "version": "0.4.0", + "version": "0.5.0", "description": "Resolve the operating system proxy configuration from Node.js", "main": "index.js", "types": "index.d.ts", @@ -31,14 +31,14 @@ "verify:packages": "node npm/scripts/verify-packages.js" }, "optionalDependencies": { - "@vscode/os-proxy-resolver-darwin-arm64": "0.4.0", - "@vscode/os-proxy-resolver-darwin-x64": "0.4.0", - "@vscode/os-proxy-resolver-linux-arm-gnueabihf": "0.4.0", - "@vscode/os-proxy-resolver-linux-arm64-gnu": "0.4.0", - "@vscode/os-proxy-resolver-linux-arm64-musl": "0.4.0", - "@vscode/os-proxy-resolver-linux-x64-gnu": "0.4.0", - "@vscode/os-proxy-resolver-linux-x64-musl": "0.4.0", - "@vscode/os-proxy-resolver-win32-arm64-msvc": "0.4.0", - "@vscode/os-proxy-resolver-win32-x64-msvc": "0.4.0" + "@vscode/os-proxy-resolver-darwin-arm64": "0.5.0", + "@vscode/os-proxy-resolver-darwin-x64": "0.5.0", + "@vscode/os-proxy-resolver-linux-arm-gnueabihf": "0.5.0", + "@vscode/os-proxy-resolver-linux-arm64-gnu": "0.5.0", + "@vscode/os-proxy-resolver-linux-arm64-musl": "0.5.0", + "@vscode/os-proxy-resolver-linux-x64-gnu": "0.5.0", + "@vscode/os-proxy-resolver-linux-x64-musl": "0.5.0", + "@vscode/os-proxy-resolver-win32-arm64-msvc": "0.5.0", + "@vscode/os-proxy-resolver-win32-x64-msvc": "0.5.0" } } \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 2cb0545..4fc5432 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,7 +41,7 @@ //! |---|---|---|---| //! | Windows | `WinHttpGetIEProxyConfigForCurrentUser` | selected embedded backend + DHCP/DNS WPAD; WinHTTP fallback with no backend | registry notification | //! | macOS | `SCDynamicStoreCopyProxies` | built-in [QuickJS] PAC engine + DNS WPAD | `SCDynamicStore` callback | -//! | Linux | GNOME `org.gnome.system.proxy` (gsettings) | built-in [QuickJS] PAC engine + DNS WPAD | `dconf watch` / `gsettings monitor` | +//! | Linux | GNOME `org.gnome.system.proxy` via runtime-loaded GIO | built-in [QuickJS] PAC engine + DNS WPAD | in-process GSettings callback | //! //! On Windows, WinHTTP always reads Internet Settings. DHCP option 252 is //! probed before the shared DNS WPAD path; an embedded PAC backend evaluates diff --git a/src/platform/gio.rs b/src/platform/gio.rs new file mode 100644 index 0000000..d66495c --- /dev/null +++ b/src/platform/gio.rs @@ -0,0 +1,537 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Runtime-loaded GIO access for the Linux proxy configuration. + +use libloading::Library; +use std::ffi::{c_char, c_int, c_uint, c_ulong, c_void, CStr}; +use std::ptr; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc, Mutex, OnceLock}; + +const PROXY_SCHEMA: &[u8] = b"org.gnome.system.proxy\0"; +const HTTP_CHILD: &[u8] = b"http\0"; +const HTTPS_CHILD: &[u8] = b"https\0"; +const SOCKS_CHILD: &[u8] = b"socks\0"; +const CHANGED_SIGNAL: &[u8] = b"changed\0"; +const MODE_KEY: &[u8] = b"mode\0"; +const AUTOCONFIG_URL_KEY: &[u8] = b"autoconfig-url\0"; +const IGNORE_HOSTS_KEY: &[u8] = b"ignore-hosts\0"; +const HOST_KEY: &[u8] = b"host\0"; +const PORT_KEY: &[u8] = b"port\0"; + +#[repr(C)] +struct GMainContext { + _private: [u8; 0], +} + +#[repr(C)] +struct GSettings { + _private: [u8; 0], +} + +#[repr(C)] +struct GSettingsSchema { + _private: [u8; 0], +} + +#[repr(C)] +struct GSettingsSchemaSource { + _private: [u8; 0], +} + +type SettingsChangedCallback = unsafe extern "C" fn(*mut GSettings, *mut c_char, *mut c_void); +type ClosureNotify = unsafe extern "C" fn(*mut c_void, *mut c_void); + +struct GioApi { + _glib: Library, + _gobject: Library, + _gio: Library, + main_context_new: unsafe extern "C" fn() -> *mut GMainContext, + main_context_push_thread_default: unsafe extern "C" fn(*mut GMainContext), + main_context_pop_thread_default: unsafe extern "C" fn(*mut GMainContext), + main_context_iteration: unsafe extern "C" fn(*mut GMainContext, c_int) -> c_int, + main_context_wakeup: unsafe extern "C" fn(*mut GMainContext), + main_context_unref: unsafe extern "C" fn(*mut GMainContext), + free: unsafe extern "C" fn(*mut c_void), + strfreev: unsafe extern "C" fn(*mut *mut c_char), + object_unref: unsafe extern "C" fn(*mut c_void), + signal_connect_data: unsafe extern "C" fn( + *mut c_void, + *const c_char, + Option, + *mut c_void, + Option, + c_uint, + ) -> c_ulong, + settings_schema_source_get_default: unsafe extern "C" fn() -> *mut GSettingsSchemaSource, + settings_schema_source_lookup: unsafe extern "C" fn( + *mut GSettingsSchemaSource, + *const c_char, + c_int, + ) -> *mut GSettingsSchema, + settings_schema_unref: unsafe extern "C" fn(*mut GSettingsSchema), + settings_new_full: + unsafe extern "C" fn(*mut GSettingsSchema, *mut c_void, *const c_char) -> *mut GSettings, + settings_get_child: unsafe extern "C" fn(*mut GSettings, *const c_char) -> *mut GSettings, + settings_get_string: unsafe extern "C" fn(*mut GSettings, *const c_char) -> *mut c_char, + settings_get_int: unsafe extern "C" fn(*mut GSettings, *const c_char) -> c_int, + settings_get_strv: unsafe extern "C" fn(*mut GSettings, *const c_char) -> *mut *mut c_char, +} + +impl GioApi { + fn load() -> Result { + Self::load_from("libglib-2.0.so.0", "libgobject-2.0.so.0", "libgio-2.0.so.0") + } + + fn load_from(glib_name: &str, gobject_name: &str, gio_name: &str) -> Result { + // SAFETY: the handles remain owned by GioApi for at least as long as + // every copied function pointer and every GObject created through them. + unsafe { + let glib = Library::new(glib_name) + .map_err(|error| format!("failed to load {glib_name}: {error}"))?; + let gobject = Library::new(gobject_name) + .map_err(|error| format!("failed to load {gobject_name}: {error}"))?; + let gio = Library::new(gio_name) + .map_err(|error| format!("failed to load {gio_name}: {error}"))?; + + Ok(Self { + main_context_new: load_symbol(&glib, b"g_main_context_new\0")?, + main_context_push_thread_default: load_symbol( + &glib, + b"g_main_context_push_thread_default\0", + )?, + main_context_pop_thread_default: load_symbol( + &glib, + b"g_main_context_pop_thread_default\0", + )?, + main_context_iteration: load_symbol(&glib, b"g_main_context_iteration\0")?, + main_context_wakeup: load_symbol(&glib, b"g_main_context_wakeup\0")?, + main_context_unref: load_symbol(&glib, b"g_main_context_unref\0")?, + free: load_symbol(&glib, b"g_free\0")?, + strfreev: load_symbol(&glib, b"g_strfreev\0")?, + object_unref: load_symbol(&gobject, b"g_object_unref\0")?, + signal_connect_data: load_symbol(&gobject, b"g_signal_connect_data\0")?, + settings_schema_source_get_default: load_symbol( + &gio, + b"g_settings_schema_source_get_default\0", + )?, + settings_schema_source_lookup: load_symbol( + &gio, + b"g_settings_schema_source_lookup\0", + )?, + settings_schema_unref: load_symbol(&gio, b"g_settings_schema_unref\0")?, + settings_new_full: load_symbol(&gio, b"g_settings_new_full\0")?, + settings_get_child: load_symbol(&gio, b"g_settings_get_child\0")?, + settings_get_string: load_symbol(&gio, b"g_settings_get_string\0")?, + settings_get_int: load_symbol(&gio, b"g_settings_get_int\0")?, + settings_get_strv: load_symbol(&gio, b"g_settings_get_strv\0")?, + _glib: glib, + _gobject: gobject, + _gio: gio, + }) + } + } +} + +unsafe fn load_symbol(library: &Library, name: &[u8]) -> Result { + // SAFETY: callers provide the exact C ABI signature for each named symbol, + // and GioApi keeps the library loaded while the copied pointer is usable. + unsafe { + library + .get::(name) + .map(|symbol| *symbol) + .map_err(|error| { + format!( + "failed to load {}: {error}", + String::from_utf8_lossy(name).trim_end_matches('\0') + ) + }) + } +} + +static GIO_API: OnceLock> = OnceLock::new(); + +fn gio_api() -> Option<&'static GioApi> { + GIO_API + .get_or_init(|| match GioApi::load() { + Ok(api) => Some(api), + Err(error) => { + log::debug!("proxy settings: GIO unavailable: {error}"); + None + } + }) + .as_ref() +} + +struct Settings<'a> { + api: &'a GioApi, + ptr: *mut GSettings, +} + +impl<'a> Settings<'a> { + fn proxy(api: &'a GioApi) -> Option { + Self::with_schema(api, PROXY_SCHEMA) + } + + fn with_schema(api: &'a GioApi, schema_id: &[u8]) -> Option { + // SAFETY: all symbols have their documented GIO signatures. The + // schema is checked before creating GSettings because a missing schema + // is fatal when passed directly to g_settings_new. + unsafe { + let source = (api.settings_schema_source_get_default)(); + if source.is_null() { + return None; + } + let schema = (api.settings_schema_source_lookup)(source, c_ptr(schema_id), 1); + if schema.is_null() { + return None; + } + let settings = (api.settings_new_full)(schema, ptr::null_mut(), ptr::null()); + (api.settings_schema_unref)(schema); + (!settings.is_null()).then_some(Self { api, ptr: settings }) + } + } + + fn child(&self, name: &[u8]) -> Option { + // SAFETY: self.ptr is a live GSettings and name is NUL-terminated. + let child = unsafe { (self.api.settings_get_child)(self.ptr, c_ptr(name)) }; + (!child.is_null()).then_some(Self { + api: self.api, + ptr: child, + }) + } + + fn string(&self, key: &[u8]) -> String { + // SAFETY: self.ptr is live and the schema defines key as a string. + let value = unsafe { (self.api.settings_get_string)(self.ptr, c_ptr(key)) }; + if value.is_null() { + return String::new(); + } + // SAFETY: GSettings returns a NUL-terminated UTF-8 string allocated by + // GLib. Copy it before releasing the allocation with g_free. + let result = unsafe { CStr::from_ptr(value) } + .to_string_lossy() + .into_owned(); + // SAFETY: value was returned with transfer-full ownership. + unsafe { (self.api.free)(value.cast()) }; + result + } + + fn int(&self, key: &[u8]) -> c_int { + // SAFETY: self.ptr is live and the schema defines key as an integer. + unsafe { (self.api.settings_get_int)(self.ptr, c_ptr(key)) } + } + + fn string_list(&self, key: &[u8]) -> Vec { + // SAFETY: self.ptr is live and the schema defines key as a string array. + let values = unsafe { (self.api.settings_get_strv)(self.ptr, c_ptr(key)) }; + if values.is_null() { + return Vec::new(); + } + + let mut result = Vec::new(); + let mut cursor = values; + // SAFETY: g_settings_get_strv returns a NUL-terminated array of + // NUL-terminated UTF-8 strings. + unsafe { + while !(*cursor).is_null() { + result.push(CStr::from_ptr(*cursor).to_string_lossy().into_owned()); + cursor = cursor.add(1); + } + (self.api.strfreev)(values); + } + result + } + + fn connect_changed(&self, state: &ChangeState) -> bool { + // SAFETY: state outlives this Settings and therefore every connected + // signal handler. The callback has the documented "changed" ABI. + unsafe { + (self.api.signal_connect_data)( + self.ptr.cast(), + c_ptr(CHANGED_SIGNAL), + Some(settings_changed), + (state as *const ChangeState).cast_mut().cast(), + None, + 0, + ) != 0 + } + } +} + +impl Drop for Settings<'_> { + fn drop(&mut self) { + // SAFETY: ptr owns one live GObject reference. + unsafe { (self.api.object_unref)(self.ptr.cast()) }; + } +} + +struct ProxySettings<'a> { + root: Settings<'a>, + http: Settings<'a>, + https: Settings<'a>, + socks: Settings<'a>, +} + +impl<'a> ProxySettings<'a> { + fn new(api: &'a GioApi) -> Option { + let root = Settings::proxy(api)?; + let http = root.child(HTTP_CHILD)?; + let https = root.child(HTTPS_CHILD)?; + let socks = root.child(SOCKS_CHILD)?; + Some(Self { + root, + http, + https, + socks, + }) + } + + fn values(&self) -> Values { + Values { + mode: self.root.string(MODE_KEY), + autoconfig_url: self.root.string(AUTOCONFIG_URL_KEY), + ignore_hosts: self.root.string_list(IGNORE_HOSTS_KEY), + http_host: self.http.string(HOST_KEY), + http_port: self.http.int(PORT_KEY), + https_host: self.https.string(HOST_KEY), + https_port: self.https.int(PORT_KEY), + socks_host: self.socks.string(HOST_KEY), + socks_port: self.socks.int(PORT_KEY), + } + } + + fn connect_changed(&self, state: &ChangeState) -> bool { + self.root.connect_changed(state) + && self.http.connect_changed(state) + && self.https.connect_changed(state) + && self.socks.connect_changed(state) + } +} + +#[derive(Default)] +pub(super) struct Values { + pub(super) mode: String, + pub(super) autoconfig_url: String, + pub(super) ignore_hosts: Vec, + pub(super) http_host: String, + pub(super) http_port: c_int, + pub(super) https_host: String, + pub(super) https_port: c_int, + pub(super) socks_host: String, + pub(super) socks_port: c_int, +} + +pub(super) fn read_values() -> Option { + let api = gio_api()?; + ProxySettings::new(api).map(|settings| settings.values()) +} + +struct ChangeState { + changed: AtomicBool, +} + +unsafe extern "C" fn settings_changed( + _settings: *mut GSettings, + _key: *mut c_char, + user_data: *mut c_void, +) { + // SAFETY: connect_changed passes a live ChangeState pointer which remains + // valid until all connected Settings objects are dropped. + let state = unsafe { &*(user_data.cast::()) }; + state.changed.store(true, Ordering::Release); +} + +struct WatcherControl { + stop: AtomicBool, + context: Mutex>, +} + +impl WatcherControl { + fn new() -> Self { + Self { + stop: AtomicBool::new(false), + context: Mutex::new(None), + } + } +} + +pub(crate) struct Watcher { + api: Option<&'static GioApi>, + control: Option>, + thread: Option>, +} + +impl Watcher { + fn unavailable() -> Self { + Self { + api: None, + control: None, + thread: None, + } + } +} + +pub(super) fn spawn_watcher(on_change: Arc) -> Watcher { + let Some(api) = gio_api() else { + return Watcher::unavailable(); + }; + + let control = Arc::new(WatcherControl::new()); + let thread_control = control.clone(); + let (started_tx, started_rx) = mpsc::sync_channel(0); + let thread = match std::thread::Builder::new() + .name("os-proxy-watch".into()) + .spawn(move || run_watcher(api, thread_control, started_tx, on_change)) + { + Ok(thread) => thread, + Err(error) => { + log::warn!("proxy watcher: failed to spawn GIO thread: {error}"); + return Watcher::unavailable(); + } + }; + + match started_rx.recv() { + Ok(true) => {} + Ok(false) => log::debug!("proxy watcher: GSettings proxy schema unavailable"), + Err(error) => log::warn!("proxy watcher: GIO thread stopped during startup: {error}"), + } + Watcher { + api: Some(api), + control: Some(control), + thread: Some(thread), + } +} + +fn run_watcher( + api: &'static GioApi, + control: Arc, + started_tx: mpsc::SyncSender, + on_change: Arc, +) { + let Some(context) = MainContext::new(api, control.clone()) else { + let _ = started_tx.send(false); + return; + }; + let changes = ChangeState { + changed: AtomicBool::new(false), + }; + let Some(settings) = ProxySettings::new(api) else { + let _ = started_tx.send(false); + return; + }; + if !settings.connect_changed(&changes) { + let _ = started_tx.send(false); + return; + } + + // GSettings only emits "changed" for keys read after a handler is + // connected. Prime every key before reporting that startup is complete. + let _ = settings.values(); + if started_tx.send(true).is_err() { + return; + } + + while !control.stop.load(Ordering::Acquire) { + // SAFETY: context belongs to this thread and remains live for the loop. + unsafe { (api.main_context_iteration)(context.ptr, 1) }; + if control.stop.load(Ordering::Acquire) { + break; + } + if changes.changed.swap(false, Ordering::AcqRel) { + // This runs after GLib has returned to Rust, so a user callback + // panic cannot unwind through a C signal frame. + on_change(); + } + } +} + +struct MainContext<'a> { + api: &'a GioApi, + control: Arc, + ptr: *mut GMainContext, +} + +impl<'a> MainContext<'a> { + fn new(api: &'a GioApi, control: Arc) -> Option { + // SAFETY: the loaded function has the documented GLib ABI. + let ptr = unsafe { (api.main_context_new)() }; + if ptr.is_null() { + return None; + } + // SAFETY: ptr is a newly-created context owned by this thread. + unsafe { (api.main_context_push_thread_default)(ptr) }; + *control + .context + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(ptr as usize); + Some(Self { api, control, ptr }) + } +} + +impl Drop for MainContext<'_> { + fn drop(&mut self) { + *self + .control + .context + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; + // SAFETY: ptr is this thread's pushed default context and owns one + // reference from g_main_context_new. + unsafe { + (self.api.main_context_pop_thread_default)(self.ptr); + (self.api.main_context_unref)(self.ptr); + } + } +} + +impl Drop for Watcher { + fn drop(&mut self) { + if let (Some(api), Some(control)) = (self.api, self.control.as_ref()) { + control.stop.store(true, Ordering::Release); + let context = control + .context + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(context) = *context { + // SAFETY: MainContext clears context while holding the same + // mutex before unref, so this pointer is live while locked. + unsafe { (api.main_context_wakeup)(context as *mut GMainContext) }; + } + } + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +fn c_ptr(value: &[u8]) -> *const c_char { + debug_assert_eq!(value.last(), Some(&0)); + value.as_ptr().cast() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn missing_schema_is_reported_without_creating_settings() { + let Some(api) = gio_api() else { + return; + }; + assert!(Settings::with_schema(api, b"com.microsoft.os-proxy-resolver.missing\0").is_none()); + } + + #[test] + fn missing_library_is_reported_without_linking_gio() { + assert!(GioApi::load_from( + "libglib-os-proxy-resolver-missing.so", + "libgobject-os-proxy-resolver-missing.so", + "libgio-os-proxy-resolver-missing.so" + ) + .is_err()); + } +} diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 53f774e..458bd6b 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -3,84 +3,48 @@ * Licensed under the MIT License. See LICENSE.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -//! Linux: GNOME's `org.gnome.system.proxy` GSettings tree, read via one -//! `gsettings list-recursively` invocation (recurses into the .http/.https/ -//! .socks child schemas). No GNOME (or no `gsettings` binary) means no OS -//! config — the env-var layer above this is then the only source, which is -//! the right default for headless boxes. KDE and proxy authentication are -//! non-goals. -//! -//! Change watching: `dconf watch /system/proxy/` (recursive) when available, -//! falling back to `gsettings monitor org.gnome.system.proxy` (top-level keys -//! only). Both are long-running child processes whose stdout lines signal -//! changes. +//! Linux: GNOME's `org.gnome.system.proxy` GSettings tree, accessed in-process +//! through GIO. The shared libraries are loaded at runtime so headless systems +//! without GLib can still use the environment-variable layer. Change signals +//! are dispatched on a private GLib main context thread. KDE and proxy +//! authentication are non-goals. use super::{OsProxyConfig, StaticRules}; use crate::bypass::BypassRules; use crate::types::{LinuxProxyConfig, PlatformProxyConfig, ProxyKind}; -use std::collections::HashMap; -use std::io::{self, BufRead}; -use std::os::unix::process::CommandExt; -use std::process::{Child, Command, Stdio}; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::Arc; + +#[path = "gio.rs"] +mod gio; +pub(crate) use gio::Watcher; pub(crate) fn read_config() -> OsProxyConfig { - let output = Command::new("gsettings") - .args(["list-recursively", "org.gnome.system.proxy"]) - .stdin(Stdio::null()) - .output(); - let output = match output { - Ok(o) if o.status.success() => o, - _ => return OsProxyConfig::default(), - }; - parse_gsettings_output(&String::from_utf8_lossy(&output.stdout)) + gio::read_values() + .map(config_from_values) + .unwrap_or_default() } -fn parse_gsettings_output(text: &str) -> OsProxyConfig { - // Lines look like: `org.gnome.system.proxy.http host 'proxy.example.com'` - let mut values: HashMap<(String, String), String> = HashMap::new(); - for line in text.lines() { - let mut parts = line.splitn(3, char::is_whitespace); - if let (Some(schema), Some(key), Some(value)) = (parts.next(), parts.next(), parts.next()) { - values.insert( - (schema.to_string(), key.to_string()), - value.trim().to_string(), - ); - } - } - let get = |schema: &str, key: &str| { - values - .get(&(format!("org.gnome.system.proxy{schema}"), key.to_string())) - .map(String::as_str) - }; - - let mode = get("", "mode").map(unquote).unwrap_or_default(); - let ignore_hosts = get("", "ignore-hosts") - .map(parse_string_array) - .unwrap_or_default(); +fn config_from_values(values: gio::Values) -> OsProxyConfig { let mut config = OsProxyConfig { platform: Some(PlatformProxyConfig::Linux(LinuxProxyConfig { - mode: (!mode.is_empty()).then(|| mode.clone()), - ignore_hosts: ignore_hosts.clone(), + mode: (!values.mode.is_empty()).then(|| values.mode.clone()), + ignore_hosts: values.ignore_hosts.clone(), })), ..Default::default() }; - match mode.as_str() { + match values.mode.as_str() { "auto" => { - config.pac_url = get("", "autoconfig-url") - .map(unquote) - .filter(|s| !s.is_empty()); + config.pac_url = (!values.autoconfig_url.is_empty()).then_some(values.autoconfig_url); // GNOME semantics: "auto" with no PAC URL means WPAD. config.auto_detect = config.pac_url.is_none(); } "manual" => { - let mut rules = StaticRules::default(); - rules.http = host_port(get(".http", "host"), get(".http", "port")).map(ProxyKind::Http); - rules.https = - host_port(get(".https", "host"), get(".https", "port")).map(ProxyKind::Http); - rules.socks = - host_port(get(".socks", "host"), get(".socks", "port")).map(ProxyKind::Socks); - rules.bypass = BypassRules::parse(ignore_hosts.iter().map(|s| s.as_str())); + let rules = StaticRules { + http: host_port(&values.http_host, values.http_port).map(ProxyKind::Http), + https: host_port(&values.https_host, values.https_port).map(ProxyKind::Http), + socks: host_port(&values.socks_host, values.socks_port).map(ProxyKind::Socks), + bypass: BypassRules::parse(values.ignore_hosts.iter().map(String::as_str)), + }; if !rules.is_empty() { config.static_rules = Some(rules); } @@ -90,227 +54,16 @@ fn parse_gsettings_output(text: &str) -> OsProxyConfig { config } -fn unquote(s: &str) -> String { - s.trim().trim_matches('\'').to_string() -} - -fn host_port(host: Option<&str>, port: Option<&str>) -> Option { - let host = unquote(host?); +fn host_port(host: &str, port: i32) -> Option { if host.is_empty() { return None; } - let port = port - .and_then(|p| p.trim().parse::().ok()) - .filter(|&p| p != 0)?; + let port = u16::try_from(port).ok().filter(|&port| port != 0)?; Some(format!("{host}:{port}")) } -/// Parse a GVariant string array like `['localhost', '127.0.0.0/8']` -/// (possibly with an `@as` type annotation when empty). -fn parse_string_array(s: &str) -> Vec { - let s = s.trim().trim_start_matches("@as").trim(); - let inner = s - .strip_prefix('[') - .and_then(|s| s.strip_suffix(']')) - .unwrap_or(""); - inner - .split(',') - .map(|item| unquote(item.trim())) - .filter(|item| !item.is_empty()) - .collect() -} - -// -------------------------------------------------------------------------- -// Change watcher - -pub(crate) struct Watcher { - child: Arc>>, - thread: Option>, -} - pub(crate) fn spawn_watcher(on_change: Arc) -> Watcher { - spawn_watcher_thread(spawn_system_watcher, on_change) -} - -fn spawn_system_watcher() -> Option { - let mut dconf = Command::new("dconf"); - dconf.args(["watch", "/system/proxy/"]); - configure_watcher_command(&mut dconf); - let dconf_error = match dconf.spawn() { - Ok(child) => return Some(child), - Err(error) => error, - }; - - let mut gsettings = Command::new("gsettings"); - gsettings.args(["monitor", "org.gnome.system.proxy"]); - configure_watcher_command(&mut gsettings); - match gsettings.spawn() { - Ok(child) => Some(child), - Err(gsettings_error) => { - log::debug!( - "proxy watcher: failed to spawn dconf ({dconf_error}) and gsettings \ - ({gsettings_error}); changes will not be detected" - ); - None - } - } -} - -fn spawn_watcher_thread( - spawn_child: impl FnOnce() -> Option + Send + 'static, - on_change: Arc, -) -> Watcher { - let child = Arc::new(Mutex::new(None)); - let thread_child = child.clone(); - let (started_tx, started_rx) = mpsc::sync_channel(0); - let thread = std::thread::Builder::new() - .name("os-proxy-watch".into()) - .spawn(move || { - let Some(mut spawned_child) = spawn_child() else { - let _ = started_tx.send(()); - return; - }; - let Some(stdout) = spawned_child.stdout.take() else { - let _ = spawned_child.kill(); - let _ = spawned_child.wait(); - log::debug!("proxy watcher: child stdout was not piped"); - let _ = started_tx.send(()); - return; - }; - *thread_child - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(spawned_child); - let _ = started_tx.send(()); - - let reader = std::io::BufReader::new(stdout); - for line in reader.lines() { - let Ok(line) = line else { break }; - // dconf watch prints the changed path on an unindented - // line, then the value indented; only count the former. - if !line.is_empty() && !line.starts_with(char::is_whitespace) { - on_change(); - } - } - }) - .expect("failed to spawn proxy watcher thread"); - started_rx - .recv() - .expect("proxy watcher thread stopped during startup"); - Watcher { - child, - thread: Some(thread), - } -} - -/// Configure a proxy watcher to terminate with its owner and inherit only standard I/O. -fn configure_watcher_command(command: &mut Command) { - let expected_parent = std::process::id() as libc::pid_t; - let file_descriptor_limit = - file_descriptor_limit().map_err(|error| error.raw_os_error().unwrap_or(libc::EIO)); - command - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()); - - // SAFETY: pre_exec runs after fork in the single-threaded child. These - // operations only invoke async-signal-safe Linux system calls. - unsafe { - command.pre_exec(move || { - if libc::prctl(libc::PR_SET_PDEATHSIG, libc::SIGKILL) == -1 { - return Err(std::io::Error::last_os_error()); - } - // The parent may have exited between fork and PR_SET_PDEATHSIG. - if libc::getppid() != expected_parent { - libc::_exit(1); - } - mark_file_descriptors_close_on_exec(file_descriptor_limit)?; - Ok(()) - }); - } -} - -fn file_descriptor_limit() -> io::Result { - let mut limit = std::mem::MaybeUninit::::uninit(); - // SAFETY: getrlimit initializes the supplied rlimit on success. - if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, limit.as_mut_ptr()) } == -1 { - return Err(io::Error::last_os_error()); - } - // SAFETY: the successful getrlimit call initialized limit. - let limit = unsafe { limit.assume_init() }.rlim_cur; - Ok(limit.min(libc::c_int::MAX as libc::rlim_t) as libc::c_int) -} - -fn mark_file_descriptors_close_on_exec( - file_descriptor_limit: Result, -) -> io::Result<()> { - // CLOSE_RANGE_CLOEXEC preserves Rust's internal exec-error pipe until exec - // succeeds while preventing every non-stdio descriptor from reaching the - // watcher program. - // SAFETY: close_range operates on the calling process's descriptor table. - loop { - let result = unsafe { - libc::syscall( - libc::SYS_close_range, - 3 as libc::c_uint, - libc::c_uint::MAX, - libc::CLOSE_RANGE_CLOEXEC, - ) - }; - if result == 0 { - return Ok(()); - } - if io::Error::last_os_error().raw_os_error() != Some(libc::EINTR) { - break; - } - } - - // Older kernels and restricted seccomp profiles may not support - // close_range. fcntl is slower but provides equivalent behavior. - let file_descriptor_limit = file_descriptor_limit.map_err(io::Error::from_raw_os_error)?; - for fd in 3..file_descriptor_limit { - let flags = loop { - // SAFETY: fcntl accepts any integer descriptor and reports EBADF - // for descriptors that are not open. - let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - if flags != -1 { - break flags; - } - let error = io::Error::last_os_error(); - match error.raw_os_error() { - Some(libc::EBADF) => break -1, - Some(libc::EINTR) => continue, - _ => return Err(error), - } - }; - if flags == -1 { - continue; - } - if flags & libc::FD_CLOEXEC == 0 { - loop { - // SAFETY: flags came from F_GETFD for this descriptor. - if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } != -1 { - break; - } - let error = io::Error::last_os_error(); - if error.raw_os_error() != Some(libc::EINTR) { - return Err(error); - } - } - } - } - Ok(()) -} - -impl Drop for Watcher { - fn drop(&mut self) { - if let Some(mut child) = self.child.lock().unwrap_or_else(|e| e.into_inner()).take() { - let _ = child.kill(); - let _ = child.wait(); - } - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } + gio::spawn_watcher(on_change) } /// DNS search domains from the OS resolver configuration. On Linux this is @@ -323,187 +76,23 @@ pub(crate) fn dns_search_domains() -> Vec { #[cfg(test)] mod tests { use super::*; - use std::os::fd::AsRawFd; - #[cfg(target_arch = "x86_64")] - use std::path::Path; - use std::time::{Duration, Instant}; + use std::process::Command; + use std::sync::{mpsc, Mutex}; + use std::time::Duration; - #[cfg(target_arch = "x86_64")] - const WATCHER_SUBPROCESS_ENV: &str = "OS_PROXY_RESOLVER_WATCHER_SUBPROCESS"; - - fn spawn_test_watcher() -> Watcher { - spawn_watcher_thread( - || { - let mut command = Command::new("sleep"); - command.arg("60"); - configure_watcher_command(&mut command); - Some(command.spawn().expect("failed to spawn test watcher")) - }, - Arc::new(|| {}), - ) - } - - fn watcher_child_pid(watcher: &Watcher) -> u32 { - watcher - .child - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .as_ref() - .expect("test watcher child was not started") - .id() - } - - fn process_is_running(pid: u32) -> bool { - let stat = match std::fs::read_to_string(format!("/proc/{pid}/stat")) { - Ok(stat) => stat, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return false, - Err(error) => panic!("failed to read status for process {pid}: {error}"), - }; - stat.rsplit_once(") ") - .and_then(|(_, fields)| fields.chars().next()) - .is_some_and(|state| state != 'Z') - } - - fn wait_for_process_exit(pid: u32) -> bool { - let deadline = Instant::now() + Duration::from_secs(5); - while process_is_running(pid) { - if Instant::now() >= deadline { - return false; - } - std::thread::sleep(Duration::from_millis(10)); - } - true - } - - #[cfg(target_arch = "x86_64")] - struct ProcessGuard(Option); - - #[cfg(target_arch = "x86_64")] - impl Drop for ProcessGuard { - fn drop(&mut self) { - let Some(pid) = self.0 else { - return; - }; - // SAFETY: kill with a positive PID and SIGKILL has no memory-safety - // requirements. It is only a fallback for a failed test. - unsafe { - libc::kill(pid as libc::pid_t, libc::SIGKILL); - } - } - } - - #[test] - fn watcher_outlives_the_thread_that_created_it() { - let (watcher, pid) = std::thread::spawn(|| { - let watcher = spawn_test_watcher(); - let pid = watcher_child_pid(&watcher); - (watcher, pid) - }) - .join() - .expect("watcher creator thread panicked"); - - assert!( - process_is_running(pid), - "watcher exited with its short-lived caller thread" - ); - drop(watcher); - assert!( - wait_for_process_exit(pid), - "watcher did not exit when dropped" - ); - } - - #[test] - fn watcher_does_not_inherit_unrelated_file_descriptors() { - let file = std::fs::File::open("/dev/null").expect("failed to open test descriptor"); - let fd = file.as_raw_fd(); - // SAFETY: fd belongs to file and remains open for the duration of the test. - let original_flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; - assert_ne!(original_flags, -1, "failed to read descriptor flags"); - // SAFETY: fd belongs to file and original_flags came from F_GETFD. - assert_ne!( - unsafe { libc::fcntl(fd, libc::F_SETFD, original_flags & !libc::FD_CLOEXEC) }, - -1, - "failed to make test descriptor inheritable" - ); - - let watcher = spawn_test_watcher(); - // Restore the parent's flags immediately; the child has its own descriptor table. - // SAFETY: fd still belongs to file and original_flags came from F_GETFD. - assert_ne!( - unsafe { libc::fcntl(fd, libc::F_SETFD, original_flags) }, - -1, - "failed to restore test descriptor flags" - ); - let pid = watcher_child_pid(&watcher); - assert!( - !std::path::Path::new(&format!("/proc/{pid}/fd/{fd}")).exists(), - "watcher inherited unrelated descriptor {fd}" - ); - } - - // `cross` runs foreign-architecture test binaries through QEMU but does not - // configure child processes to do so, so a test binary can only re-exec - // itself in the host-compatible x86_64 jobs. - #[cfg(target_arch = "x86_64")] - #[test] - fn watcher_exits_when_parent_process_exits_without_drop() { - let pid_file = std::env::temp_dir().join(format!( - "os-proxy-resolver-watcher-{}.pid", - std::process::id() - )); - let _ = std::fs::remove_file(&pid_file); - let status = Command::new(std::env::current_exe().expect("test executable unavailable")) - .arg("watcher_parent_death_subprocess_helper") - .arg("--nocapture") - .env(WATCHER_SUBPROCESS_ENV, &pid_file) - .status() - .expect("failed to run watcher parent subprocess"); - let pid_result = std::fs::read_to_string(&pid_file); - let _ = std::fs::remove_file(&pid_file); - - assert!(status.success(), "watcher parent subprocess failed"); - let pid = pid_result - .expect("watcher parent subprocess did not report its child PID") - .parse() - .expect("watcher parent subprocess reported an invalid child PID"); - let mut guard = ProcessGuard(Some(pid)); - let exited = wait_for_process_exit(pid); - if exited { - guard.0 = None; - } - assert!(exited, "watcher survived after its parent process exited"); - } - - #[cfg(target_arch = "x86_64")] - #[test] - fn watcher_parent_death_subprocess_helper() { - let Some(pid_file) = std::env::var_os(WATCHER_SUBPROCESS_ENV) else { - return; - }; - let watcher = spawn_test_watcher(); - std::fs::write( - Path::new(&pid_file), - watcher_child_pid(&watcher).to_string(), - ) - .expect("failed to report watcher PID"); - std::process::exit(0); - } + static OS_TEST_LOCK: Mutex<()> = Mutex::new(()); #[test] - fn parses_manual_mode() { - let out = "\ -org.gnome.system.proxy mode 'manual' -org.gnome.system.proxy autoconfig-url '' -org.gnome.system.proxy ignore-hosts ['localhost', '127.0.0.0/8', '::1'] -org.gnome.system.proxy.http host 'hp.example.com' -org.gnome.system.proxy.http port 3128 -org.gnome.system.proxy.https host '' -org.gnome.system.proxy.https port 0 -org.gnome.system.proxy.socks host 'sp.example.com' -org.gnome.system.proxy.socks port 1080 -"; - let cfg = parse_gsettings_output(out); + fn builds_manual_mode() { + let cfg = config_from_values(gio::Values { + mode: "manual".into(), + ignore_hosts: vec!["localhost".into(), "127.0.0.0/8".into(), "::1".into()], + http_host: "hp.example.com".into(), + http_port: 3128, + socks_host: "sp.example.com".into(), + socks_port: 1080, + ..Default::default() + }); assert!(!cfg.auto_detect); assert_eq!(cfg.pac_url, None); let rules = cfg.static_rules.unwrap(); @@ -526,32 +115,34 @@ org.gnome.system.proxy.socks port 1080 } #[test] - fn parses_auto_modes() { - let with_url = "org.gnome.system.proxy mode 'auto'\norg.gnome.system.proxy autoconfig-url 'http://x/p.pac'\n"; - let cfg = parse_gsettings_output(with_url); + fn builds_auto_modes() { + let cfg = config_from_values(gio::Values { + mode: "auto".into(), + autoconfig_url: "http://x/p.pac".into(), + ..Default::default() + }); assert!(!cfg.auto_detect); assert_eq!(cfg.pac_url.as_deref(), Some("http://x/p.pac")); - let wpad = "org.gnome.system.proxy mode 'auto'\norg.gnome.system.proxy autoconfig-url ''\n"; - let cfg = parse_gsettings_output(wpad); + let cfg = config_from_values(gio::Values { + mode: "auto".into(), + ..Default::default() + }); assert!(cfg.auto_detect); assert_eq!(cfg.pac_url, None); } #[test] fn none_mode_is_direct() { - let cfg = parse_gsettings_output("org.gnome.system.proxy mode 'none'\n"); + let cfg = config_from_values(gio::Values { + mode: "none".into(), + ..Default::default() + }); assert!(!cfg.auto_detect); assert!(cfg.pac_url.is_none()); assert!(cfg.static_rules.is_none()); } - #[test] - fn empty_array_annotation() { - assert_eq!(parse_string_array("@as []"), Vec::::new()); - assert_eq!(parse_string_array("['a', 'b']"), vec!["a", "b"]); - } - fn gsettings_get(schema: &str, key: &str) -> Option { let out = Command::new("gsettings") .args(["get", schema, key]) @@ -611,6 +202,9 @@ org.gnome.system.proxy.socks port 1080 ); return; } + let _test_lock = OS_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); // No working gsettings / GNOME proxy schema (e.g. headless minimal // image) means there is nothing to round-trip through. if gsettings_get("org.gnome.system.proxy", "mode").is_none() { @@ -683,4 +277,57 @@ org.gnome.system.proxy.socks port 1080 ); assert!(!cfg.auto_detect); } + + #[test] + fn os_watcher_observes_root_and_child_changes() { + if std::env::var_os("OS_PROXY_RESOLVER_OS_TESTS").is_none() { + eprintln!( + "skipping os_watcher_observes_root_and_child_changes: \ + set OS_PROXY_RESOLVER_OS_TESTS=1 to run OS watcher tests" + ); + return; + } + let _test_lock = OS_TEST_LOCK + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let current_mode = gsettings_get("org.gnome.system.proxy", "mode"); + if current_mode.is_none() { + eprintln!( + "skipping os_watcher_observes_root_and_child_changes: \ + gsettings org.gnome.system.proxy unavailable" + ); + return; + } + + let _guard = GSettingsGuard::save(&[ + ("org.gnome.system.proxy", "mode"), + ("org.gnome.system.proxy.http", "host"), + ]); + let (changed_tx, changed_rx) = mpsc::channel(); + let watcher = spawn_watcher(Arc::new(move || { + let _ = changed_tx.send(()); + })); + + let next_mode = if current_mode.as_deref() == Some("'manual'") { + "none" + } else { + "manual" + }; + assert!(gsettings_set("org.gnome.system.proxy", "mode", next_mode)); + changed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("GIO watcher did not observe root proxy setting change"); + + while changed_rx.try_recv().is_ok() {} + assert!(gsettings_set( + "org.gnome.system.proxy.http", + "host", + "watcher-test.example.com" + )); + changed_rx + .recv_timeout(Duration::from_secs(5)) + .expect("GIO watcher did not observe child proxy setting change"); + + drop(watcher); + } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 70acabb..d201a3b 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -9,7 +9,7 @@ //! //! - `read_config()` — a snapshot of the OS proxy configuration //! - `spawn_watcher(on_change)` — a thread wired to the native change signal -//! (SCDynamicStore callback / dconf-gsettings monitor / registry notify) +//! (SCDynamicStore callback / GSettings callback / registry notify) //! that invokes `on_change` on every possible change. Returned handle stops //! the watcher on drop.