From 1cecfafb031a7d26cfe2d375d722d00f6c1d51f5 Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 8 Jul 2026 09:41:11 +0200 Subject: [PATCH 1/2] Replace pacparser with reimplementation from spec from pac-eval branch --- .github/workflows/ci.yml | 30 +- .gitmodules | 3 - Cargo.lock | 11 +- Cargo.toml | 25 +- LICENSE.txt | 8 +- README.md | 46 +-- ThirdPartyNotices.txt | 49 ++-- build.rs | 106 ------- src/lib.rs | 21 +- src/pac/engine/ffi.rs | 483 +++++++++++++++++++++++++++++++ src/pac/engine/mod.rs | 214 ++++++++++++++ src/pac/engine/pac_helpers.js | 377 ++++++++++++++++++++++++ src/pac/engine/pac_helpers_ms.js | 232 +++++++++++++++ src/pac/engine/state.rs | 167 +++++++++++ src/pac/ffi.rs | 55 ---- src/pac/mod.rs | 127 ++++---- src/pac/shim.c | 42 --- src/resolver.rs | 10 +- tests/data/ms_extensions.pac | 2 +- tests/pac_corpus.rs | 2 +- vendor/pacparser | 1 - 21 files changed, 1633 insertions(+), 378 deletions(-) delete mode 100644 .gitmodules delete mode 100644 build.rs create mode 100644 src/pac/engine/ffi.rs create mode 100644 src/pac/engine/mod.rs create mode 100644 src/pac/engine/pac_helpers.js create mode 100644 src/pac/engine/pac_helpers_ms.js create mode 100644 src/pac/engine/state.rs delete mode 100644 src/pac/ffi.rs delete mode 100644 src/pac/shim.c delete mode 160000 vendor/pacparser diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31af72b..30ca93b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: fail-fast: false matrix: include: - # Windows: pure Rust — no C toolchain, no pacparser/QuickJS. + # Windows: pure Rust — no C toolchain, no QuickJS. - name: windows x64 os: windows-latest target: x86_64-pc-windows-msvc @@ -26,7 +26,7 @@ jobs: target: aarch64-pc-windows-msvc run_tests: true - # macOS: native runners provide the C toolchain for pacparser. + # macOS: native runners provide the C toolchain for QuickJS. - name: macos arm64 os: macos-latest target: aarch64-apple-darwin @@ -43,7 +43,7 @@ jobs: run_tests: true # Linux ARM: cross — its images ship the C cross-toolchain - # pacparser needs, and run tests under qemu. + # QuickJS needs, and run tests under qemu. - name: linux aarch64 os: ubuntu-latest target: aarch64-unknown-linux-gnu @@ -57,8 +57,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Rust run: | @@ -91,26 +89,14 @@ jobs: - name: Stage pactester artifact run: | ext="" - lib="" case "${{ matrix.target }}" in - *windows*) ext=".exe" ;; - *darwin*|*apple*) lib="libpacparser.dylib" ;; - *) lib="libpacparser.so" ;; + *windows*) ext=".exe" ;; esac stage="dist/pactester-${{ matrix.target }}" mkdir -p "$stage" + # QuickJS is statically linked into the binary, so pactester is + # self-contained (WinHTTP handles PAC on Windows). cp "target/${{ matrix.target }}/release/examples/pactester${ext}" "$stage/" - # On macOS/Linux pactester links libpacparser dynamically; ship it - # right next to the binary so the @loader_path / $ORIGIN rpath finds - # it (WinHTTP handles PAC on Windows, so there's no dylib there). - if [ -n "$lib" ]; then - found=$(find "target/${{ matrix.target }}/release/build" -name "$lib" | head -n1) - if [ -z "$found" ]; then - echo "::error::could not find $lib to bundle" - exit 1 - fi - cp "$found" "$stage/" - fi shell: bash - name: Upload pactester artifact @@ -135,8 +121,6 @@ jobs: os: ubuntu-latest steps: - uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Rust run: | @@ -177,8 +161,6 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 - with: - submodules: recursive - name: Install Rust run: | rustup toolchain install stable --profile minimal --component rustfmt --component clippy --no-self-update diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index b407d29..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vendor/pacparser"] - path = vendor/pacparser - url = https://github.com/manugarg/pacparser diff --git a/Cargo.lock b/Cargo.lock index de73b70..3b63c41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -249,9 +249,9 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" name = "os-proxy-resolver" version = "0.1.0" dependencies = [ - "cc", "core-foundation", "log", + "rquickjs-sys", "system-configuration", "tokio", "ureq", @@ -312,6 +312,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rquickjs-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef73520804cf5aa4876097ac5733058d628c769eba9678e0f4dba5a4ef79703" +dependencies = [ + "cc", +] + [[package]] name = "rustls" version = "0.23.41" diff --git a/Cargo.toml b/Cargo.toml index e949c78..3a8506e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,22 +2,15 @@ name = "os-proxy-resolver" version = "0.1.0" edition = "2021" -rust-version = "1.75" -# First-party code is MIT (see LICENSE.txt); the bundled pacparser library -# under vendor/pacparser is LGPL-3.0-or-later (see vendor/pacparser/COPYING). -license = "MIT AND LGPL-3.0-or-later" -description = "Resolve the OS-configured proxy (static, PAC, WPAD) for a URL, with change notification. WinHTTP on Windows; SystemConfiguration/gsettings + vendored pacparser (QuickJS) on macOS/Linux." +rust-version = "1.77" +# 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. +license = "MIT" +description = "Resolve the OS-configured proxy (static, PAC, WPAD) for a URL, with change notification. WinHTTP on Windows; SystemConfiguration/gsettings + an embedded QuickJS PAC engine on macOS/Linux." repository = "https://github.com/microsoft/os-proxy-resolver" keywords = ["proxy", "pac", "wpad", "winhttp", "system-proxy"] categories = ["network-programming", "os"] -exclude = [ - "vendor/pacparser/tests", - "vendor/pacparser/web", - "vendor/pacparser/docs", - "vendor/pacparser/examples", - "vendor/pacparser/tools", - "vendor/pacparser/src/pymod", -] [lib] crate-type = ["rlib", "cdylib"] @@ -35,6 +28,9 @@ tokio = { version = "1", optional = true, default-features = false, features = [ [target.'cfg(not(windows))'.dependencies] # PAC / wpad.dat fetching. Sync, small, rustls-backed so no OpenSSL cross headaches. ureq = { version = "2", default-features = false, features = ["tls", "gzip"] } +# Embedded QuickJS-NG (MIT) for the PAC engine. Vendors and compiles the +# quickjs-ng C sources via `cc`. Not used on Windows (WinHTTP handles PAC). +rquickjs-sys = "0.12.1" [target.'cfg(target_os = "macos")'.dependencies] core-foundation = "0.9" @@ -49,8 +45,5 @@ windows-sys = { version = "0.60", features = [ "Win32_System_Threading", ] } -[build-dependencies] -cc = "1" - [dev-dependencies] tokio = { version = "1", features = ["sync", "rt", "macros", "time"] } diff --git a/LICENSE.txt b/LICENSE.txt index b45ba63..be17b3c 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -22,7 +22,7 @@ SOFTWARE. --- -This product bundles pacparser (in vendor/pacparser), which is licensed under -the GNU Lesser General Public License, version 3. See vendor/pacparser/COPYING -for the full license text. The MIT license above applies only to the -first-party source of this project, not to the bundled pacparser library. +The embedded PAC engine (in src/pac/engine) links the QuickJS-NG JavaScript +engine on macOS and Linux via the MIT-licensed rquickjs-sys crate. QuickJS-NG +is also MIT-licensed. See ThirdPartyNotices.txt for details. The MIT license +above applies to the first-party source of this project. diff --git a/README.md b/README.md index 4345851..83ae36a 100644 --- a/README.md +++ b/README.md @@ -38,28 +38,32 @@ resolution. | | config source | PAC + WPAD | change signal | |---|---|---|---| | **Windows** | `WinHttpGetIEProxyConfigForCurrentUser` | WinHTTP `WinHttpGetProxyForUrl` (PAC eval + DHCP/DNS WPAD in the OS) | registry change notification | -| **macOS** | `SCDynamicStoreCopyProxies` | vendored [pacparser] (QuickJS) + DNS WPAD | `SCDynamicStore` callback | -| **Linux** | GNOME `org.gnome.system.proxy` via `gsettings` | vendored [pacparser] (QuickJS) + DNS WPAD | `dconf watch` / `gsettings monitor` | +| **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` | -Windows builds are **pure Rust** — pacparser/QuickJS are neither compiled nor -linked there. On macOS/Linux the vendored C sources (two files: -`pacparser.c` + the QuickJS-NG amalgam) are compiled directly by `build.rs` -via the `cc` crate; no autotools/make, which keeps cross-compilation clean. +Windows builds are **pure Rust** — the QuickJS PAC engine is neither compiled +nor linked there. On macOS/Linux the PAC engine embeds QuickJS-NG via the +MIT-licensed `rquickjs-sys` crate (which compiles the QuickJS-NG C sources); +no autotools/make, which keeps cross-compilation clean. The PAC helper +functions are first-party JavaScript implemented from the public PAC +specification. Non-goals: DHCP-based WPAD (option 252) on macOS/Linux (Windows gets it via WinHTTP), KDE proxy settings, proxy authentication credentials. ## The PAC cage -A PAC file is untrusted JavaScript running on a live JS engine, and pacparser -has a single global, non-thread-safe context whose `dnsResolve()` / +A PAC file is untrusted JavaScript running on a live JS engine. The embedded +QuickJS context is neither `Send` nor `Sync`, and its `dnsResolve()` / `myIpAddress()` builtins block on real network I/O. Containment: -- **One process-global worker thread** owns the pacparser context; every - init/parse/find_proxy is serialized through a command channel. -- **Hard timeout** on every `FindProxyForURL` call. A wedged evaluator (e.g. - an infinite JS loop) makes subsequent calls fail fast into the fallback - path instead of queueing; service resumes if the worker ever recovers. +- **One process-global worker thread** owns the PAC engine; every + parse/find_proxy is serialized through a command channel. +- **Hard timeout** on every `FindProxyForURL` call. A runaway JS loop is + interrupted inside the engine by its own deadline; a blocking native + builtin (e.g. slow DNS) that outlasts the caller's deadline makes + subsequent calls fail fast into the fallback path instead of queueing, + and service resumes once the worker recovers. - **URL sanitization** before evaluation (Chromium-style): identity is always stripped; for https URLs the path and query are dropped, so a hostile PAC/WPAD author can't read request details. @@ -112,7 +116,7 @@ is returned and retried. ## Building ```sh -git clone --recurse-submodules # pacparser is a git submodule +git clone cargo build # needs a C compiler on macOS/Linux only cargo test ``` @@ -133,17 +137,17 @@ wired up yet. GitHub Actions builds and tests: Windows x64 + arm64 (pure Rust), macOS x64 + arm64, Linux x86_64 (native), Linux aarch64 + armv7 (via `cross`, whose images -ship the C cross-toolchain pacparser needs). +ship the C cross-toolchain QuickJS needs). ## License The first-party code in this repository is licensed under the [MIT License](LICENSE.txt), Copyright (c) Microsoft Corporation. -The bundled [pacparser] submodule is LGPL-3.0 and embeds QuickJS-NG (MIT); the -compiled library on macOS/Linux statically links both, so the resulting binary -is a combined work governed by `MIT AND LGPL-3.0-or-later` — check that this -suits your distribution model (in particular the LGPL relinking obligation). -Windows binaries contain neither pacparser nor QuickJS. +On macOS/Linux the built-in PAC engine embeds QuickJS-NG (MIT) via the +MIT-licensed `rquickjs-sys` crate, statically linked into the compiled library; +the PAC helper functions are first-party JavaScript implemented from the public +PAC specification. Everything is permissively licensed. Windows binaries +contain no JavaScript engine at all (WinHTTP handles PAC). -[pacparser]: https://github.com/manugarg/pacparser +[QuickJS]: https://github.com/quickjs-ng/quickjs diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index 0af7cf9..54dd780 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -4,29 +4,23 @@ This repository incorporates material as listed below or described in the code. --- -## pacparser +## PAC engine and helper functions -Included as a git submodule under `vendor/pacparser`. - -- Project: pacparser -- Homepage: https://github.com/manugarg/pacparser -- Copyright (c) 2007-2015 Manu Garg and contributors -- License: GNU Lesser General Public License, version 3 (LGPL-3.0-or-later) - -The full license text is available at `vendor/pacparser/COPYING` and at -https://www.gnu.org/licenses/lgpl-3.0.html. - -On macOS and Linux, pacparser is statically linked into this project's compiled -library, producing a combined work. See the LGPL-3.0 for the resulting -obligations (including the requirement to allow relinking against a modified -version of pacparser). Windows builds do not compile or link pacparser. +The PAC (Proxy Auto-Config) engine under `src/pac/engine` is first-party code +licensed under the MIT License (see LICENSE.txt). Its JavaScript helper +functions (`src/pac/engine/pac_helpers.js` and `pac_helpers_ms.js`) are +implemented solely from the public PAC specification — the Netscape "Navigator +Proxy Auto-Config File Format" document and Microsoft's published IPv6 PAC +extension documentation. --- -## QuickJS-NG +## QuickJS-NG (via rquickjs-sys) -Embedded within the pacparser sources under -`vendor/pacparser/src/quickjs`. +The PAC engine embeds the QuickJS-NG JavaScript engine on macOS and Linux, +linked through the MIT-licensed `rquickjs-sys` crate, which vendors and +compiles the QuickJS-NG C sources. Windows builds do not compile or link +QuickJS (WinHTTP handles PAC there). - Project: QuickJS-NG (a fork of QuickJS by Fabrice Bellard and Charlie Gordon) - Homepage: https://github.com/quickjs-ng/quickjs @@ -54,3 +48,22 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/build.rs b/build.rs deleted file mode 100644 index 6f858bc..0000000 --- a/build.rs +++ /dev/null @@ -1,106 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -fn main() { - let target = std::env::var("TARGET").unwrap_or_default(); - if target.contains("windows") { - // Windows delegates all proxy resolution (static, PAC, WPAD) to WinHTTP. - // No C toolchain, no pacparser, no QuickJS. - return; - } - - let out_dir = std::path::PathBuf::from(std::env::var("OUT_DIR").unwrap()); - let src = std::path::Path::new("vendor/pacparser/src"); - if !src.join("pacparser.c").exists() { - panic!("vendor/pacparser is missing. Run: git submodule update --init --recursive"); - } - - println!("cargo:rerun-if-changed=vendor/pacparser/src/pacparser.c"); - println!("cargo:rerun-if-changed=vendor/pacparser/src/pacparser.h"); - println!("cargo:rerun-if-changed=vendor/pacparser/src/pac_utils.h"); - println!("cargo:rerun-if-changed=vendor/pacparser/src/quickjs/quickjs.c"); - println!("cargo:rerun-if-changed=vendor/pacparser/src/quickjs/quickjs.h"); - println!("cargo:rerun-if-changed=src/pac/shim.c"); - - // Compile pacparser + QuickJS + our error shim as position-independent - // objects, but DO NOT archive them into a static lib. Instead we link them - // into a *shared* library (`libpacparser`) below. Keeping pacparser (LGPL) - // in its own replaceable shared object is the LGPL v3 §4(d) "suitable shared - // library mechanism": a user can drop in a modified pacparser without - // relinking the Rust application. - let mut build = cc::Build::new(); - build - .file(src.join("pacparser.c")) - .file(src.join("quickjs/quickjs.c")) - .file("src/pac/shim.c") - .include(src.join("quickjs")) - .define("VERSION", "\"1.5.1-vendored\"") - .flag_if_supported("-fno-strict-aliasing") - .flag_if_supported("-funsigned-char") - // Shared objects require position-independent code. - .pic(true) - // The vendored C sources are not ours to lint. - .warnings(false); - - if target.contains("linux") { - build.define("_GNU_SOURCE", None); - } - - let objects = build.compile_intermediates(); - - // Link the objects into a shared library in OUT_DIR. - let is_macos = target.contains("darwin") || target.contains("apple"); - let lib_file = if is_macos { - "libpacparser.dylib" - } else { - "libpacparser.so" - }; - let lib_path = out_dir.join(lib_file); - - let mut link = build.get_compiler().to_command(); - link.args(&objects); - if is_macos { - // An @rpath install name lets a distributor relocate the dylib and add - // their own LC_RPATH (e.g. @loader_path) when they ship it alongside a - // binary. libm/pthread live in libSystem, so nothing extra to link. - link.arg("-dynamiclib") - .arg("-install_name") - .arg("@rpath/libpacparser.dylib"); - } else { - // A soname keeps the runtime lookup name stable; QuickJS needs libm and - // (on glibc) libpthread, so resolve them into the shared object itself. - link.arg("-shared") - .arg("-Wl,-soname,libpacparser.so") - .arg("-lm") - .arg("-lpthread"); - } - link.arg("-o").arg(&lib_path); - - let status = link - .status() - .expect("failed to invoke the C compiler to link libpacparser"); - assert!(status.success(), "linking libpacparser failed: {status}"); - - // Link the Rust crate dynamically against the shared pacparser. - println!("cargo:rustc-link-search=native={}", out_dir.display()); - println!("cargo:rustc-link-lib=dylib=pacparser"); - - // Dev/test/examples in THIS package need to find the dylib at runtime; an - // absolute OUT_DIR rpath makes `cargo test` / `cargo run --example` work in - // place. NOTE: rpath link-args are NOT propagated to external consumers. - // A distributor shipping a prebuilt binary should place libpacparser next - // to it and add an $ORIGIN (Linux) / @loader_path (macOS) rpath instead. - println!("cargo:rustc-link-arg=-Wl,-rpath,{}", out_dir.display()); - - // Also add a *relocatable* rpath so a prebuilt binary can find - // libpacparser when it's shipped right next to it (see the CI packaging). - // This is harmless in-tree — the absolute OUT_DIR rpath above is what dev - // builds actually use, since the dylib isn't next to the binary there. - if is_macos { - println!("cargo:rustc-link-arg=-Wl,-rpath,@loader_path"); - } else { - println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN"); - } -} diff --git a/src/lib.rs b/src/lib.rs index 29a3646..2862af6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -40,22 +40,23 @@ //! | | config source | PAC/WPAD engine | change signal | //! |---|---|---|---| //! | Windows | `WinHttpGetIEProxyConfigForCurrentUser` | WinHTTP (`WinHttpGetProxyForUrl`, incl. DHCP+DNS WPAD) | registry notification | -//! | macOS | `SCDynamicStoreCopyProxies` | vendored [pacparser] (QuickJS) + DNS WPAD | `SCDynamicStore` callback | -//! | Linux | GNOME `org.gnome.system.proxy` (gsettings) | vendored [pacparser] (QuickJS) + DNS WPAD | `dconf watch` / `gsettings monitor` | +//! | 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` | //! -//! On Windows no C engine is built or linked — PAC evaluation, DHCP and DNS +//! On Windows no JS engine is built or linked — PAC evaluation, DHCP and DNS //! WPAD are all WinHTTP's. On macOS/Linux, DHCP-based WPAD (option 252) is a //! documented non-goal; DNS-based WPAD walks `wpad.` with //! tight timeouts. //! //! # The PAC cage //! -//! A PAC file is untrusted JavaScript on a live JS engine, and pacparser has -//! a single global, non-thread-safe context with synchronously-blocking DNS -//! builtins. All pacparser calls are therefore serialized on one dedicated -//! worker thread, every `FindProxyForURL` call has a hard timeout, URLs are -//! stripped (identity always; path+query for https) before evaluation, and a -//! wedged evaluator makes callers fail fast into the fallback path instead of +//! A PAC file is untrusted JavaScript on a live JS engine. The embedded +//! QuickJS context is neither `Send` nor `Sync` and has synchronously-blocking +//! DNS builtins. All engine calls are therefore serialized on one dedicated +//! worker thread, every `FindProxyForURL` call has a hard timeout (a runaway +//! JS loop is interrupted inside the engine), URLs are stripped (identity +//! always; path+query for https) before evaluation, and a worker stuck in a +//! blocking builtin makes callers fail fast into the fallback path instead of //! queueing. The worker protocol is process-agnostic so the evaluator can //! later move out-of-process entirely (Chromium-style sandboxing). //! @@ -73,7 +74,7 @@ //! - With the `tokio` feature, [`ProxyResolver::watch`] additionally exposes //! a `tokio::sync::watch::Receiver` for async consumers. //! -//! [pacparser]: https://github.com/manugarg/pacparser +//! [QuickJS]: https://github.com/quickjs-ng/quickjs mod bypass; mod env_cfg; diff --git a/src/pac/engine/ffi.rs b/src/pac/engine/ffi.rs new file mode 100644 index 0000000..f251626 --- /dev/null +++ b/src/pac/engine/ffi.rs @@ -0,0 +1,483 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Private FFI layer over the QuickJS-NG C API (linked via the MIT-licensed +//! `rquickjs-sys` crate, which vendors and compiles the MIT-licensed +//! quickjs-ng sources). +//! +//! All `unsafe` code in the PAC engine is confined to this module; everything +//! it exposes to the rest of the engine is a safe method on [`Context`]. + +use std::ffi::{c_char, c_int, c_void, CStr, CString}; +use std::time::Instant; + +use rquickjs_sys as q; + +use super::state::HostState; +use super::Error; + +/// Signature of a native function callable from JavaScript. +type NativeFn = + unsafe extern "C" fn(*mut q::JSContext, q::JSValue, c_int, *mut q::JSValue) -> q::JSValue; + +/// One QuickJS runtime + context pair with the host state wired up as the +/// context/interrupt opaque. +/// +/// Contains raw pointers, so it is automatically `!Send` and `!Sync`; this is +/// intentional (a QuickJS context must stay on one thread). +pub(crate) struct Context { + rt: *mut q::JSRuntime, + ctx: *mut q::JSContext, + /// Boxed so the pointers registered with QuickJS stay valid for the + /// lifetime of the runtime, wherever the `Context` itself moves. + state: Box, +} + +impl Context { + pub(crate) fn new(state: Box, memory_limit: usize) -> Result { + // SAFETY: runtime and context are created, checked for null and freed + // in `Drop`. The opaque pointer handed to QuickJS points into the + // boxed `HostState`, which outlives the runtime (it is dropped after + // `Drop::drop` runs `JS_FreeRuntime`). + unsafe { + let rt = q::JS_NewRuntime(); + if rt.is_null() { + return Err(Error::Internal("failed to create QuickJS runtime".into())); + } + let ctx = q::JS_NewContext(rt); + if ctx.is_null() { + q::JS_FreeRuntime(rt); + return Err(Error::Internal("failed to create QuickJS context".into())); + } + let this = Context { rt, ctx, state }; + this.set_memory_limit(memory_limit); + let opaque = std::ptr::from_ref::(&this.state) + .cast_mut() + .cast::(); + q::JS_SetContextOpaque(ctx, opaque); + q::JS_SetInterruptHandler(rt, Some(interrupt_handler), opaque); + this.register_natives()?; + Ok(this) + } + } + + pub(crate) fn state(&self) -> &HostState { + &self.state + } + + pub(crate) fn set_memory_limit(&self, bytes: usize) { + // SAFETY: `self.rt` is a valid runtime for the lifetime of `self`. + unsafe { q::JS_SetMemoryLimit(self.rt, bytes as q::size_t) } + } + + /// Installs the native PAC helper functions on the global object. + fn register_natives(&self) -> Result<(), Error> { + self.register(c"alert", tramp_alert, 1)?; + self.register(c"dnsResolve", tramp_dns_resolve, 1)?; + self.register(c"myIpAddress", tramp_my_ip_address, 0)?; + self.register(c"dnsResolveEx", tramp_dns_resolve_ex, 1)?; + self.register(c"myIpAddressEx", tramp_my_ip_address_ex, 0)?; + Ok(()) + } + + fn register(&self, name: &CStr, func: NativeFn, arity: c_int) -> Result<(), Error> { + // SAFETY: `name` is a valid NUL-terminated string; the function value + // returned by `JS_NewCFunction2` is either consumed by + // `JS_SetPropertyStr` (which takes ownership even on failure) or is + // the non-refcounted exception marker. + unsafe { + let value = q::JS_NewCFunction2( + self.ctx, + Some(func), + name.as_ptr(), + arity, + q::JSCFunctionEnum_JS_CFUNC_generic, + 0, + ); + if q::JS_IsException(value) { + self.drain_exception(); + return Err(Error::Internal(format!( + "failed to create native function {}", + name.to_string_lossy() + ))); + } + let global = q::JS_GetGlobalObject(self.ctx); + let rc = q::JS_SetPropertyStr(self.ctx, global, name.as_ptr(), value); + q::JS_FreeValue(self.ctx, global); + if rc < 0 { + self.drain_exception(); + return Err(Error::Internal(format!( + "failed to register native function {}", + name.to_string_lossy() + ))); + } + Ok(()) + } + } + + /// Evaluates a script in the global scope, subject to the configured + /// deadline. `classify_syntax` maps `SyntaxError` to + /// [`Error::ScriptSyntax`]. + pub(crate) fn eval( + &self, + script: &str, + filename: &CStr, + classify_syntax: bool, + ) -> Result<(), Error> { + let code = CString::new(script) + .map_err(|_| Error::ScriptSyntax("script contains a NUL byte".into()))?; + self.state.begin_call(); + // SAFETY: `code` is NUL-terminated and lives across the call; the + // returned value is freed (the exception marker is not refcounted). + let ret = unsafe { + q::JS_Eval( + self.ctx, + code.as_ptr(), + script.len() as q::size_t, + filename.as_ptr(), + q::JS_EVAL_TYPE_GLOBAL as c_int, + ) + }; + self.state.end_call(); + // SAFETY: see above. + unsafe { + if q::JS_IsException(ret) { + return Err(self.error_from_exception(classify_syntax)); + } + q::JS_FreeValue(self.ctx, ret); + } + Ok(()) + } + + /// Returns whether a global with the given name exists and is callable. + pub(crate) fn has_global_function(&self, name: &CStr) -> bool { + // SAFETY: property lookup on the global object; all obtained values + // are freed. + unsafe { + let global = q::JS_GetGlobalObject(self.ctx); + let value = q::JS_GetPropertyStr(self.ctx, global, name.as_ptr()); + q::JS_FreeValue(self.ctx, global); + if q::JS_IsException(value) { + self.drain_exception(); + return false; + } + let is_function = q::JS_IsFunction(self.ctx, value); + q::JS_FreeValue(self.ctx, value); + is_function + } + } + + /// Calls the global PAC entry point `name(url, host)` and returns its + /// string result. + pub(crate) fn call_pac_function( + &self, + name: &CStr, + url: &str, + host: &str, + ) -> Result { + let missing = || Error::FunctionMissing(name.to_string_lossy().into_owned()); + // SAFETY: all values created here (function, argument strings, return + // value) are freed on every path; the exception marker is not + // refcounted and is never freed. + unsafe { + let global = q::JS_GetGlobalObject(self.ctx); + let func = q::JS_GetPropertyStr(self.ctx, global, name.as_ptr()); + q::JS_FreeValue(self.ctx, global); + if q::JS_IsException(func) { + self.drain_exception(); + return Err(missing()); + } + if !q::JS_IsFunction(self.ctx, func) { + q::JS_FreeValue(self.ctx, func); + return Err(missing()); + } + + let mut argv = [ + q::JS_NewStringLen(self.ctx, url.as_ptr().cast::(), url.len() as _), + q::JS_NewStringLen(self.ctx, host.as_ptr().cast::(), host.len() as _), + ]; + if argv.iter().any(|v| q::JS_IsException(*v)) { + for v in argv { + if !q::JS_IsException(v) { + q::JS_FreeValue(self.ctx, v); + } + } + q::JS_FreeValue(self.ctx, func); + self.drain_exception(); + return Err(Error::Internal( + "failed to allocate argument strings".into(), + )); + } + + self.state.begin_call(); + let ret = q::JS_Call( + self.ctx, + func, + q::JS_UNDEFINED, + argv.len() as c_int, + argv.as_mut_ptr(), + ); + self.state.end_call(); + for v in argv { + q::JS_FreeValue(self.ctx, v); + } + q::JS_FreeValue(self.ctx, func); + + if q::JS_IsException(ret) { + return Err(self.error_from_exception(false)); + } + if !q::JS_IsString(ret) { + q::JS_FreeValue(self.ctx, ret); + return Err(Error::ReturnedNonString( + name.to_string_lossy().into_owned(), + )); + } + let result = self.value_to_string(ret); + q::JS_FreeValue(self.ctx, ret); + result.ok_or_else(|| Error::Internal("failed to read result string".into())) + } + } + + /// Converts the pending exception into an [`Error`], preferring + /// [`Error::Timeout`] when the interrupt handler fired. + fn error_from_exception(&self, classify_syntax: bool) -> Error { + if self.state.interrupted.get() { + self.drain_exception(); + return Error::Timeout; + } + let (name, text) = self.take_exception(); + if classify_syntax && name.as_deref() == Some("SyntaxError") { + Error::ScriptSyntax(text) + } else { + Error::JsException(text) + } + } + + /// Takes the pending exception, returning its `name` property (if any) + /// and a human-readable message including the stack trace when available. + fn take_exception(&self) -> (Option, String) { + // SAFETY: the exception value and every property value are freed. + unsafe { + let exc = q::JS_GetException(self.ctx); + let name = self.get_string_property(exc, c"name"); + let mut text = self + .value_to_string(exc) + .unwrap_or_else(|| "unknown JavaScript exception".to_string()); + if let Some(stack) = self.get_string_property(exc, c"stack") { + let stack = stack.trim_end(); + if !stack.is_empty() { + text.push('\n'); + text.push_str(stack); + } + } + q::JS_FreeValue(self.ctx, exc); + (name, text) + } + } + + /// Reads a string-valued property of an object; `None` for anything else. + fn get_string_property(&self, obj: q::JSValue, name: &CStr) -> Option { + // SAFETY: `obj` is a live value owned by the caller. + unsafe { + if !q::JS_IsObject(obj) { + return None; + } + let value = q::JS_GetPropertyStr(self.ctx, obj, name.as_ptr()); + if q::JS_IsException(value) { + self.drain_exception(); + return None; + } + if !q::JS_IsString(value) { + q::JS_FreeValue(self.ctx, value); + return None; + } + let s = self.value_to_string(value); + q::JS_FreeValue(self.ctx, value); + s + } + } + + /// Converts a value to a Rust `String` via its JS string conversion. + /// Swallows any conversion exception and returns `None`. + fn value_to_string(&self, value: q::JSValue) -> Option { + // SAFETY: `value` is live; the C string is copied and freed. + unsafe { value_to_string_raw(self.ctx, value) } + } + + /// Clears any pending exception, discarding it. + fn drain_exception(&self) { + // SAFETY: the exception value is owned by us and freed. + unsafe { + let exc = q::JS_GetException(self.ctx); + q::JS_FreeValue(self.ctx, exc); + } + } +} + +impl Drop for Context { + fn drop(&mut self) { + // SAFETY: pointers were checked non-null at construction and are + // freed exactly once. `self.state` is dropped afterwards, so the + // opaque pointers never dangle while the runtime is alive. + unsafe { + q::JS_FreeContext(self.ctx); + q::JS_FreeRuntime(self.rt); + } + } +} + +/// # Safety +/// `ctx` must be a live context and `value` a live value belonging to it. +unsafe fn value_to_string_raw(ctx: *mut q::JSContext, value: q::JSValue) -> Option { + // SAFETY: per the function contract; the returned C string is freed. + unsafe { + let mut len: usize = 0; + let ptr = q::JS_ToCStringLen(ctx, &mut len, value); + if ptr.is_null() { + drain_exception_raw(ctx); + return None; + } + let bytes = std::slice::from_raw_parts(ptr.cast::(), len); + let s = String::from_utf8_lossy(bytes).into_owned(); + q::JS_FreeCString(ctx, ptr); + Some(s) + } +} + +/// # Safety +/// `ctx` must be a live context. +unsafe fn drain_exception_raw(ctx: *mut q::JSContext) { + // SAFETY: per the function contract. + unsafe { + let exc = q::JS_GetException(ctx); + q::JS_FreeValue(ctx, exc); + } +} + +/// # Safety +/// `ctx` must be a live context whose opaque was set to a `HostState` that is +/// still alive (both are guaranteed by `Context`). +unsafe fn host_state<'a>(ctx: *mut q::JSContext) -> &'a HostState { + // SAFETY: per the function contract. + unsafe { &*q::JS_GetContextOpaque(ctx).cast::() } +} + +/// # Safety +/// `argv` must point to `argc` live values (guaranteed by QuickJS). +unsafe fn arg_to_string( + ctx: *mut q::JSContext, + argc: c_int, + argv: *mut q::JSValue, + index: usize, +) -> Option { + if index >= argc.max(0) as usize { + return None; + } + // SAFETY: per the function contract; the value stays owned by QuickJS. + unsafe { value_to_string_raw(ctx, *argv.add(index)) } +} + +/// Builds a JS string, or the exception marker on allocation failure (which +/// then propagates naturally out of the native call). +/// +/// # Safety +/// `ctx` must be a live context. +unsafe fn new_js_string(ctx: *mut q::JSContext, s: &str) -> q::JSValue { + // SAFETY: pointer/length pair is valid for the duration of the call. + unsafe { q::JS_NewStringLen(ctx, s.as_ptr().cast::(), s.len() as _) } +} + +/// Interrupt handler: aborts execution once the armed deadline has passed. +unsafe extern "C" fn interrupt_handler(_rt: *mut q::JSRuntime, opaque: *mut c_void) -> c_int { + // SAFETY: `opaque` is the `HostState` registered at construction and + // outlives the runtime. + let state = unsafe { &*opaque.cast::() }; + if let Some(deadline) = state.deadline.get() { + if Instant::now() >= deadline { + state.interrupted.set(true); + return 1; + } + } + 0 +} + +/// `alert(...)` (also used for `console.log`): joins all arguments with +/// spaces and forwards them to the configured log sink. Never throws. +unsafe extern "C" fn tramp_alert( + ctx: *mut q::JSContext, + _this: q::JSValue, + argc: c_int, + argv: *mut q::JSValue, +) -> q::JSValue { + let mut parts: Vec = Vec::new(); + for i in 0..argc.max(0) as usize { + // SAFETY: QuickJS guarantees `argv` holds `argc` live values. + let part = unsafe { arg_to_string(ctx, argc, argv, i) }; + parts.push(part.unwrap_or_else(|| "".to_string())); + } + // SAFETY: the context opaque is a live `HostState`. + let state = unsafe { host_state(ctx) }; + state.log(&parts.join(" ")); + q::JS_UNDEFINED +} + +/// `dnsResolve(host)` -> first IPv4 address as a string, or `null`. +unsafe extern "C" fn tramp_dns_resolve( + ctx: *mut q::JSContext, + _this: q::JSValue, + argc: c_int, + argv: *mut q::JSValue, +) -> q::JSValue { + // SAFETY: see `tramp_alert`. + let (state, host) = unsafe { (host_state(ctx), arg_to_string(ctx, argc, argv, 0)) }; + match host.and_then(|h| state.dns_resolve(&h)) { + // SAFETY: `ctx` is live. + Some(ip) => unsafe { new_js_string(ctx, &ip) }, + None => q::JS_NULL, + } +} + +/// `myIpAddress()` -> IPv4 address string (override, best effort, or +/// `"127.0.0.1"`). +unsafe extern "C" fn tramp_my_ip_address( + ctx: *mut q::JSContext, + _this: q::JSValue, + _argc: c_int, + _argv: *mut q::JSValue, +) -> q::JSValue { + // SAFETY: see `tramp_alert`. + let state = unsafe { host_state(ctx) }; + let ip = state.my_ip_address(); + // SAFETY: `ctx` is live. + unsafe { new_js_string(ctx, &ip) } +} + +/// `dnsResolveEx(host)` -> `;`-separated address list, or `""`. +unsafe extern "C" fn tramp_dns_resolve_ex( + ctx: *mut q::JSContext, + _this: q::JSValue, + argc: c_int, + argv: *mut q::JSValue, +) -> q::JSValue { + // SAFETY: see `tramp_alert`. + let (state, host) = unsafe { (host_state(ctx), arg_to_string(ctx, argc, argv, 0)) }; + let list = host.map(|h| state.dns_resolve_ex(&h)).unwrap_or_default(); + // SAFETY: `ctx` is live. + unsafe { new_js_string(ctx, &list) } +} + +/// `myIpAddressEx()` -> `;`-separated local address list, or `""`. +unsafe extern "C" fn tramp_my_ip_address_ex( + ctx: *mut q::JSContext, + _this: q::JSValue, + _argc: c_int, + _argv: *mut q::JSValue, +) -> q::JSValue { + // SAFETY: see `tramp_alert`. + let state = unsafe { host_state(ctx) }; + let list = state.my_ip_address_ex(); + // SAFETY: `ctx` is live. + unsafe { new_js_string(ctx, &list) } +} diff --git a/src/pac/engine/mod.rs b/src/pac/engine/mod.rs new file mode 100644 index 0000000..dd9b85c --- /dev/null +++ b/src/pac/engine/mod.rs @@ -0,0 +1,214 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Evaluate PAC (Proxy Auto-Config) files with an embedded [QuickJS-NG] +//! JavaScript engine. +//! +//! A [`PacEngine`] owns one JavaScript runtime, pre-loaded with the standard +//! PAC helper functions (`isPlainHostName`, `dnsDomainIs`, `shExpMatch`, +//! `isInNet`, `dateRange`, ... — implemented from the public Netscape PAC +//! specification) and the Microsoft IPv6 extensions (`dnsResolveEx`, +//! `isInNetEx`, ...). +//! +//! ```ignore +//! let mut engine = PacEngine::new()?; +//! engine.load( +//! r#" +//! function FindProxyForURL(url, host) { +//! if (dnsDomainIs(host, ".example.com")) +//! return "DIRECT"; +//! return "PROXY proxy.example.com:8080; DIRECT"; +//! } +//! "#, +//! )?; +//! let proxy = engine.find_proxy("http://www.example.com/", "www.example.com")?; +//! assert_eq!(proxy, "DIRECT"); +//! ``` +//! +//! # Sandboxing and untrusted scripts +//! +//! PAC scripts are treated as untrusted input: +//! +//! * No filesystem, module loading, timer or network APIs are exposed to the +//! script. The only host access is through the PAC helpers (`dnsResolve*`, +//! `myIpAddress*`) and the log sink (`alert`, `console.log`). +//! * Every evaluation runs under a wall-clock deadline (default 10 seconds, +//! see [`PacEngine::set_timeout`]) enforced by a QuickJS interrupt +//! handler, so `while (true) {}` returns [`Error::Timeout`] instead of +//! hanging. Note that the interrupt handler cannot fire while a *native* +//! call is in progress, so a slow blocking DNS lookup inside `dnsResolve` +//! can still exceed the deadline. +//! * The runtime has a memory limit (default 64 MiB, see +//! [`PacEngine::set_memory_limit`]); scripts that exceed it fail with an +//! exception instead of exhausting the process. +//! +//! # Thread safety +//! +//! A [`PacEngine`] wraps a QuickJS context, which must stay on the thread it +//! was created on. `PacEngine` is therefore neither [`Send`] nor [`Sync`], +//! and no locking is added to pretend otherwise. To use PAC evaluation from +//! multiple threads, either create one engine per thread, or own the engine +//! on a dedicated thread and serialize calls to it through a channel. +//! +//! The helper functions in `pac_helpers.js` and `pac_helpers_ms.js` are +//! implemented solely from the public PAC specification and Microsoft's +//! published IPv6 extension documentation; no code from any other PAC +//! implementation was used or consulted. +//! +//! [QuickJS-NG]: https://github.com/quickjs-ng/quickjs + +#![allow(dead_code)] + +mod ffi; +mod state; + +use std::fmt; +use std::net::IpAddr; +use std::time::Duration; + +use self::state::HostState; + +/// Default wall-clock budget for a single script evaluation or PAC call. +pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Default QuickJS runtime memory limit in bytes (64 MiB). +pub const DEFAULT_MEMORY_LIMIT: usize = 64 * 1024 * 1024; + +const HELPERS_JS: &str = include_str!("pac_helpers.js"); +const HELPERS_MS_JS: &str = include_str!("pac_helpers_ms.js"); + +/// Errors returned by [`PacEngine`]. +#[derive(Debug)] +#[non_exhaustive] +pub enum Error { + /// The PAC script failed to parse. + ScriptSyntax(String), + /// The loaded script does not define the named entry point + /// (`FindProxyForURL` / `FindProxyForURLEx`). + FunctionMissing(String), + /// The script threw an exception (message and stack trace, if available). + JsException(String), + /// The named entry point returned a value that is not a string. + ReturnedNonString(String), + /// Evaluation exceeded the configured wall-clock limit and was + /// interrupted inside the engine. + Timeout, + /// An unexpected engine-level failure (allocation, embedding bug, ...). + Internal(String), +} + +impl fmt::Display for Error { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Error::ScriptSyntax(msg) => write!(f, "PAC script syntax error: {msg}"), + Error::FunctionMissing(name) => { + write!(f, "PAC script does not define function `{name}`") + } + Error::JsException(msg) => write!(f, "PAC script threw an exception: {msg}"), + Error::ReturnedNonString(name) => { + write!(f, "PAC function `{name}` returned a non-string value") + } + Error::Timeout => write!(f, "PAC evaluation exceeded the configured time limit"), + Error::Internal(msg) => write!(f, "internal PAC engine error: {msg}"), + } + } +} + +impl std::error::Error for Error {} + +/// A PAC evaluator: one QuickJS runtime/context with the PAC helper library +/// installed and (after [`load`](PacEngine::load)) a PAC script. +/// +/// Not `Send`/`Sync` — see the module-level documentation on thread safety. +pub struct PacEngine { + ctx: ffi::Context, +} + +impl fmt::Debug for PacEngine { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PacEngine").finish_non_exhaustive() + } +} + +impl PacEngine { + /// Creates an engine and installs the built-in PAC helper library. + pub fn new() -> Result { + let state = Box::new(HostState::new(DEFAULT_TIMEOUT)); + let ctx = ffi::Context::new(state, DEFAULT_MEMORY_LIMIT)?; + let install = |source, filename| { + ctx.eval(source, filename, false) + .map_err(|e| Error::Internal(format!("failed to install PAC helper library: {e}"))) + }; + install(HELPERS_JS, c"pac_helpers.js")?; + install(HELPERS_MS_JS, c"pac_helpers_ms.js")?; + Ok(PacEngine { ctx }) + } + + /// Evaluates a PAC script in the engine's global scope. + /// + /// Returns [`Error::ScriptSyntax`] when the script fails to parse, + /// [`Error::JsException`] when its top-level code throws, and + /// [`Error::Timeout`] when top-level execution exceeds the configured + /// limit. Loading another script re-uses the same global scope, so later + /// definitions override earlier ones. + pub fn load(&mut self, script: &str) -> Result<(), Error> { + self.ctx.eval(script, c"", true) + } + + /// Calls `FindProxyForURL(url, host)` and returns its result string + /// verbatim (e.g. `"PROXY proxy:8080; DIRECT"` — multi-directive results + /// are not reformatted). + pub fn find_proxy(&mut self, url: &str, host: &str) -> Result { + self.ctx.call_pac_function(c"FindProxyForURL", url, host) + } + + /// Calls the IPv6-aware entry point `FindProxyForURLEx(url, host)`, + /// falling back to `FindProxyForURL` when the script does not define it + /// (mirroring the behavior of IPv6-aware Windows PAC clients). + pub fn find_proxy_ex(&mut self, url: &str, host: &str) -> Result { + if self.ctx.has_global_function(c"FindProxyForURLEx") { + self.ctx.call_pac_function(c"FindProxyForURLEx", url, host) + } else { + self.ctx.call_pac_function(c"FindProxyForURL", url, host) + } + } + + /// One-shot convenience: creates an engine, loads `script` and evaluates + /// `FindProxyForURL(url, host)`. + pub fn eval_once(script: &str, url: &str, host: &str) -> Result { + let mut engine = Self::new()?; + engine.load(script)?; + engine.find_proxy(url, host) + } + + /// Overrides what `myIpAddress()` and `myIpAddressEx()` return. Pass + /// `None` to restore OS-based detection. Essential for deterministic + /// tests. + pub fn set_my_ip(&mut self, ip: Option) { + self.ctx.state().my_ip.set(ip); + } + + /// Sets the maximum wall-clock time for a single [`load`](Self::load) or + /// `find_proxy*` call. A runaway script is interrupted inside the engine + /// and the call returns [`Error::Timeout`]. + pub fn set_timeout(&mut self, timeout: Duration) { + self.ctx.state().timeout.set(timeout); + } + + /// Sets the QuickJS runtime memory limit in bytes. + pub fn set_memory_limit(&mut self, bytes: usize) { + self.ctx.set_memory_limit(bytes); + } + + /// Routes `alert()` / `console.log()` output to `sink` instead of the + /// default (stderr). Each call receives one message with all arguments + /// converted to strings and joined by spaces. + pub fn set_log_sink(&mut self, sink: F) + where + F: Fn(&str) + 'static, + { + *self.ctx.state().log_sink.borrow_mut() = Some(Box::new(sink)); + } +} diff --git a/src/pac/engine/pac_helpers.js b/src/pac/engine/pac_helpers.js new file mode 100644 index 0000000..53723cb --- /dev/null +++ b/src/pac/engine/pac_helpers.js @@ -0,0 +1,377 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Built-in PAC (Proxy Auto-Config) helper functions. + * + * These helpers are implemented from the public PAC specification only: + * the Netscape "Navigator Proxy Auto-Config File Format" document (1996). + * No code from any other PAC implementation was used or consulted. + * + * The native functions dnsResolve(), myIpAddress() and alert() are provided + * by the embedding Rust crate before this file is evaluated. + * + * SPDX-License-Identifier: MIT + */ +(function (global) { + "use strict"; + + function toStr(v) { + return typeof v === "string" ? v : String(v); + } + + /* Strict dotted-quad IPv4 parser; returns a number in [0, 2^32) or null. */ + function parseIPv4(s) { + var parts = s.split("."); + if (parts.length !== 4) { + return null; + } + var n = 0; + for (var i = 0; i < 4; i++) { + if (!/^\d{1,3}$/.test(parts[i])) { + return null; + } + var octet = parseInt(parts[i], 10); + if (octet > 255) { + return null; + } + n = n * 256 + octet; + } + return n; + } + + /* True when there is no domain part (no dots) in the host name. */ + global.isPlainHostName = function (host) { + return toStr(host).indexOf(".") === -1; + }; + + /* True when the host name ends in the given domain suffix. */ + global.dnsDomainIs = function (host, domain) { + host = toStr(host); + domain = toStr(domain); + return ( + domain.length <= host.length && + host.substring(host.length - domain.length) === domain + ); + }; + + /* + * True when the host matches exactly, or when the host is unqualified + * (no domain part) and matches the host part of the fully qualified name. + */ + global.localHostOrDomainIs = function (host, hostdom) { + host = toStr(host); + hostdom = toStr(hostdom); + if (host === hostdom) { + return true; + } + if (host.indexOf(".") !== -1) { + return false; + } + var dot = hostdom.indexOf("."); + return host === (dot === -1 ? hostdom : hostdom.substring(0, dot)); + }; + + /* True when the host name can be resolved to an IPv4 address. */ + global.isResolvable = function (host) { + return dnsResolve(toStr(host)) !== null; + }; + + /* + * True when the host (an IP address or a resolvable host name) belongs + * to the given IPv4 network, i.e. (host & mask) == (pattern & mask). + */ + global.isInNet = function (host, pattern, mask) { + var pat = parseIPv4(toStr(pattern)); + var msk = parseIPv4(toStr(mask)); + if (pat === null || msk === null) { + return false; + } + var hostStr = toStr(host); + var ip = parseIPv4(hostStr); + if (ip === null) { + var resolved = dnsResolve(hostStr); + if (resolved === null) { + return false; + } + ip = parseIPv4(resolved); + if (ip === null) { + return false; + } + } + return ((ip & msk) >>> 0) === ((pat & msk) >>> 0); + }; + + /* Number of DNS domain levels (number of dots) in the host name. */ + global.dnsDomainLevels = function (host) { + var m = toStr(host).match(/\./g); + return m === null ? 0 : m.length; + }; + + /* + * Shell-glob match anchored to the whole string. "*" matches any + * sequence, "?" matches any single character, and "[...]" is a shell + * character class (with "[!...]" / "[^...]" negation and ranges such as + * "[0-9]"); every other character, including regular-expression + * metacharacters such as ".", is literal. + */ + global.shExpMatch = function (str, shexp) { + str = toStr(str); + shexp = toStr(shexp); + var re = ""; + var i = 0; + var n = shexp.length; + while (i < n) { + var c = shexp.charAt(i); + if (c === "*") { + re += ".*"; + i++; + } else if (c === "?") { + re += "."; + i++; + } else if (c === "[") { + var j = i + 1; + var negate = false; + if (j < n && (shexp.charAt(j) === "!" || shexp.charAt(j) === "^")) { + negate = true; + j++; + } + var start = j; + /* A "]" right after "[" (or "[!") is a literal member. */ + if (j < n && shexp.charAt(j) === "]") { + j++; + } + while (j < n && shexp.charAt(j) !== "]") { + j++; + } + if (j >= n) { + /* No closing bracket: treat "[" as a literal character. */ + re += "\\["; + i++; + } else { + /* Escape "\" and "]" so the class body is safe in a JS + regex; ranges and other members pass through. */ + var body = shexp + .substring(start, j) + .replace(/[\\\]]/g, "\\$&"); + re += "[" + (negate ? "^" : "") + body + "]"; + i = j + 1; + } + } else if (/[.+^${}()|\\]/.test(c)) { + re += "\\" + c; + i++; + } else { + re += c; + i++; + } + } + try { + return new RegExp("^" + re + "$").test(str); + } catch (e) { + return false; + } + }; + + var WEEKDAYS = { SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6 }; + var MONTHS = { + JAN: 0, FEB: 1, MAR: 2, APR: 3, MAY: 4, JUN: 5, + JUL: 6, AUG: 7, SEP: 8, OCT: 9, NOV: 10, DEC: 11 + }; + + /* Removes a trailing "GMT" argument; returns whether it was present. */ + function stripGmt(args) { + if (args.length > 0 && args[args.length - 1] === "GMT") { + args.pop(); + return true; + } + return false; + } + + /* Inclusive range test that wraps around (e.g. FRI..MON). */ + function inWrappedRange(value, lo, hi) { + return lo <= hi ? value >= lo && value <= hi : value >= lo || value <= hi; + } + + /* + * weekdayRange(wd1 [, wd2] [, "GMT"]): true when the current weekday is + * wd1, or falls in the inclusive range wd1..wd2 (wrapping past SAT). + */ + global.weekdayRange = function () { + var args = Array.prototype.slice.call(arguments); + var gmt = stripGmt(args); + if (args.length < 1 || args.length > 2) { + return false; + } + var now = new Date(); + var weekday = gmt ? now.getUTCDay() : now.getDay(); + var wd1 = WEEKDAYS[toStr(args[0]).toUpperCase()]; + if (wd1 === undefined) { + return false; + } + if (args.length === 1) { + return weekday === wd1; + } + var wd2 = WEEKDAYS[toStr(args[1]).toUpperCase()]; + if (wd2 === undefined) { + return false; + } + return inWrappedRange(weekday, wd1, wd2); + }; + + /* + * Classifies a dateRange() argument as a day of month (1-31), a month + * name (JAN..DEC) or a four-digit year. Returns null for anything else. + */ + function dateArg(a) { + if (typeof a === "string") { + var month = MONTHS[a.toUpperCase()]; + if (month !== undefined) { + return { kind: "m", value: month }; + } + if (!/^\d+$/.test(a)) { + return null; + } + a = parseInt(a, 10); + } + if (typeof a !== "number" || !isFinite(a) || Math.floor(a) !== a) { + return null; + } + if (a >= 1 && a <= 31) { + return { kind: "d", value: a }; + } + if (a >= 1000 && a <= 9999) { + return { kind: "y", value: a }; + } + return null; + } + + /* + * dateRange(...) with the full argument-count overloading of the spec: + * day | month | year + * day1, day2 | month1, month2 | year1, year2 + * day1, month1, day2, month2 | month1, year1, month2, year2 + * day1, month1, year1, day2, month2, year2 + * each optionally followed by "GMT". Day and month ranges wrap; ranges + * that include a year are absolute. All bounds are inclusive. + */ + global.dateRange = function () { + var args = Array.prototype.slice.call(arguments); + var gmt = stripGmt(args); + var parsed = []; + for (var i = 0; i < args.length; i++) { + var p = dateArg(args[i]); + if (p === null) { + return false; + } + parsed.push(p); + } + var kinds = parsed + .map(function (x) { return x.kind; }) + .join(""); + var v = parsed.map(function (x) { return x.value; }); + var now = new Date(); + var year = gmt ? now.getUTCFullYear() : now.getFullYear(); + var month = gmt ? now.getUTCMonth() : now.getMonth(); + var day = gmt ? now.getUTCDate() : now.getDate(); + switch (kinds) { + case "d": + return day === v[0]; + case "m": + return month === v[0]; + case "y": + return year === v[0]; + case "dd": + return inWrappedRange(day, v[0], v[1]); + case "mm": + return inWrappedRange(month, v[0], v[1]); + case "yy": + return year >= v[0] && year <= v[1]; + case "dmdm": + return inWrappedRange( + month * 32 + day, + v[1] * 32 + v[0], + v[3] * 32 + v[2] + ); + case "mymy": + var cur = year * 12 + month; + return cur >= v[1] * 12 + v[0] && cur <= v[3] * 12 + v[2]; + case "dmydmy": + var today = (year * 12 + month) * 32 + day; + var lo = (v[2] * 12 + v[1]) * 32 + v[0]; + var hi = (v[5] * 12 + v[4]) * 32 + v[3]; + return today >= lo && today <= hi; + default: + return false; + } + }; + + /* + * timeRange(...) with the argument-count overloading of the spec: + * hour -- true during that hour + * hour1, hour2 -- [hour1:00:00, hour2:00:00) + * hour1, min1, hour2, min2 -- [h1:m1:00, h2:m2:00) + * h1, m1, s1, h2, m2, s2 -- [h1:m1:s1, h2:m2:s2) + * each optionally followed by "GMT". Ranges wrap past midnight when the + * start is later than the end. + */ + global.timeRange = function () { + var args = Array.prototype.slice.call(arguments); + var gmt = stripGmt(args); + var nums = []; + for (var i = 0; i < args.length; i++) { + var n = typeof args[i] === "number" ? args[i] : Number(args[i]); + if (!isFinite(n) || Math.floor(n) !== n || n < 0) { + return false; + } + nums.push(n); + } + var valid = function (hour, min, sec) { + return hour <= 24 && min <= 59 && sec <= 59; + }; + var now = new Date(); + var hour = gmt ? now.getUTCHours() : now.getHours(); + var min = gmt ? now.getUTCMinutes() : now.getMinutes(); + var sec = gmt ? now.getUTCSeconds() : now.getSeconds(); + var cur = hour * 3600 + min * 60 + sec; + var lo, hi; + switch (nums.length) { + case 1: + return valid(nums[0], 0, 0) && hour === nums[0]; + case 2: + if (!valid(nums[0], 0, 0) || !valid(nums[1], 0, 0)) { + return false; + } + lo = nums[0] * 3600; + hi = nums[1] * 3600; + break; + case 4: + if (!valid(nums[0], nums[1], 0) || !valid(nums[2], nums[3], 0)) { + return false; + } + lo = nums[0] * 3600 + nums[1] * 60; + hi = nums[2] * 3600 + nums[3] * 60; + break; + case 6: + if ( + !valid(nums[0], nums[1], nums[2]) || + !valid(nums[3], nums[4], nums[5]) + ) { + return false; + } + lo = nums[0] * 3600 + nums[1] * 60 + nums[2]; + hi = nums[3] * 3600 + nums[4] * 60 + nums[5]; + break; + default: + return false; + } + if (lo === hi) { + return cur === lo; + } + return lo < hi ? cur >= lo && cur < hi : cur >= lo || cur < hi; + }; + + /* console.log routes to the same sink as the native alert(). */ + global.console = { log: global.alert }; +})(globalThis); diff --git a/src/pac/engine/pac_helpers_ms.js b/src/pac/engine/pac_helpers_ms.js new file mode 100644 index 0000000..15f48e4 --- /dev/null +++ b/src/pac/engine/pac_helpers_ms.js @@ -0,0 +1,232 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Microsoft IPv6 PAC extension helper functions. + * + * Implemented from Microsoft's published documentation of the IPv6-aware + * proxy-auto-configuration extensions (the "*Ex" helper family) only. + * No code from any other PAC implementation was used or consulted. + * + * The native functions dnsResolveEx() and myIpAddressEx() are provided by + * the embedding Rust crate before this file is evaluated. + * + * SPDX-License-Identifier: MIT + */ +(function (global) { + "use strict"; + + function toStr(v) { + return typeof v === "string" ? v : String(v); + } + + /* Strict dotted-quad IPv4 parser; returns [a, b, c, d] or null. */ + function parseIPv4Bytes(s) { + var parts = s.split("."); + if (parts.length !== 4) { + return null; + } + var bytes = []; + for (var i = 0; i < 4; i++) { + if (!/^\d{1,3}$/.test(parts[i])) { + return null; + } + var octet = parseInt(parts[i], 10); + if (octet > 255) { + return null; + } + bytes.push(octet); + } + return bytes; + } + + /* + * RFC 4291 textual IPv6 parser supporting "::" compression and an + * embedded dotted-quad IPv4 tail; returns 16 bytes or null. A zone + * suffix ("%eth0") is ignored. + */ + function parseIPv6Bytes(s) { + if (s.indexOf(":") === -1) { + return null; + } + var zone = s.indexOf("%"); + if (zone !== -1) { + s = s.substring(0, zone); + } + var dbl = s.indexOf("::"); + if (dbl !== -1 && s.indexOf("::", dbl + 1) !== -1) { + return null; + } + + /* Parses a colon-separated run into 16-bit groups. */ + function groupsOf(part, v4Allowed) { + if (part === "") { + return []; + } + var items = part.split(":"); + var groups = []; + for (var i = 0; i < items.length; i++) { + var g = items[i]; + if (g.indexOf(".") !== -1) { + if (!v4Allowed || i !== items.length - 1) { + return null; + } + var v4 = parseIPv4Bytes(g); + if (v4 === null) { + return null; + } + groups.push(v4[0] * 256 + v4[1], v4[2] * 256 + v4[3]); + } else { + if (!/^[0-9A-Fa-f]{1,4}$/.test(g)) { + return null; + } + groups.push(parseInt(g, 16)); + } + } + return groups; + } + + var groups; + if (dbl !== -1) { + var head = groupsOf(s.substring(0, dbl), false); + var tail = groupsOf(s.substring(dbl + 2), true); + if (head === null || tail === null || head.length + tail.length > 7) { + return null; + } + var zeros = new Array(8 - head.length - tail.length).fill(0); + groups = head.concat(zeros, tail); + } else { + groups = groupsOf(s, true); + if (groups === null || groups.length !== 8) { + return null; + } + } + var bytes = []; + for (var i = 0; i < 8; i++) { + bytes.push(groups[i] >> 8, groups[i] & 0xff); + } + return bytes; + } + + /* Parses either address family: { family: 4|6, bytes: [...] } or null. */ + function parseAddress(s) { + var v4 = parseIPv4Bytes(s); + if (v4 !== null) { + return { family: 4, bytes: v4 }; + } + var v6 = parseIPv6Bytes(s); + if (v6 !== null) { + return { family: 6, bytes: v6 }; + } + return null; + } + + /* True when the first `bits` bits of the two byte arrays are equal. */ + function prefixMatch(a, b, bits) { + var fullBytes = bits >> 3; + for (var i = 0; i < fullBytes; i++) { + if (a[i] !== b[i]) { + return false; + } + } + var remainder = bits & 7; + if (remainder === 0) { + return true; + } + var mask = (0xff00 >> remainder) & 0xff; + return (a[fullBytes] & mask) === (b[fullBytes] & mask); + } + + /* + * isInNetEx(ipAddress, ipPrefix): true when the IPv4 or IPv6 address + * falls inside the CIDR prefix ("198.51.100.0/24", "2001:db8::/32"). + * A `;`-separated list of prefixes is accepted; any match wins. A + * prefix without "/length" is compared in full. + */ + global.isInNetEx = function (ipAddress, ipPrefix) { + var addr = parseAddress(toStr(ipAddress)); + if (addr === null) { + return false; + } + var ranges = toStr(ipPrefix).split(";"); + for (var i = 0; i < ranges.length; i++) { + var range = ranges[i].trim(); + if (range === "") { + continue; + } + var slash = range.indexOf("/"); + var net = parseAddress( + slash === -1 ? range : range.substring(0, slash) + ); + if (net === null || net.family !== addr.family) { + continue; + } + var maxBits = net.family === 4 ? 32 : 128; + var bits = maxBits; + if (slash !== -1) { + var lenStr = range.substring(slash + 1); + if (!/^\d+$/.test(lenStr)) { + continue; + } + bits = parseInt(lenStr, 10); + if (bits > maxBits) { + continue; + } + } + if (prefixMatch(addr.bytes, net.bytes, bits)) { + return true; + } + } + return false; + }; + + /* True when the host resolves to at least one IPv4 or IPv6 address. */ + global.isResolvableEx = function (host) { + return dnsResolveEx(toStr(host)) !== ""; + }; + + /* + * sortIpAddressList(list): sorts a `;`-separated address list in + * ascending order, IPv6 addresses before IPv4 addresses. Returns false + * when the list is empty or contains an unparsable address. + */ + global.sortIpAddressList = function (list) { + var items = toStr(list).split(";"); + var parsed = []; + for (var i = 0; i < items.length; i++) { + var item = items[i].trim(); + if (item === "") { + return false; + } + var addr = parseAddress(item); + if (addr === null) { + return false; + } + parsed.push({ text: item, addr: addr }); + } + if (parsed.length === 0) { + return false; + } + parsed.sort(function (x, y) { + if (x.addr.family !== y.addr.family) { + return x.addr.family === 6 ? -1 : 1; + } + for (var i = 0; i < x.addr.bytes.length; i++) { + if (x.addr.bytes[i] !== y.addr.bytes[i]) { + return x.addr.bytes[i] - y.addr.bytes[i]; + } + } + return 0; + }); + return parsed + .map(function (x) { return x.text; }) + .join(";"); + }; + + /* Version of the PAC extension interface implemented here. */ + global.getClientVersion = function () { + return "1.0"; + }; +})(globalThis); diff --git a/src/pac/engine/state.rs b/src/pac/engine/state.rs new file mode 100644 index 0000000..6cd7aa0 --- /dev/null +++ b/src/pac/engine/state.rs @@ -0,0 +1,167 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +//! Host-side state shared with the native functions exposed to PAC scripts. +//! +//! A pointer to [`HostState`] is stored as the QuickJS context/interrupt +//! opaque, so native callbacks can reach configuration (logging sink, +//! `myIpAddress()` override) and the evaluation deadline. All fields use +//! interior mutability because callbacks only ever see a shared reference. + +use std::cell::{Cell, RefCell}; +use std::net::{IpAddr, ToSocketAddrs, UdpSocket}; +use std::time::{Duration, Instant}; + +/// Callback receiving `alert()` / `console.log()` output. +pub(crate) type LogSink = Box; + +pub(crate) struct HostState { + /// Override for `myIpAddress()` / `myIpAddressEx()`. + pub(crate) my_ip: Cell>, + /// Wall-clock budget for a single script evaluation or PAC call. + pub(crate) timeout: Cell, + /// Deadline armed while JavaScript is executing; read by the QuickJS + /// interrupt handler. + pub(crate) deadline: Cell>, + /// Set by the interrupt handler when it aborts execution, so an engine + /// exception can be told apart from a script exception. + pub(crate) interrupted: Cell, + /// Destination for `alert()` / `console.log()`; `None` means stderr. + pub(crate) log_sink: RefCell>, +} + +impl HostState { + pub(crate) fn new(default_timeout: Duration) -> Self { + HostState { + my_ip: Cell::new(None), + timeout: Cell::new(default_timeout), + deadline: Cell::new(None), + interrupted: Cell::new(false), + log_sink: RefCell::new(None), + } + } + + /// Arms the deadline before handing control to QuickJS. + pub(crate) fn begin_call(&self) { + self.interrupted.set(false); + self.deadline.set(Some(Instant::now() + self.timeout.get())); + } + + /// Disarms the deadline once QuickJS has returned. + pub(crate) fn end_call(&self) { + self.deadline.set(None); + } + + pub(crate) fn log(&self, message: &str) { + match &*self.log_sink.borrow() { + Some(sink) => sink(message), + None => eprintln!("{message}"), + } + } + + /// `dnsResolve(host)`: first IPv4 address, if any. + pub(crate) fn dns_resolve(&self, host: &str) -> Option { + lookup(host) + .into_iter() + .find(|ip| ip.is_ipv4()) + .map(|ip| ip.to_string()) + } + + /// `dnsResolveEx(host)`: all addresses (IPv4 and IPv6), `;`-separated. + pub(crate) fn dns_resolve_ex(&self, host: &str) -> String { + let mut out: Vec = Vec::new(); + for ip in lookup(host) { + let s = ip.to_string(); + if !out.contains(&s) { + out.push(s); + } + } + out.join(";") + } + + /// `myIpAddress()`: the configured override, a best-effort primary IPv4 + /// address, or `"127.0.0.1"`. + pub(crate) fn my_ip_address(&self) -> String { + if let Some(ip) = self.my_ip.get() { + return ip.to_string(); + } + primary_local_ip(false) + .map(|ip| ip.to_string()) + .unwrap_or_else(|| "127.0.0.1".to_string()) + } + + /// `myIpAddressEx()`: the configured override, or a best-effort + /// `;`-separated list of local IPv4/IPv6 addresses (may be empty). + pub(crate) fn my_ip_address_ex(&self) -> String { + if let Some(ip) = self.my_ip.get() { + return ip.to_string(); + } + let mut out: Vec = Vec::new(); + for v6 in [false, true] { + if let Some(ip) = primary_local_ip(v6) { + let s = ip.to_string(); + if !out.contains(&s) { + out.push(s); + } + } + } + out.join(";") + } +} + +/// Resolves a host name (or parses an IP literal) into its addresses. +/// Returns an empty list on any failure; PAC evaluation must never error out +/// because of DNS. +fn lookup(host: &str) -> Vec { + if host.is_empty() { + return Vec::new(); + } + match (host, 0u16).to_socket_addrs() { + Ok(addrs) => addrs.map(|sa| sa.ip()).collect(), + Err(_) => Vec::new(), + } +} + +/// Determines the local address the OS would use for outbound traffic by +/// `connect()`ing a UDP socket to a well-known address. No packets are sent. +fn primary_local_ip(v6: bool) -> Option { + let (bind, probe) = if v6 { + ("[::]:0", "[2001:4860:4860::8888]:53") + } else { + ("0.0.0.0:0", "8.8.8.8:53") + }; + let socket = UdpSocket::bind(bind).ok()?; + socket.connect(probe).ok()?; + let ip = socket.local_addr().ok()?.ip(); + if ip.is_unspecified() { + None + } else { + Some(ip) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lookup_parses_ip_literals_without_dns() { + assert_eq!( + lookup("127.0.0.1"), + vec!["127.0.0.1".parse::().expect("literal")] + ); + assert_eq!(lookup(""), Vec::::new()); + } + + #[test] + fn my_ip_override_wins() { + let state = HostState::new(Duration::from_secs(1)); + state + .my_ip + .set(Some("203.0.113.7".parse().expect("literal"))); + assert_eq!(state.my_ip_address(), "203.0.113.7"); + assert_eq!(state.my_ip_address_ex(), "203.0.113.7"); + } +} diff --git a/src/pac/ffi.rs b/src/pac/ffi.rs deleted file mode 100644 index a7d3d1e..0000000 --- a/src/pac/ffi.rs +++ /dev/null @@ -1,55 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -//! Hand-written bindings to pacparser's small C API (vendored, QuickJS-based) -//! plus the error-capture shim in `shim.c`. -//! -//! # Safety contract -//! pacparser has a single global, non-thread-safe context. Every function -//! here (except the version getter) must only ever be called from the one -//! worker thread owned by [`super::PacEvaluator`]. - -#![allow(dead_code)] - -use std::os::raw::{c_char, c_int}; - -extern "C" { - /// Returns 1 on success, 0 on failure. - pub fn pacparser_init() -> c_int; - /// Returns 1 on success, 0 on failure. - pub fn pacparser_parse_pac_string(pacstring: *const c_char) -> c_int; - /// Returns 1 on success, 0 on failure. - pub fn pacparser_parse_pac_file(pacfile: *const c_char) -> c_int; - /// Returns a library-owned string, valid until the next call — copy - /// immediately, never free. NULL on error. - pub fn pacparser_find_proxy(url: *const c_char, host: *const c_char) -> *mut c_char; - pub fn pacparser_cleanup(); - /// Overrides what `myIpAddress()` returns. 1 on success. - pub fn pacparser_setmyip(ip: *const c_char) -> c_int; - /// Enables `FindProxyForURLEx` / `dnsResolveEx` etc. Call before init. - pub fn pacparser_enable_microsoft_extensions(); - pub fn pacparser_version() -> *mut c_char; - - // shim.c - pub fn ospr_install_error_printer(); - pub fn ospr_get_error() -> *const c_char; - pub fn ospr_clear_error(); -} - -/// Read and clear the error buffer accumulated by the shim printer. -/// Only call from the worker thread. -pub(super) unsafe fn take_error() -> String { - let ptr = ospr_get_error(); - let msg = if ptr.is_null() { - String::new() - } else { - std::ffi::CStr::from_ptr(ptr) - .to_string_lossy() - .trim() - .to_string() - }; - ospr_clear_error(); - msg -} diff --git a/src/pac/mod.rs b/src/pac/mod.rs index 3d01dfb..89bec47 100644 --- a/src/pac/mod.rs +++ b/src/pac/mod.rs @@ -6,34 +6,38 @@ //! The caged PAC evaluator (macOS/Linux only — Windows delegates PAC to //! WinHTTP). //! -//! pacparser has a single global, non-thread-safe context, and the PAC -//! builtins `dnsResolve()` / `myIpAddress()` do synchronous network I/O. Both -//! problems are contained the same way: one dedicated worker thread owns the -//! context, all calls are serialized through a command channel, and every +//! The embedded QuickJS engine ([`engine::PacEngine`]) wraps a QuickJS +//! context that is neither `Send` nor `Sync`, and the PAC builtins +//! `dnsResolve()` / `myIpAddress()` do synchronous network I/O. Both problems +//! are contained the same way: one dedicated worker thread owns the engine, +//! all calls are serialized through a command channel, and every //! `FindProxyForURL` call gets a hard timeout on the caller side. //! -//! A PAC script is untrusted JS on a live engine. If a hostile script loops -//! forever, the worker thread is wedged — it cannot be killed safely (the -//! global C context would be corrupted). Instead, callers fail fast -//! ([`Error::PacTimeout`]) while a request is outstanding past its deadline, -//! and service resumes automatically if the worker ever completes. The +//! A PAC script is untrusted JS on a live engine. A runaway JavaScript loop +//! is interrupted inside the engine by its own deadline (see +//! [`engine::PacEngine::set_timeout`]), so the worker recovers on its own. +//! But a native builtin — most importantly a blocking DNS lookup — cannot be +//! interrupted, so it can still exceed the caller's deadline. In that case +//! callers fail fast ([`Error::PacTimeout`]) while a request is outstanding, +//! and service resumes automatically once the worker completes. The //! command/reply protocol here is deliberately process-agnostic so the worker //! can be moved out-of-process later (Chromium-style: a subprocess you can -//! resource-limit and kill), which is the real fix. +//! resource-limit and kill). -mod ffi; +mod engine; use crate::types::{parse_pac_result, sanitize_url_for_pac, Error, ProxyKind, Result}; -use std::ffi::{CStr, CString}; +use std::net::IpAddr; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::mpsc; use std::sync::Arc; use std::time::Duration; use url::Url; -/// Handle to the process-global PAC worker. pacparser's context is a single -/// global — one worker thread per *process*, shared by all resolvers, created -/// lazily and never torn down. Only the timeout is per-handle. +/// Handle to the process-global PAC worker. The QuickJS engine lives on a +/// single dedicated worker thread — one per *process*, shared by all +/// resolvers, created lazily and never torn down. Only the timeout is +/// per-handle. pub(crate) struct PacEvaluator { timeout: Duration, } @@ -140,24 +144,22 @@ impl PacEvaluator { } // --------------------------------------------------------------------------- -// Worker thread: the only code allowed to touch pacparser. +// Worker thread: the only code allowed to touch the QuickJS engine. struct WorkerState { - /// Script currently loaded into the global context, with the parse - /// outcome cached so a broken script doesn't get re-parsed per request. + /// The engine with the current script loaded. `None` until the first + /// successful load, and cleared whenever a (re)load fails. + engine: Option, + /// Script currently loaded, with the load outcome cached so a broken + /// script doesn't get re-parsed per request. current: Option<(Arc, std::result::Result<(), String>)>, - initialized: bool, my_ip: Option, } fn worker_loop(rx: mpsc::Receiver, shared: Arc) { - unsafe { - ffi::ospr_install_error_printer(); - ffi::pacparser_enable_microsoft_extensions(); - } let mut state = WorkerState { + engine: None, current: None, - initialized: false, my_ip: None, }; while let Ok(request) = rx.recv() { @@ -166,71 +168,56 @@ fn worker_loop(rx: mpsc::Receiver, shared: Arc) { // Receiver may have timed out and gone away; that's fine. let _ = request.reply.send(result); } - if state.initialized { - unsafe { ffi::pacparser_cleanup() }; - } } fn eval_one(state: &mut WorkerState, request: &EvalRequest) -> Result { - let needs_parse = match &state.current { + let needs_reload = match &state.current { Some((script, _)) => !same_script(script, &request.script), None => true, }; - if needs_parse { - let outcome = parse_script(state, &request.script); + if needs_reload { + let outcome = load_script(state, &request.script); state.current = Some((request.script.clone(), outcome)); } if let Some((_, Err(msg))) = &state.current { return Err(Error::PacEval(msg.clone())); } + let engine = state + .engine + .as_mut() + .expect("engine is present after a successful load"); + + // A fresh engine starts with OS-based `myIpAddress`; only touch the + // override when the requested value actually changes. if request.my_ip != state.my_ip { - if let Some(ip) = &request.my_ip { - if let Ok(c_ip) = CString::new(ip.as_str()) { - unsafe { ffi::pacparser_setmyip(c_ip.as_ptr()) }; - } - } + let ip = request + .my_ip + .as_deref() + .and_then(|s| s.parse::().ok()); + engine.set_my_ip(ip); state.my_ip = request.my_ip.clone(); } - let c_url = - CString::new(request.url.as_str()).map_err(|_| Error::InvalidUrl(request.url.clone()))?; - let c_host = - CString::new(request.host.as_str()).map_err(|_| Error::InvalidUrl(request.host.clone()))?; - unsafe { - ffi::ospr_clear_error(); - let ptr = ffi::pacparser_find_proxy(c_url.as_ptr(), c_host.as_ptr()); - if ptr.is_null() { - let msg = ffi::take_error(); - return Err(Error::PacEval(if msg.is_empty() { - "FindProxyForURL returned no result".into() - } else { - msg - })); - } - // Library-owned; copy before the next pacparser call frees it. - Ok(CStr::from_ptr(ptr).to_string_lossy().into_owned()) + match engine.find_proxy_ex(&request.url, &request.host) { + Ok(result) => Ok(result), + // A runaway JS loop is interrupted by the engine's own backstop + // deadline; surface it the same way as a caller-side timeout. + Err(engine::Error::Timeout) => Err(Error::PacTimeout), + Err(e) => Err(Error::PacEval(e.to_string())), } } -fn parse_script(state: &mut WorkerState, script: &str) -> std::result::Result<(), String> { - unsafe { - if state.initialized { - ffi::pacparser_cleanup(); - state.initialized = false; - state.my_ip = None; - } - ffi::ospr_clear_error(); - if ffi::pacparser_init() != 1 { - return Err(format!("pacparser_init failed: {}", ffi::take_error())); - } - state.initialized = true; - let c_script = CString::new(script).map_err(|_| "PAC script contains NUL".to_string())?; - if ffi::pacparser_parse_pac_string(c_script.as_ptr()) != 1 { - return Err(format!("PAC parse failed: {}", ffi::take_error())); - } - Ok(()) - } +/// Builds a fresh engine for `script` (discarding any previous one, so a new +/// script never inherits stale globals) and loads it. +fn load_script(state: &mut WorkerState, script: &str) -> std::result::Result<(), String> { + // Drop the old engine first so only one runtime exists at a time. + state.engine = None; + state.my_ip = None; + let mut engine = engine::PacEngine::new().map_err(|e| e.to_string())?; + engine.load(script).map_err(|e| e.to_string())?; + state.engine = Some(engine); + Ok(()) } fn same_script(a: &Arc, b: &Arc) -> bool { diff --git a/src/pac/shim.c b/src/pac/shim.c deleted file mode 100644 index e28deac..0000000 --- a/src/pac/shim.c +++ /dev/null @@ -1,42 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See LICENSE.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/* - * Error-capture shim around pacparser's error printer. pacparser reports - * errors through a printf-style callback taking a va_list, which Rust cannot - * receive on stable — so the callback lives here and Rust reads the buffer. - * - * A static buffer is safe because the crate serializes every pacparser call - * on one dedicated worker thread. - */ -#include -#include - -typedef int (*pacparser_error_printer)(const char *fmt, va_list argp); -extern void pacparser_set_error_printer(pacparser_error_printer func); - -static char ospr_err_buf[4096]; -static size_t ospr_err_len = 0; - -static int ospr_buf_printer(const char *fmt, va_list argp) { - size_t remaining = sizeof(ospr_err_buf) - ospr_err_len; - if (remaining > 1) { - int n = vsnprintf(ospr_err_buf + ospr_err_len, remaining, fmt, argp); - if (n > 0) - ospr_err_len += ((size_t)n < remaining ? (size_t)n : remaining - 1); - } - return 0; -} - -void ospr_install_error_printer(void) { - pacparser_set_error_printer(ospr_buf_printer); -} - -const char *ospr_get_error(void) { return ospr_err_buf; } - -void ospr_clear_error(void) { - ospr_err_len = 0; - ospr_err_buf[0] = '\0'; -} diff --git a/src/resolver.rs b/src/resolver.rs index 58dddf4..3ded5d2 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -449,7 +449,7 @@ impl ProxyResolver { self.eval_for_resolution(&script, url) } - /// Best-effort local IP for PAC `myIpAddress()`, so pacparser doesn't + /// Best-effort local IP for PAC `myIpAddress()`, so the engine doesn't /// fall back to resolving the hostname (slow, often wrong on multi-homed /// machines). A connected UDP socket never sends a packet. #[cfg(not(windows))] @@ -604,8 +604,8 @@ mod tests { } // Serves a PAC over http and evaluates it via the public API. Exercises the - // real engine on every platform (pacparser off Windows, WinHTTP on it), - // which is also the path the `pactester` example drives. + // real engine on every platform (the built-in QuickJS engine off Windows, + // WinHTTP on it), which is also the path the `pactester` example drives. #[test] fn evaluate_pac_source_over_http() { use std::io::{Read, Write}; @@ -639,8 +639,8 @@ mod tests { .evaluate_pac_source(&pac_url, &url("https://x.com/")) .unwrap(); - // WinHTTP drops a trailing DIRECT; pacparser keeps it. Both agree on - // the primary proxy. + // WinHTTP drops a trailing DIRECT; the built-in engine keeps it. Both + // agree on the primary proxy. assert_eq!(got.first(), Some(&ProxyKind::Http("p:1".into()))); } } diff --git a/tests/data/ms_extensions.pac b/tests/data/ms_extensions.pac index 337b17c..df3413f 100644 --- a/tests/data/ms_extensions.pac +++ b/tests/data/ms_extensions.pac @@ -1,5 +1,5 @@ // Microsoft PAC extensions are enabled in the evaluator (as in WinHTTP and -// Chromium): when FindProxyForURLEx is defined, pacparser prefers it over +// Chromium): when FindProxyForURLEx is defined, the engine prefers it over // FindProxyForURL. This file pins that precedence. function FindProxyForURL(url, host) { return "PROXY plain.example.com:3128"; diff --git a/tests/pac_corpus.rs b/tests/pac_corpus.rs index a0fc045..39ec9d5 100644 --- a/tests/pac_corpus.rs +++ b/tests/pac_corpus.rs @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ //! pactester-style corpus: (pac file, url) -> expected proxy list. Catches -//! drift in the vendored pacparser/QuickJS builtins cheaply. +//! drift in the built-in QuickJS PAC engine builtins cheaply. #![cfg(not(windows))] diff --git a/vendor/pacparser b/vendor/pacparser deleted file mode 160000 index e4f6057..0000000 --- a/vendor/pacparser +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e4f60573e6ee89c3f04f552769beb6fce16137b0 From 09646d6f33227be02a3473f71b143551fcd9cd9d Mon Sep 17 00:00:00 2001 From: Christof Marti Date: Wed, 8 Jul 2026 09:55:10 +0200 Subject: [PATCH 2/2] Rename pactester to proxytester --- .github/workflows/ci.yml | 18 +++++++++--------- README.md | 2 +- examples/{pactester.rs => proxytester.rs} | 6 +++--- src/resolver.rs | 6 +++--- tests/pac_corpus.rs | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) rename examples/{pactester.rs => proxytester.rs} (96%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30ca93b..d084554 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,28 +82,28 @@ jobs: run: ${{ matrix.use_cross && 'cross' || 'cargo' }} test --target ${{ matrix.target }} --all-features shell: bash - - name: Build pactester (release) - run: ${{ matrix.use_cross && 'cross' || 'cargo' }} build --release --target ${{ matrix.target }} --example pactester + - name: Build proxytester (release) + run: ${{ matrix.use_cross && 'cross' || 'cargo' }} build --release --target ${{ matrix.target }} --example proxytester shell: bash - - name: Stage pactester artifact + - name: Stage proxytester artifact run: | ext="" case "${{ matrix.target }}" in *windows*) ext=".exe" ;; esac - stage="dist/pactester-${{ matrix.target }}" + stage="dist/proxytester-${{ matrix.target }}" mkdir -p "$stage" - # QuickJS is statically linked into the binary, so pactester is + # QuickJS is statically linked into the binary, so proxytester is # self-contained (WinHTTP handles PAC on Windows). - cp "target/${{ matrix.target }}/release/examples/pactester${ext}" "$stage/" + cp "target/${{ matrix.target }}/release/examples/proxytester${ext}" "$stage/" shell: bash - - name: Upload pactester artifact + - name: Upload proxytester artifact uses: actions/upload-artifact@v4 with: - name: pactester-${{ matrix.target }} - path: dist/pactester-${{ matrix.target }}/ + name: proxytester-${{ matrix.target }} + path: dist/proxytester-${{ matrix.target }}/ if-no-files-found: error os-config-tests: diff --git a/README.md b/README.md index 83ae36a..a7506a1 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,7 @@ Examples: ```sh cargo run --example resolve -- https://example.com/ # live OS config cargo run --example resolve -- --watch # watch for changes -cargo run --example pactester -- file.pac http://url/ # macOS/Linux +cargo run --example proxytester -- --pac-script file.pac http://url/ # test a PAC file ``` Builds as both `rlib` and `cdylib`. Release automation with `cargo-dist` is a diff --git a/examples/pactester.rs b/examples/proxytester.rs similarity index 96% rename from examples/pactester.rs rename to examples/proxytester.rs index 0cf0a14..5eb090a 100644 --- a/examples/pactester.rs +++ b/examples/proxytester.rs @@ -8,8 +8,8 @@ //! list per URL. Works on every platform. //! //! ```text -//! cargo run --example pactester -- [...] -//! cargo run --example pactester -- --pac-script [...] +//! cargo run --example proxytester -- [...] +//! cargo run --example proxytester -- --pac-script [...] //! ``` //! //! With `--pac-script`, the given PAC file is evaluated instead of the OS @@ -84,7 +84,7 @@ fn main() { fn print_usage() { eprintln!( - "usage: pactester [--pac-script ] [...]\n\ + "usage: proxytester [--pac-script ] [...]\n\ \n\ Without --pac-script, resolves each URL via the OS proxy config, WPAD,\n\ and any configured PAC script. With --pac-script, evaluates the given\n\ diff --git a/src/resolver.rs b/src/resolver.rs index 3ded5d2..a4ce001 100644 --- a/src/resolver.rs +++ b/src/resolver.rs @@ -235,9 +235,9 @@ impl ProxyResolver { /// /// `source` may be a local filesystem path, a `file://` URL, or an /// `http(s)://` URL. Off Windows the script is read (or fetched) and run on - /// the vendored engine. On Windows evaluation is delegated to WinHTTP, + /// the built-in engine. On Windows evaluation is delegated to WinHTTP, /// which only loads PAC over `http(s)`; a local path / `file://` URL is - /// therefore rejected there (serve it over http instead — the `pactester` + /// therefore rejected there (serve it over http instead — the `proxytester` /// example does this for you). #[cfg(not(windows))] pub fn evaluate_pac_source(&self, source: &str, url: &Url) -> Result> { @@ -605,7 +605,7 @@ mod tests { // Serves a PAC over http and evaluates it via the public API. Exercises the // real engine on every platform (the built-in QuickJS engine off Windows, - // WinHTTP on it), which is also the path the `pactester` example drives. + // WinHTTP on it), which is also the path the `proxytester` example drives. #[test] fn evaluate_pac_source_over_http() { use std::io::{Read, Write}; diff --git a/tests/pac_corpus.rs b/tests/pac_corpus.rs index 39ec9d5..6834adc 100644 --- a/tests/pac_corpus.rs +++ b/tests/pac_corpus.rs @@ -3,7 +3,7 @@ * Licensed under the MIT License. See LICENSE.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -//! pactester-style corpus: (pac file, url) -> expected proxy list. Catches +//! proxytester-style corpus: (pac file, url) -> expected proxy list. Catches //! drift in the built-in QuickJS PAC engine builtins cheaply. #![cfg(not(windows))]