From ca29ae0845d902241e5c8e8cf86aab0056e6fdfa Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:44:54 +0000 Subject: [PATCH 1/7] Build the desktop binary before cargo test in native CI. qualification_real spawns target/debug/shadowcode, which cargo test does not produce. Native Linux release v0.21.0 failed Verify native source with that missing-binary assertion. Co-authored-by: Bob Corbin --- .github/workflows/native.yml | 3 ++- .github/workflows/release.yml | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/native.yml b/.github/workflows/native.yml index c71e53bc..f733add3 100644 --- a/.github/workflows/native.yml +++ b/.github/workflows/native.yml @@ -26,8 +26,9 @@ jobs: run: rustup toolchain install 1.95.0 --profile minimal --component rustfmt --component clippy - run: cargo +1.95.0 fmt --all --check - run: cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings - - run: cargo +1.95.0 test --workspace --locked + # qualification_real spawns target/debug/shadowcode; cargo test does not build it. - run: cargo +1.95.0 build -p shadowcode-desktop --locked + - run: cargo +1.95.0 test --workspace --locked - name: Exercise the native CLI without a display run: node scripts/test-native-cli.mjs - name: Stress repeated native tasks, cancellation, output and cleanup diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 04236013..ed58a4e0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,8 +32,9 @@ jobs: run: | cargo +1.95.0 fmt --all --check cargo +1.95.0 clippy --workspace --all-targets --locked -- -D warnings - cargo +1.95.0 test --workspace --locked + # qualification_real spawns target/debug/shadowcode; cargo test does not build it. cargo +1.95.0 build -p shadowcode-desktop --locked + cargo +1.95.0 test --workspace --locked - name: Exercise native application behavior run: | node scripts/test-native-cli.mjs From a28437759223b7671028733a14ac245221054b69 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:55:00 +0000 Subject: [PATCH 2/7] Build qualification_real's engine bin as a cargo test dependency. Those tests looked for target/debug/shadowcode, which cargo test does not produce. Point them at a same-crate shadowcode-engine bin so `cargo test --workspace --locked` builds it first on a clean checkout. Co-authored-by: Bob Corbin --- native/core/Cargo.toml | 6 ++++ native/core/src/bin/shadowcode.rs | 47 +++++++++++++++++++++++++ native/core/tests/qualification_real.rs | 6 ++-- 3 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 native/core/src/bin/shadowcode.rs diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml index 60ecff5d..f18a0c39 100644 --- a/native/core/Cargo.toml +++ b/native/core/Cargo.toml @@ -5,6 +5,12 @@ edition.workspace = true rust-version.workspace = true license.workspace = true +[[bin]] +name = "shadowcode-engine" +path = "src/bin/shadowcode.rs" +test = false +bench = false + [dependencies] anyhow = "1" serde = { version = "1", features = ["derive"] } diff --git a/native/core/src/bin/shadowcode.rs b/native/core/src/bin/shadowcode.rs new file mode 100644 index 00000000..f3c7f02e --- /dev/null +++ b/native/core/src/bin/shadowcode.rs @@ -0,0 +1,47 @@ +//! Engine CLI used by `qualification_real`. Cargo builds this bin before those +//! integration tests, so `cargo test --workspace` does not need a prior desktop build. +fn main() { + #[cfg(not(unix))] + { + eprintln!("ShadowCode CLI is only available on Unix"); + std::process::exit(1); + } + #[cfg(unix)] + if let Err(error) = run() { + eprintln!("ShadowCode: {error:#}"); + std::process::exit(1); + } +} + +#[cfg(unix)] +fn run() -> anyhow::Result<()> { + use serde_json::json; + use shadowcode_core::cli::{self, Options}; + let options = Options::parse_args(); + let json_output = options.json && !options.mcp_stdio(); + let events_output = options.events(); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + let result = runtime.block_on(cli::run(options)); + runtime.shutdown_timeout(std::time::Duration::from_secs(2)); + let code = match result { + Ok(code) => code, + Err(error) => { + use std::io::Write; + if json_output || events_output { + let value = json!({"ok":false,"error":format!("{error:#}")}); + let value = if events_output { + json!({"type":"result","exit_code":1,"result":value}) + } else { + value + }; + let _ = writeln!(std::io::stdout(), "{value}"); + } else { + let _ = writeln!(std::io::stderr(), "ShadowCode: {error:#}"); + } + 1 + } + }; + std::process::exit(code); +} diff --git a/native/core/tests/qualification_real.rs b/native/core/tests/qualification_real.rs index e4771e84..8ca108af 100644 --- a/native/core/tests/qualification_real.rs +++ b/native/core/tests/qualification_real.rs @@ -1,5 +1,5 @@ //! Real OS-process qualification: engine death, persistence, and no silent replay. -//! These spawn `target/debug/shadowcode`, not an in-process Server. +//! These spawn the `shadowcode-engine` bin (or `SHADOW_DESKTOP_BINARY`), not an in-process Server. #![cfg(unix)] mod support; use serde_json::{json, Value}; @@ -16,9 +16,7 @@ use std::{ fn binary() -> PathBuf { std::env::var_os("SHADOW_DESKTOP_BINARY") .map(PathBuf::from) - .unwrap_or_else(|| { - PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/shadowcode") - }) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_shadowcode_engine"))) } fn request(method: &str, path: &str, body: Value) -> Request { From 83ee58878ff3c3dc373c1eaef38a392b0cb85097 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 13:56:15 +0000 Subject: [PATCH 3/7] Revert "Build qualification_real's engine bin as a cargo test dependency." This reverts commit a28437759223b7671028733a14ac245221054b69. --- native/core/Cargo.toml | 6 ---- native/core/src/bin/shadowcode.rs | 47 ------------------------- native/core/tests/qualification_real.rs | 6 ++-- 3 files changed, 4 insertions(+), 55 deletions(-) delete mode 100644 native/core/src/bin/shadowcode.rs diff --git a/native/core/Cargo.toml b/native/core/Cargo.toml index f18a0c39..60ecff5d 100644 --- a/native/core/Cargo.toml +++ b/native/core/Cargo.toml @@ -5,12 +5,6 @@ edition.workspace = true rust-version.workspace = true license.workspace = true -[[bin]] -name = "shadowcode-engine" -path = "src/bin/shadowcode.rs" -test = false -bench = false - [dependencies] anyhow = "1" serde = { version = "1", features = ["derive"] } diff --git a/native/core/src/bin/shadowcode.rs b/native/core/src/bin/shadowcode.rs deleted file mode 100644 index f3c7f02e..00000000 --- a/native/core/src/bin/shadowcode.rs +++ /dev/null @@ -1,47 +0,0 @@ -//! Engine CLI used by `qualification_real`. Cargo builds this bin before those -//! integration tests, so `cargo test --workspace` does not need a prior desktop build. -fn main() { - #[cfg(not(unix))] - { - eprintln!("ShadowCode CLI is only available on Unix"); - std::process::exit(1); - } - #[cfg(unix)] - if let Err(error) = run() { - eprintln!("ShadowCode: {error:#}"); - std::process::exit(1); - } -} - -#[cfg(unix)] -fn run() -> anyhow::Result<()> { - use serde_json::json; - use shadowcode_core::cli::{self, Options}; - let options = Options::parse_args(); - let json_output = options.json && !options.mcp_stdio(); - let events_output = options.events(); - let runtime = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - let result = runtime.block_on(cli::run(options)); - runtime.shutdown_timeout(std::time::Duration::from_secs(2)); - let code = match result { - Ok(code) => code, - Err(error) => { - use std::io::Write; - if json_output || events_output { - let value = json!({"ok":false,"error":format!("{error:#}")}); - let value = if events_output { - json!({"type":"result","exit_code":1,"result":value}) - } else { - value - }; - let _ = writeln!(std::io::stdout(), "{value}"); - } else { - let _ = writeln!(std::io::stderr(), "ShadowCode: {error:#}"); - } - 1 - } - }; - std::process::exit(code); -} diff --git a/native/core/tests/qualification_real.rs b/native/core/tests/qualification_real.rs index 8ca108af..e4771e84 100644 --- a/native/core/tests/qualification_real.rs +++ b/native/core/tests/qualification_real.rs @@ -1,5 +1,5 @@ //! Real OS-process qualification: engine death, persistence, and no silent replay. -//! These spawn the `shadowcode-engine` bin (or `SHADOW_DESKTOP_BINARY`), not an in-process Server. +//! These spawn `target/debug/shadowcode`, not an in-process Server. #![cfg(unix)] mod support; use serde_json::{json, Value}; @@ -16,7 +16,9 @@ use std::{ fn binary() -> PathBuf { std::env::var_os("SHADOW_DESKTOP_BINARY") .map(PathBuf::from) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_shadowcode_engine"))) + .unwrap_or_else(|| { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/debug/shadowcode") + }) } fn request(method: &str, path: &str, body: Value) -> Request { From dc95d7937ea2e891e1a1d1ef9861d39bf50de590 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 14:20:08 +0000 Subject: [PATCH 4/7] Isolate attach/close descriptor counting from sibling tests. Native desktop CI failed attach_close_loop_does_not_grow_descriptors with descriptor leak: baseline=39 after=64. Isolated runs stay flat; the test reads /proc/self/fd for the whole process while other control tests run in the same binary. Move it to its own integration crate. Co-authored-by: Bob Corbin --- native/core/tests/control.rs | 29 -------------- native/core/tests/control_descriptors.rs | 49 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 native/core/tests/control_descriptors.rs diff --git a/native/core/tests/control.rs b/native/core/tests/control.rs index b8ba3dd6..e5b86564 100644 --- a/native/core/tests/control.rs +++ b/native/core/tests/control.rs @@ -598,32 +598,3 @@ async fn attached_view_reattaches_after_owner_restart_without_replay_or_duplicat assert_eq!(running, 0, "reattach must not start or resume jobs"); stop(server, service).await; } - -fn descriptor_count() -> usize { - std::fs::read_dir("/proc/self/fd") - .map(|entries| entries.count()) - .unwrap_or(0) -} - -#[tokio::test] -async fn attach_close_loop_does_not_grow_descriptors() { - let (_root, service) = setup(); - let server = Server::start_with_mode(service.clone(), "server").unwrap(); - let client = server.endpoint().client(service.workspace().unwrap(), None); - for _ in 0..2 { - let view = client.open_view().await.unwrap(); - view.close().await.unwrap(); - } - let baseline = descriptor_count(); - for _ in 0..20 { - let view = client.open_view().await.unwrap(); - view.close().await.unwrap(); - } - let after = descriptor_count(); - eprintln!("attach_close_loop descriptors baseline={baseline} after={after}"); - assert!( - after <= baseline + 24, - "descriptor leak: baseline={baseline} after={after}" - ); - stop(server, service).await; -} diff --git a/native/core/tests/control_descriptors.rs b/native/core/tests/control_descriptors.rs new file mode 100644 index 00000000..5fb3bb52 --- /dev/null +++ b/native/core/tests/control_descriptors.rs @@ -0,0 +1,49 @@ +//! Process-wide `/proc/self/fd` counts. This file is a separate integration +//! binary so sibling `control` tests cannot inflate the measurement. +use serde_json::json; +use shadowcode_core::{config::Config, control::Server, paths::AppPaths, service::Service}; +use std::fs; + +fn setup() -> (tempfile::TempDir, Service) { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("project"); + fs::create_dir(&workspace).unwrap(); + let paths = AppPaths::isolated(&root.path().join("profile")).unwrap(); + Config::patch(&paths,json!({"trusted_workspaces":[workspace],"model":{"provider":"local","name":"fixture","endpoint":"http://127.0.0.1:9/v1"},"permissions":{"approve_shell":false}})).unwrap(); + (root, Service::open(paths, Some(workspace)).unwrap()) +} + +async fn stop(server: Server, service: Service) { + server.close(); + server.wait_closed().await; + service.engine.shutdown().await.unwrap(); +} + +fn descriptor_count() -> usize { + std::fs::read_dir("/proc/self/fd") + .map(|entries| entries.count()) + .unwrap_or(0) +} + +#[tokio::test] +async fn attach_close_loop_does_not_grow_descriptors() { + let (_root, service) = setup(); + let server = Server::start_with_mode(service.clone(), "server").unwrap(); + let client = server.endpoint().client(service.workspace().unwrap(), None); + for _ in 0..2 { + let view = client.open_view().await.unwrap(); + view.close().await.unwrap(); + } + let baseline = descriptor_count(); + for _ in 0..20 { + let view = client.open_view().await.unwrap(); + view.close().await.unwrap(); + } + let after = descriptor_count(); + eprintln!("attach_close_loop descriptors baseline={baseline} after={after}"); + assert!( + after <= baseline + 24, + "descriptor leak: baseline={baseline} after={after}" + ); + stop(server, service).await; +} From 520c7264846caf864caccc3fd81fe4221edf2d96 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 14:30:27 +0000 Subject: [PATCH 5/7] Read --version from tauri.conf.json in native CLI tests. Native desktop verify reached the CLI harness after cargo test passed. test-native-cli.mjs still required ShadowCode 0.20.0; the 0.21.0 binary prints 0.21.0. Use the packaged version so the next bump does not stall. Co-authored-by: Bob Corbin --- scripts/test-native-cli.mjs | 10 ++++++++-- scripts/test-native-runtime.mjs | 10 ++++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/scripts/test-native-cli.mjs b/scripts/test-native-cli.mjs index 153a88f5..b31afd57 100644 --- a/scripts/test-native-cli.mjs +++ b/scripts/test-native-cli.mjs @@ -9,6 +9,12 @@ import { fileURLToPath } from "node:url"; import { DatabaseSync } from "node:sqlite"; const root = fileURLToPath(new URL("../", import.meta.url)); +const version = JSON.parse( + await readFile(path.join(root, "src-tauri/tauri.conf.json"), "utf8"), +).version; +const versionLine = new RegExp( + `^ShadowCode ${String(version).replaceAll(".", "\\.")}\\s*$`, +); const binary = process.env.SHADOW_DESKTOP_BINARY || path.join(root, "target/debug/shadowcode"); const binaryArgs = JSON.parse(process.env.SHADOW_CLI_ARGS || "[]"); assert.ok(Array.isArray(binaryArgs) && binaryArgs.every(arg => typeof arg === "string")); @@ -124,8 +130,8 @@ await writeFile(path.join(profile, "config/config.yaml"), JSON.stringify({ })); let server; try { - assert.match((await finish(launch(["--version"]))).stdout, /^ShadowCode 0\.20\.0\s*$/); - assert.match((await finish(launch(["ui", "--version"]))).stdout, /^ShadowCode 0\.20\.0\s*$/); + assert.match((await finish(launch(["--version"]))).stdout, versionLine); + assert.match((await finish(launch(["ui", "--version"]))).stdout, versionLine); assert.match((await finish(launch(["--help"]))).stdout, /serve/); await finish(launch(["--unknown-option"]), 2); assert.equal((await cli(["health"])).runtime, "rust"); diff --git a/scripts/test-native-runtime.mjs b/scripts/test-native-runtime.mjs index c7983b11..24e5eec3 100644 --- a/scripts/test-native-runtime.mjs +++ b/scripts/test-native-runtime.mjs @@ -16,6 +16,12 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; const root = fileURLToPath(new URL("../", import.meta.url)); +const version = JSON.parse( + await readFile(path.join(root, "src-tauri/tauri.conf.json"), "utf8"), +).version; +const versionLine = new RegExp( + `^ShadowCode ${String(version).replaceAll(".", "\\.")}\\s*$`, +); const binary = path.resolve( process.argv[2] || path.join( @@ -224,10 +230,10 @@ try { Array.from({ length: 8 }, () => finish(launch(["--version"]))), ); for (const reply of replies) - assert.match(reply.stdout, /^ShadowCode 0\.20\.0\s*$/); + assert.match(reply.stdout, versionLine); assert.match( (await finish(launch(["ui", "--version"], {}, true))).stdout, - /^ShadowCode 0\.20\.0\s*$/, + versionLine, "Environment-based extraction preserves arguments", ); await intact(first); From 54f7db1cf2d1746151229284178983cdf1646d66 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 14:46:16 +0000 Subject: [PATCH 6/7] Do not block attached-window close on owner reconnect. Native desktop timed out at "Window can close after its owner exits". reattach() held the attach lock for up to 20s in wait_available, so desktop_quit/close() could not detach. Release that lock while waiting and abort reconnect when close starts. Co-authored-by: Bob Corbin --- native/core/src/control/view.rs | 48 +++++++++++++++++++++++++++++++-- native/core/tests/control.rs | 31 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/native/core/src/control/view.rs b/native/core/src/control/view.rs index 84ce2f24..9946c055 100644 --- a/native/core/src/control/view.rs +++ b/native/core/src/control/view.rs @@ -62,6 +62,7 @@ pub struct ViewClient { reader: Mutex>>>, events: broadcast::Sender, closed: Arc, + closing: Arc, attach: Mutex<()>, } impl Client { @@ -126,6 +127,7 @@ impl Client { reader: Mutex::new(Some(AbortOnDropHandle::new(task))), events, closed, + closing: Arc::new(AtomicBool::new(false)), attach: Mutex::new(()), }) } @@ -158,14 +160,28 @@ impl ViewClient { /// tools, or emit durable events. Existing subscribers keep this /// broadcast and receive `view.reattached`. pub async fn reattach(&self) -> Result { - let _gate = self.attach.lock().await; + ensure!( + !self.closing.load(Ordering::Acquire), + "Attached view is closed" + ); ensure!( self.closed.load(Ordering::Acquire), "Attached view is still connected; detach before reattaching" ); let mut base = self.client()?; base.view = None; - base.wait_available(Duration::from_secs(20)).await?; + // Do not hold `attach` while waiting: close() must stay able to + // detach after the owner exits instead of blocking for 20s. + self.wait_for_owner(&base, Duration::from_secs(20)).await?; + let _gate = self.attach.lock().await; + ensure!( + !self.closing.load(Ordering::Acquire), + "Attached view is closed" + ); + ensure!( + self.closed.load(Ordering::Acquire), + "Attached view is still connected; detach before reattaching" + ); let _ = self.reader.lock().await.take(); let _ = self.writer.lock().await.take(); let fresh = base.open_view().await?; @@ -195,7 +211,34 @@ impl ViewClient { "tools_replayed": 0, })) } + async fn wait_for_owner(&self, client: &Client, timeout: Duration) -> Result<()> { + let deadline = tokio::time::Instant::now() + timeout; + loop { + ensure!( + !self.closing.load(Ordering::Acquire), + "Attached view is closed" + ); + match client.available().await { + Ok(true) => return Ok(()), + Ok(false) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Ok(false) => bail!("No running engine is available to attach"), + Err(error) + if error.to_string().contains("different version") + || error.to_string().contains("protocol/profile mismatch") => + { + return Err(error); + } + Err(_error) if tokio::time::Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(100)).await; + } + Err(error) => return Err(error), + } + } + } pub async fn close(&self) -> Result<()> { + self.closing.store(true, Ordering::Release); let _gate = self.attach.lock().await; self.closed.store(true, Ordering::Release); let Some(mut task) = self.reader.lock().await.take() else { @@ -317,6 +360,7 @@ mod tests { reader: Mutex::new(Some(AbortOnDropHandle::new(task))), events, closed: Arc::new(AtomicBool::new(false)), + closing: Arc::new(AtomicBool::new(false)), attach: Mutex::new(()), }); let closing_view = view.clone(); diff --git a/native/core/tests/control.rs b/native/core/tests/control.rs index e5b86564..060631af 100644 --- a/native/core/tests/control.rs +++ b/native/core/tests/control.rs @@ -476,6 +476,37 @@ async fn attached_views_receive_bounded_completion_notifications_and_detect_owne service.engine.shutdown().await.unwrap(); } +#[tokio::test] +async fn attached_view_close_does_not_wait_for_missing_owner() { + let (_root, service) = setup(); + let server = Server::start_with_mode(service.clone(), "server").unwrap(); + let client = server.endpoint().client(service.workspace().unwrap(), None); + let view = std::sync::Arc::new(client.open_view().await.unwrap()); + server.close(); + server.wait_closed().await; + let reattach = { + let view = view.clone(); + tokio::spawn(async move { view.reattach().await }) + }; + tokio::time::sleep(Duration::from_millis(150)).await; + let started = std::time::Instant::now(); + let _ = view.close().await; + assert!( + started.elapsed() < Duration::from_secs(2), + "close waited for owner reconnect: {:?}", + started.elapsed() + ); + let reattach = tokio::time::timeout(Duration::from_secs(2), reattach) + .await + .expect("reattach should stop when the view closes") + .unwrap(); + assert!( + reattach.is_err(), + "reattach must not succeed after close: {reattach:?}" + ); + service.engine.shutdown().await.unwrap(); +} + #[tokio::test] async fn attached_view_reattaches_after_owner_restart_without_replay_or_duplicates() { let (_root, service) = setup(); From bb2090b130338f4dbd9930c9d7a938694cdebbeb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 21 Sep 2026 15:06:41 +0000 Subject: [PATCH 7/7] Keep rustc on the sanitized packaging PATH. Native desktop failed after the window suite: applicationNotices spawns rustc --version --verbose, but applyPackagingPath only kept target/, Node, and /usr/bin. Add CARGO_HOME, ~/.cargo/bin, and any safe PATH directory that already has rustc/cargo. Still reject /usr/local/bin and Hermes. Co-authored-by: Bob Corbin --- scripts/native-packaging-env.mjs | 34 +++++++++++++++++++++++++++ scripts/test-native-packaging-env.mjs | 10 ++++++++ 2 files changed, 44 insertions(+) diff --git a/scripts/native-packaging-env.mjs b/scripts/native-packaging-env.mjs index 15c1ccee..9feade1d 100644 --- a/scripts/native-packaging-env.mjs +++ b/scripts/native-packaging-env.mjs @@ -56,11 +56,45 @@ export function isUnsafePackagingDir(dir) { ); } +function dirContains(dir, name) { + try { + statSync(path.join(dir, name)); + return true; + } catch { + return false; + } +} + +// rustc/cargo live on the caller PATH (rustup ~/.cargo/bin, CARGO_HOME). +// linuxdeploy still must not see /usr/local/bin or Hermes; keep only the +// toolchain directories themselves when they pass the same safety checks. +function rustToolchainDirs() { + const candidates = []; + if (process.env.CARGO_HOME) { + candidates.push(path.join(process.env.CARGO_HOME, "bin")); + } + if (process.env.HOME) { + candidates.push(path.join(process.env.HOME, ".cargo", "bin")); + } + for (const dir of (process.env.PATH || "").split(path.delimiter)) { + if (!dir) continue; + const resolved = resolveExistingDir(dir); + if ( + resolved && + (dirContains(resolved, "rustc") || dirContains(resolved, "cargo")) + ) { + candidates.push(resolved); + } + } + return candidates; +} + export function packagingDirs(root, options = {}) { const execDir = options.execDir ?? path.dirname(process.execPath); const extras = [ path.join(root, "tools/rust-dev/extracted/usr/bin"), path.join(root, "..", "tools/rust-dev/extracted/usr/bin"), + ...rustToolchainDirs(), path.join(root, "target/release"), path.join(root, "target/debug"), path.join(root, "target/.tauri"), diff --git a/scripts/test-native-packaging-env.mjs b/scripts/test-native-packaging-env.mjs index 198c34e8..a24b1e0a 100644 --- a/scripts/test-native-packaging-env.mjs +++ b/scripts/test-native-packaging-env.mjs @@ -38,6 +38,12 @@ test("packaging PATH is constructed, not inherited", () => { assert.doesNotMatch(joined, /\.hermes/); const rustDev = path.resolve(root, "../tools/rust-dev/extracted/usr/bin"); if (existsSync(rustDev)) assert.ok(dirs.includes(rustDev)); + const rustcDir = dirs.find((dir) => existsSync(path.join(dir, "rustc"))); + assert.ok( + rustcDir, + "sanitized PATH must still find rustc for dependency notices", + ); + assert.notEqual(path.resolve(rustcDir), "/usr/local/bin"); const tauriTools = path.join(root, "target/.tauri"); if (existsSync(tauriTools)) assert.ok(dirs.includes(tauriTools)); }); @@ -64,6 +70,10 @@ test("applyPackagingPath overwrites a dirty process PATH", () => { assert.doesNotMatch(next, /(^|:)\/usr\/local\/bin(:|$)/); assert.match(next, /(^|:)\/usr\/bin(:|$)/); assert.notEqual(next, dirtyPath); + assert.ok( + next.split(path.delimiter).some((dir) => existsSync(path.join(dir, "rustc"))), + "dirty caller PATH must not drop rustc", + ); } finally { process.env.PATH = previous; }