From c1ad8078a9cea504f9085e317b5dc138a76d2ca0 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 22:39:21 -0700 Subject: [PATCH 01/11] fix: repair Windows long paths with a warned short-path fallback (#1673) --- .github/workflows/check-windows.yml | 56 ++ README.md | 4 + crates/fbuild-build-engine/src/compiler.rs | 3 + .../src/pipeline/context.rs | 3 + crates/fbuild-cli/build.rs | 8 + crates/fbuild-cli/src/cli/README.md | 1 + crates/fbuild-cli/src/cli/args.rs | 8 + crates/fbuild-cli/src/cli/build.rs | 5 +- crates/fbuild-cli/src/cli/clean.rs | 1 + crates/fbuild-cli/src/cli/daemon_cmd.rs | 15 +- crates/fbuild-cli/src/cli/deploy.rs | 2 + crates/fbuild-cli/src/cli/dispatch.rs | 24 + crates/fbuild-cli/src/cli/mod.rs | 1 + .../fbuild-cli/src/cli/windows_long_paths.rs | 481 ++++++++++++++ crates/fbuild-cli/src/daemon_client.rs | 103 ++- .../fbuild-cli/src/daemon_client/identity.rs | 1 + crates/fbuild-cli/src/daemon_client/tests.rs | 1 + crates/fbuild-cli/src/daemon_client/types.rs | 10 + crates/fbuild-cli/src/main.rs | 7 +- crates/fbuild-cli/src/mcp/tools.rs | 2 + crates/fbuild-cli/src/output.rs | 91 ++- crates/fbuild-cli/tests/it/main.rs | 1 + .../fbuild-cli/tests/it/windows_long_paths.rs | 41 ++ crates/fbuild-core/Cargo.toml | 1 + crates/fbuild-core/src/platform/README.md | 15 + crates/fbuild-core/src/platform/fs.rs | 115 +++- crates/fbuild-core/src/platform/linux/fs.rs | 72 +- crates/fbuild-core/src/platform/macos/fs.rs | 72 +- .../src/platform/windows/README.md | 25 + crates/fbuild-core/src/platform/windows/fs.rs | 315 ++++++++- .../src/platform/windows/long-paths.manifest | 15 + .../fbuild-core/src/platform/windows/mod.rs | 1 + .../src/platform/windows/private_directory.rs | 627 ++++++++++++++++++ crates/fbuild-daemon/build.rs | 15 + crates/fbuild-daemon/src/README.md | 1 + crates/fbuild-daemon/src/broker/backend.rs | 1 + crates/fbuild-daemon/src/context.rs | 9 + crates/fbuild-daemon/src/handlers/health.rs | 1 + crates/fbuild-daemon/src/lib.rs | 1 + crates/fbuild-daemon/src/long_paths.rs | 621 +++++++++++++++++ crates/fbuild-daemon/src/main.rs | 12 + crates/fbuild-daemon/src/models.rs | 7 + crates/fbuild-packages-fetch/src/extractor.rs | 62 ++ crates/fbuild-paths/README.md | 5 + crates/fbuild-paths/src/README.md | 1 + crates/fbuild-paths/src/lib.rs | 43 +- crates/fbuild-paths/src/long_paths.rs | 570 ++++++++++++++++ crates/fbuild-paths/src/long_paths/README.md | 3 + crates/fbuild-paths/src/long_paths/tests.rs | 522 +++++++++++++++ crates/fbuild-paths/src/running_process.rs | 12 +- docs/getting-started/README.md | 44 ++ docs/reference/cli.md | 6 + 52 files changed, 4004 insertions(+), 59 deletions(-) create mode 100644 crates/fbuild-cli/src/cli/windows_long_paths.rs create mode 100644 crates/fbuild-cli/tests/it/windows_long_paths.rs create mode 100644 crates/fbuild-core/src/platform/windows/long-paths.manifest create mode 100644 crates/fbuild-core/src/platform/windows/private_directory.rs create mode 100644 crates/fbuild-daemon/build.rs create mode 100644 crates/fbuild-daemon/src/long_paths.rs create mode 100644 crates/fbuild-paths/src/long_paths.rs create mode 100644 crates/fbuild-paths/src/long_paths/README.md create mode 100644 crates/fbuild-paths/src/long_paths/tests.rs diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index c4d7871bd..4080594db 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -167,3 +167,59 @@ jobs: throw "Warm check did not reuse the verified source launcher" } # The hosted runner discards RUNNER_TEMP after the job. + + - name: Windows long-path repair and fallback smoke + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $registry = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' + $original = Get-ItemPropertyValue -Path $registry -Name LongPathsEnabled -ErrorAction SilentlyContinue + $metadata = soldr cargo metadata --format-version 1 --no-deps | ConvertFrom-Json + if ($LASTEXITCODE -ne 0) { throw 'cargo metadata failed' } + $cli = Join-Path $metadata.target_directory 'debug\fbuild.exe' + $daemon = Join-Path $metadata.target_directory 'debug\fbuild-daemon.exe' + foreach ($binary in @($cli, $daemon)) { + $image = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($binary)) + if ($image -notmatch 'longPathAware[^>]*>true long-path-warning.txt + $code = $LASTEXITCODE + if ($code -notin @(0, 2)) { throw "fallback package check failed: $code" } + $output | ConvertFrom-Json | Out-Null + $warning = Get-Content long-path-warning.txt -Raw + if ($warning -notmatch 'WARNING:' -or $warning -notmatch 'fix-win' -or $warning.Contains([char]27)) { + throw 'fallback warning is missing or contains ANSI in redirected stderr' + } + & $cli build tests/platform/uno --dry-run --no-update-check + if ($LASTEXITCODE -ne 0) { throw 'disabled-policy daemon handoff failed' } + $after = Get-Content (Join-Path $env:USERPROFILE '.fbuild\prod\daemon\root-owner.json') -Raw | ConvertFrom-Json + if ($before.pid -eq $after.pid) { throw 'daemon was reused after the registry changed' } + & $cli daemon stop --no-update-check + if ($LASTEXITCODE -ne 0) { throw 'smoke daemon did not stop' } + } finally { + if ($null -eq $original) { + Remove-ItemProperty -Path $registry -Name LongPathsEnabled -ErrorAction SilentlyContinue + } else { + New-ItemProperty -Path $registry -Name LongPathsEnabled -PropertyType DWord -Value $original -Force | Out-Null + } + } diff --git a/README.md b/README.md index 0be4acdea..e08eff13e 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,10 @@ pip install fbuild For source installs, platform notes, and first-run cache behavior, start with the [getting started guide](docs/getting-started/README.md). +On Windows, `fbuild --fix-win` enables long paths with administrator approval. +Interactive ESP32 commands offer this repair when needed; declining uses a +short cache and prints a yellow warning. See [Windows setup](docs/getting-started/README.md#windows-long-paths). + ## Command Quick Start fbuild reads the same `platformio.ini` files as PlatformIO. Use these commands diff --git a/crates/fbuild-build-engine/src/compiler.rs b/crates/fbuild-build-engine/src/compiler.rs index f3e4dc952..da2aa9b80 100644 --- a/crates/fbuild-build-engine/src/compiler.rs +++ b/crates/fbuild-build-engine/src/compiler.rs @@ -591,6 +591,9 @@ pub async fn compile_source( let output_buf = absolute_from_cwd(output); let source = source_buf.as_path(); let output = output_buf.as_path(); + for path in [compiler, source, output] { + fbuild_paths::long_paths::validate_legacy_path(path)?; + } if let Some(parent) = output.parent() { std::fs::create_dir_all(parent)?; diff --git a/crates/fbuild-build-engine/src/pipeline/context.rs b/crates/fbuild-build-engine/src/pipeline/context.rs index 21f7291b4..993f1f2f0 100644 --- a/crates/fbuild-build-engine/src/pipeline/context.rs +++ b/crates/fbuild-build-engine/src/pipeline/context.rs @@ -109,6 +109,9 @@ impl BuildContext { ) -> Result { let project_dir = ¶ms.project_dir; let env_name = ¶ms.env_name; + fbuild_paths::long_paths::validate_legacy_path(project_dir)?; + fbuild_paths::long_paths::validate_legacy_path(¶ms.build_dir)?; + fbuild_paths::long_paths::prepare_build_directory(¶ms.build_dir)?; // 1. Parse platformio.ini, attaching any forwarded `PLATFORMIO_*` env // var overrides from the CLI caller (the daemon does not inherit diff --git a/crates/fbuild-cli/build.rs b/crates/fbuild-cli/build.rs index cd275eb2e..f0951e411 100644 --- a/crates/fbuild-cli/build.rs +++ b/crates/fbuild-cli/build.rs @@ -10,5 +10,13 @@ fn main() { let target = std::env::var("TARGET").unwrap_or_default(); if target.contains("pc-windows-msvc") { println!("cargo:rustc-link-arg-bin=fbuild=/STACK:8388608"); + let manifest = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("../fbuild-core/src/platform/windows/long-paths.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg-bin=fbuild=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-bin=fbuild=/MANIFESTINPUT:{}", + manifest.display() + ); } } diff --git a/crates/fbuild-cli/src/cli/README.md b/crates/fbuild-cli/src/cli/README.md index 8b51caae5..6c520f5ba 100644 --- a/crates/fbuild-cli/src/cli/README.md +++ b/crates/fbuild-cli/src/cli/README.md @@ -26,4 +26,5 @@ here and is dispatched from `cli::async_main`. - **`reset.rs`** -- `run_reset` - **`lnk.rs`** -- `run_lnk` (pull / check / add) - **`install.rs`** -- `run_install`: in-process `fbuild install` over `fbuild_build::provision_env`, text / JSON rendering, `--check` exit codes (FastLED/fbuild#1433) +- **`windows_long_paths.rs`** -- Windows preflight, explicit UAC repair, fresh command restart, and graceful daemon handoff (#1673) - **`tests.rs`** -- unit tests for argument normalization and `fbuild ci` parsing diff --git a/crates/fbuild-cli/src/cli/args.rs b/crates/fbuild-cli/src/cli/args.rs index febe87649..23b8945ab 100644 --- a/crates/fbuild-cli/src/cli/args.rs +++ b/crates/fbuild-cli/src/cli/args.rs @@ -116,6 +116,14 @@ pub struct Cli { /// (`CI=true`) is auto-suppressed. See FastLED/fbuild#626. #[arg(long = "no-update-check")] pub no_update_check: bool, + + /// Enable Windows long paths with administrator approval (UAC) + #[arg(long, global = true)] + pub fix_win: bool, + + /// Fixed, one-shot registry repair helper; never starts a daemon. + #[arg(long = "__fix-win-helper", hide = true, conflicts_with = "fix_win")] + pub fix_win_helper: bool, } #[derive(Subcommand)] diff --git a/crates/fbuild-cli/src/cli/build.rs b/crates/fbuild-cli/src/cli/build.rs index 06062895c..c479d6ff7 100644 --- a/crates/fbuild-cli/src/cli/build.rs +++ b/crates/fbuild-cli/src/cli/build.rs @@ -97,6 +97,7 @@ pub async fn run_build( } let (caller_pid, caller_cwd) = daemon_client::caller_info(); let req = BuildRequest { + build_dir_override: daemon_client::capture_build_root(&project_dir), project_dir: project_dir.clone(), environment, clean_build: clean || clean_all, @@ -114,9 +115,7 @@ pub async fn run_build( symbol_analysis: symbol_analysis.is_some(), symbol_analysis_path: symbol_analysis.filter(|s| !s.is_empty()), no_timestamp, - src_dir: std::env::var("PLATFORMIO_SRC_DIR") - .ok() - .filter(|s| !s.is_empty()), + src_dir: daemon_client::capture_src_dir(), caller_path: std::env::var("PATH").ok(), output_dir, pio_env: daemon_client::capture_pio_env(), diff --git a/crates/fbuild-cli/src/cli/clean.rs b/crates/fbuild-cli/src/cli/clean.rs index 010c8aed3..ea4ee635f 100644 --- a/crates/fbuild-cli/src/cli/clean.rs +++ b/crates/fbuild-cli/src/cli/clean.rs @@ -37,6 +37,7 @@ pub async fn run_clean( let client = DaemonClient::new(); daemon_client::warn_if_daemon_identity_mismatch(&client, &project_dir).await; let req = BuildRequest { + build_dir_override: daemon_client::capture_build_root(&project_dir), project_dir, environment, clean_build: true, diff --git a/crates/fbuild-cli/src/cli/daemon_cmd.rs b/crates/fbuild-cli/src/cli/daemon_cmd.rs index efd06eb0a..1836b423a 100644 --- a/crates/fbuild-cli/src/cli/daemon_cmd.rs +++ b/crates/fbuild-cli/src/cli/daemon_cmd.rs @@ -15,7 +15,16 @@ use super::show::run_show; reason = "baseline, zackees/ci.yml#229" )] pub async fn run_daemon(action: DaemonAction) -> fbuild_core::Result<()> { - let client = DaemonClient::new(); + let client = fbuild_paths::daemon_ownership::read_owner_claim() + .filter(|claim| { + fbuild_core::platform::process::pid_exe_stem_matches( + claim.pid, + fbuild_paths::daemon_ownership::DAEMON_EXE_STEM, + ) + }) + .map_or_else(DaemonClient::new, |claim| { + DaemonClient::with_port(claim.port) + }); match action { DaemonAction::Stop => { run_daemon_stop(&client).await?; @@ -26,8 +35,8 @@ pub async fn run_daemon(action: DaemonAction) -> fbuild_core::Result<()> { Ok(info) => { let uptime = format_uptime(info.uptime_seconds); output::result(format!( - "daemon is running at {}", - fbuild_paths::get_daemon_url() + "daemon is running at http://127.0.0.1:{}", + info.port )); output::result(format!(" PID: {}", info.pid)); output::result(format!(" Port: {}", info.port)); diff --git a/crates/fbuild-cli/src/cli/deploy.rs b/crates/fbuild-cli/src/cli/deploy.rs index 6c96036b1..4be3317a3 100644 --- a/crates/fbuild-cli/src/cli/deploy.rs +++ b/crates/fbuild-cli/src/cli/deploy.rs @@ -196,6 +196,7 @@ pub async fn run_deploy( let (caller_pid, caller_cwd) = daemon_client::caller_info(); let req = DeployRequest { + build_dir_override: daemon_client::capture_build_root(&project_dir), project_dir, environment, port, @@ -465,6 +466,7 @@ pub async fn run_test_emu( let (caller_pid, caller_cwd) = daemon_client::caller_info(); let req = TestEmuRequest { + build_dir_override: daemon_client::capture_build_root(&project_dir), project_dir, environment, verbose, diff --git a/crates/fbuild-cli/src/cli/dispatch.rs b/crates/fbuild-cli/src/cli/dispatch.rs index b6dba27ac..b675900f7 100644 --- a/crates/fbuild-cli/src/cli/dispatch.rs +++ b/crates/fbuild-cli/src/cli/dispatch.rs @@ -47,6 +47,21 @@ use super::usb_recovery::run_hidden_helper; pub async fn async_main() { let cli = Cli::parse_from(rewrite_args()); + if cli.fix_win_helper { + if let Err(error) = super::windows_long_paths::run_helper() { + output::diagnostic(error); + std::process::exit(1); + } + return; + } + if cli.fix_win { + if let Err(error) = super::windows_long_paths::run_fix_win().await { + output::diagnostic(error); + std::process::exit(1); + } + return; + } + // This must remain before tracing, environment capture, update checks, or // any daemon client call. The elevated helper is a one-shot PnP process, // never an alternate daemon host. FastLED/fbuild#1148. @@ -62,6 +77,15 @@ pub async fn async_main() { .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); + match super::windows_long_paths::prepare(&cli).await { + Ok(Some(code)) => std::process::exit(code), + Ok(None) => {} + Err(error) => { + output::diagnostic(error); + std::process::exit(1); + } + } + // Scan caller's environment for `PLATFORMIO_*` vars and warn about any // that fbuild does not act on, so users aren't bitten by silent // mis-builds. The captured map is forwarded to the daemon per request. diff --git a/crates/fbuild-cli/src/cli/mod.rs b/crates/fbuild-cli/src/cli/mod.rs index f1db20795..0a9d0b37a 100644 --- a/crates/fbuild-cli/src/cli/mod.rs +++ b/crates/fbuild-cli/src/cli/mod.rs @@ -52,6 +52,7 @@ pub mod udev_install; reason = "USB recovery integration belongs exclusively to #1147" )] pub mod usb_recovery; +pub mod windows_long_paths; #[cfg(test)] mod tests; diff --git a/crates/fbuild-cli/src/cli/windows_long_paths.rs b/crates/fbuild-cli/src/cli/windows_long_paths.rs new file mode 100644 index 000000000..56ed1feb2 --- /dev/null +++ b/crates/fbuild-cli/src/cli/windows_long_paths.rs @@ -0,0 +1,481 @@ +//! Windows long-path repair and interactive preflight (#1673). + +use std::io::IsTerminal; +use std::path::Path; + +use fbuild_core::platform::{fs, host, process}; +use fbuild_core::{FbuildError, Result}; +use fbuild_paths::long_paths::{self, Observation}; + +use super::args::{Cli, Commands}; +use crate::{daemon_client::DaemonClient, output}; + +pub(crate) fn should_prompt(observation: &Observation, interactive: bool, ci: bool) -> bool { + observation.requires_fallback() && interactive && !ci +} + +pub(crate) fn consented(answer: &str) -> bool { + matches!(answer.trim().to_ascii_lowercase().as_str(), "y" | "yes") +} + +pub(crate) fn setting_changed(startup: Option<&Observation>, current: &Observation) -> bool { + !matches!(current, Observation::NotApplicable) + && startup.is_none_or(|startup| startup.status_key() != current.status_key()) +} + +fn ci_session() -> bool { + [ + "CI", + "GITHUB_ACTIONS", + "GITLAB_CI", + "CIRCLECI", + "TF_BUILD", + "JENKINS_URL", + ] + .iter() + .any(|key| { + std::env::var(key).is_ok_and(|value| { + !value.is_empty() && value != "0" && !value.eq_ignore_ascii_case("false") + }) + }) +} + +fn is_build_operation(cli: &Cli) -> bool { + if (cli.command.is_none() && cli.platformio) + || matches!( + &cli.command, + Some( + Commands::Build { + platformio: true, + .. + } | Commands::Deploy { + platformio: true, + .. + } + ) + ) + { + return false; + } + matches!( + cli.command, + None | Some( + Commands::Build { .. } + | Commands::Deploy { .. } + | Commands::TestEmu { .. } + | Commands::Install { .. } + | Commands::CompileMany { .. } + | Commands::Ci { .. } + ) + ) +} + +fn is_esp32_operation(cli: &Cli) -> bool { + let (project, environments): (&str, Vec<&str>) = match &cli.command { + Some( + Commands::Build { + project_dir, + environment, + .. + } + | Commands::Deploy { + project_dir, + environment, + .. + } + | Commands::TestEmu { + project_dir, + environment, + .. + }, + ) => ( + project_dir + .as_deref() + .or(cli.project_dir.as_deref()) + .unwrap_or("."), + environment.iter().map(String::as_str).collect(), + ), + Some(Commands::Install { + project_dir, + environments, + .. + }) => ( + project_dir + .as_deref() + .or(cli.project_dir.as_deref()) + .unwrap_or("."), + environments.iter().map(String::as_str).collect(), + ), + Some(Commands::CompileMany { board, .. } | Commands::Ci { board, .. }) => { + return fbuild_config::BoardConfig::from_board_id( + board, + &std::collections::HashMap::new(), + ) + .is_ok_and(|board| board.platform() == Some(fbuild_core::Platform::Espressif32)); + } + None => ( + cli.project_dir.as_deref().unwrap_or("."), + cli.environment.iter().map(String::as_str).collect(), + ), + _ => return false, + }; + let install = matches!(&cli.command, Some(Commands::Install { .. })); + let config_path = if install { + Path::new(project).join("platformio.ini") + } else { + std::env::var_os("PLATFORMIO_PROJECT_CONFIG") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| Path::new(project).join("platformio.ini")) + }; + let Ok(config) = fbuild_config::PlatformIOConfig::from_path_with_overrides( + &config_path, + if install { + fbuild_config::pio_env::PioEnvOverrides::empty() + } else { + fbuild_config::pio_env::PioEnvOverrides::from_map( + crate::daemon_client::capture_pio_env(), + ) + }, + ) else { + return false; // The ordinary handler reports configuration errors. + }; + selected_config_requires_esp32( + &config, + &environments, + matches!(&cli.command, Some(Commands::Install { all_envs: true, .. })), + ) +} + +fn selected_config_requires_esp32( + config: &fbuild_config::PlatformIOConfig, + environments: &[&str], + all_envs: bool, +) -> bool { + let environments = if all_envs { + config.get_environments() + } else if environments.is_empty() { + config.get_default_environment().into_iter().collect() + } else { + environments.to_vec() + }; + environments.iter().any(|env| { + config + .get_env_config(env) + .ok() + .and_then(|config| config.get("platform").cloned()) + .and_then(|platform| fbuild_core::Platform::from_platform_str(&platform)) + == Some(fbuild_core::Platform::Espressif32) + }) +} + +/// The hidden helper runs before tracing, cache discovery, or daemon acquisition. +pub fn run_helper() -> Result<()> { + fs::enable_long_paths().map_err(|error| { + FbuildError::Other(format!("could not enable Windows long paths: {error}")) + }) +} + +/// Explicit repair authorization: elevate only this executable's fixed helper. +async fn repair() -> Result { + if !host::is_windows() { + return Err(FbuildError::Other( + "fbuild --fix-win is available only on Windows".into(), + )); + } + if matches!(long_paths::probe(), Observation::Enabled) { + return Ok(true); + } + let outcome = tokio::task::spawn_blocking(|| { + let executable = fbuild_core::platform::executable::current_image()?; + process::launch_elevated(executable.as_os_str(), "--__fix-win-helper") + }) + .await + .map_err(|error| FbuildError::Other(format!("Windows repair worker failed: {error}")))? + .map_err(|error| { + FbuildError::Other(format!( + "Windows repair could not request administrator approval: {error}" + )) + })?; + verify_repair(outcome, long_paths::probe()) +} + +fn verify_repair(outcome: process::ElevationOutcome, observation: Observation) -> Result { + match outcome { + process::ElevationOutcome::Declined => Ok(false), + process::ElevationOutcome::Completed(0) if matches!(observation, Observation::Enabled) => { + Ok(true) + } + process::ElevationOutcome::Completed(code) => Err(FbuildError::Other(format!( + "Windows long-path repair was not verified (helper exit {code}). Administrator policy may prevent the change." + ))), + } +} + +pub async fn run_fix_win() -> Result<()> { + if !repair().await? { + return Err(FbuildError::Other( + "Windows repair cancelled; no setting was changed".into(), + )); + } + // Retire a daemon that observed the old setting. The parent does not build. + reconcile_daemon_owner().await?; + output::result("Windows long paths are enabled. The next build will use fresh processes."); + Ok(()) +} + +/// Returns the exit status of a freshly relaunched pending command after repair. +pub async fn prepare(cli: &Cli) -> Result> { + if !host::is_windows() || !is_build_operation(cli) { + return Ok(None); + } + let observation = long_paths::probe(); + let interactive = std::io::stdin().is_terminal() && std::io::stderr().is_terminal(); + if is_build_operation(cli) + && is_esp32_operation(cli) + && should_prompt(&observation, interactive, ci_session()) + { + output::visible_warning( + "Windows long-path support is disabled or unavailable. ESP32 package paths may exceed Windows limits. Run fbuild --fix-win to repair it.", + ); + if output::confirmation_prompt( + "Enable Windows long paths now? This changes a system setting and requires administrator approval.", + )? { + match repair().await { + Ok(true) => { + reconcile_daemon_owner().await?; + return relaunch_command().await.map(Some); + } + Ok(false) => output::visible_warning( + "Administrator approval was cancelled; selecting a short-path fallback.", + ), + Err(error) => { + output::visible_warning(format!("{error} Selecting a short-path fallback.")) + } + } + } + } + tokio::task::spawn_blocking(long_paths::initialize) + .await + .map_err(|error| FbuildError::Other(format!("path selection worker failed: {error}")))??; + if is_build_operation(cli) { + if let Some(advisory) = long_paths::advisory() { + output::visible_warning(advisory); + } + } + Ok(None) +} + +async fn relaunch_command() -> Result { + tokio::task::spawn_blocking(|| { + let executable = fbuild_core::platform::executable::current_image()?; + let mut command = std::process::Command::new(executable.as_path()); + command.args(std::env::args_os().skip(1)); + let mut child = process::spawn_contained( + &mut command, + process::ContainedStdio { + stdin: process::StdioSource::Parent, + stdout: process::StdioSource::Parent, + stderr: process::StdioSource::Parent, + }, + )?; + child.wait() + }) + .await + .map_err(|error| FbuildError::Other(format!("fresh command worker failed: {error}")))? + .map_err(|error| FbuildError::Other(format!("could not restart the pending command: {error}"))) +} + +/// A single mode owns the daemon/compiler store. Drain that owner before a +/// different cache selection or registry observation can start a replacement. +pub async fn reconcile_daemon_owner() -> Result<()> { + if !host::is_windows() { + return Ok(()); + } + if fbuild_paths::daemon_ownership::read_owner_claim().is_none() { + return Ok(()); + } + const HANDOFF_WAIT: std::time::Duration = std::time::Duration::from_secs(15 * 60); + let deadline = tokio::time::Instant::now() + HANDOFF_WAIT; + // Serialize replacement with the existing spawn election, without breaking + // ownership locks or forcing termination of a compiler in flight. + let _spawn = loop { + if let Some(guard) = fbuild_paths::daemon_ownership::try_acquire_spawn_lock_result_at( + &fbuild_paths::daemon_ownership::spawn_lock_path(), + )? { + break guard; + } + if tokio::time::Instant::now() >= deadline { + return Err(FbuildError::DaemonError("another invocation is still changing Windows path settings; retry after it finishes".into())); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + }; + // Read the claim and negotiated identity only AFTER election. A concurrent + // caller may have replaced the owner while we waited for this lock. + let Some(claim) = fbuild_paths::daemon_ownership::read_owner_claim() else { + return Ok(()); + }; + if !process::pid_exe_stem_matches(claim.pid, fbuild_paths::daemon_ownership::DAEMON_EXE_STEM) { + return Ok(()); + } + let client = DaemonClient::with_port(claim.port); + let info = match client.daemon_info().await { + Ok(info) => info, + Err(_) + if !process::pid_exe_stem_matches( + claim.pid, + fbuild_paths::daemon_ownership::DAEMON_EXE_STEM, + ) => + { + return Ok(()); + } + Err(error) => return Err(error), + }; + if info.pid != claim.pid { + return Err(FbuildError::DaemonError( + "daemon owner identity changed during path-policy negotiation; retry the command" + .into(), + )); + } + let observation = long_paths::probe(); + let cache_changed = long_paths::current_resolution().is_some() + && crate::daemon_client::daemon_cache_identity_error(&info).is_some(); + if !cache_changed && !setting_changed(info.long_path_support.as_ref(), &observation) { + return Ok(()); + } + output::visible_warning( + "Windows path settings changed. Waiting for active work to finish before restarting the daemon.", + ); + if let Err(error) = client.shutdown_gracefully().await { + if process::pid_exe_stem_matches(claim.pid, fbuild_paths::daemon_ownership::DAEMON_EXE_STEM) + { + return Err(error); + } + } + while process::pid_exe_stem_matches(claim.pid, fbuild_paths::daemon_ownership::DAEMON_EXE_STEM) + { + if tokio::time::Instant::now() >= deadline { + return Err(FbuildError::DaemonError("path settings have changed, but active daemon work is still draining. It has not been interrupted; retry the command after it finishes".into())); + } + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use fbuild_paths::long_paths::Observation; + + #[test] + fn repair_prompt_is_interactive_and_never_runs_in_ci() { + assert!(should_prompt(&Observation::Disabled, true, false)); + assert!(should_prompt( + &Observation::Unknown("access denied".into()), + true, + false + )); + assert!(!should_prompt(&Observation::Enabled, true, false)); + assert!(!should_prompt(&Observation::NotApplicable, true, false)); + assert!(!should_prompt(&Observation::Disabled, false, false)); + assert!(!should_prompt(&Observation::Disabled, true, true)); + } + + #[test] + fn affirmative_consent_is_required_before_elevation() { + for answer in ["y", "Y\n", "yes", " YES "] { + assert!(consented(answer)); + } + for answer in ["", "\n", "n", "no", "maybe"] { + assert!(!consented(answer)); + } + } + + #[test] + fn platformio_passthrough_does_not_select_fbuild_paths() { + use clap::Parser; + for args in [ + vec!["fbuild", "--platformio"], + vec!["fbuild", "build", "--platformio"], + vec!["fbuild", "deploy", "--platformio"], + ] { + let cli = Cli::try_parse_from(args).unwrap(); + assert!(!is_build_operation(&cli)); + } + assert!(is_build_operation( + &Cli::try_parse_from(["fbuild", "build"]).unwrap() + )); + assert!(is_build_operation( + &Cli::try_parse_from(["fbuild", "--platformio", "build"]).unwrap() + )); + } + + #[test] + fn changing_registry_state_requires_fresh_processes() { + assert!(setting_changed( + Some(&Observation::Disabled), + &Observation::Enabled + )); + assert!(setting_changed( + Some(&Observation::Enabled), + &Observation::Disabled + )); + assert!(!setting_changed( + Some(&Observation::Disabled), + &Observation::Disabled + )); + assert!(!setting_changed( + Some(&Observation::Unknown("old".into())), + &Observation::Unknown("new".into()) + )); + assert!(!setting_changed(None, &Observation::NotApplicable)); + } + + #[test] + fn prompt_selection_follows_requested_environment() { + let root = tempfile::tempdir().unwrap(); + let path = root.path().join("platformio.ini"); + std::fs::write(&path, + "[platformio]\ndefault_envs = uno\n[env:uno]\nplatform = atmelavr\nboard = uno\n[env:esp]\nplatform = espressif32\nboard = esp32dev\n", + ).unwrap(); + let config = fbuild_config::PlatformIOConfig::from_path(&path).unwrap(); + assert!(!selected_config_requires_esp32(&config, &[], false)); + assert!(selected_config_requires_esp32(&config, &[], true)); + assert!(selected_config_requires_esp32(&config, &["esp"], false)); + assert!(!selected_config_requires_esp32(&config, &["uno"], false)); + } + + #[test] + fn repair_requires_successful_helper_and_registry_readback() { + assert!( + !verify_repair(process::ElevationOutcome::Declined, Observation::Disabled).unwrap() + ); + assert!( + verify_repair( + process::ElevationOutcome::Completed(0), + Observation::Enabled + ) + .unwrap() + ); + assert!( + verify_repair( + process::ElevationOutcome::Completed(1), + Observation::Enabled + ) + .is_err() + ); + assert!( + verify_repair( + process::ElevationOutcome::Completed(0), + Observation::Disabled + ) + .is_err() + ); + assert!( + verify_repair( + process::ElevationOutcome::Completed(0), + Observation::Unknown("policy".into()) + ) + .is_err() + ); + } +} diff --git a/crates/fbuild-cli/src/daemon_client.rs b/crates/fbuild-cli/src/daemon_client.rs index a71aa060d..9d32d9817 100644 --- a/crates/fbuild-cli/src/daemon_client.rs +++ b/crates/fbuild-cli/src/daemon_client.rs @@ -73,13 +73,22 @@ pub fn caller_info() -> (Option, Option) { (pid, cwd) } -/// Snapshot all `PLATFORMIO_*` env vars from the current process environment. -/// -/// This is the only place in the codebase where `std::env::vars()` is consulted -/// for `PLATFORMIO_*` keys (other than `fbuild-paths` startup fallbacks). The -/// returned map is forwarded to the daemon over HTTP via `BuildRequest.pio_env` -/// / `DeployRequest.pio_env`, since the daemon process does not inherit caller -/// env vars. +/// Capture this caller's selected build root for the request, including fallback. +pub fn capture_build_root(project_dir: &str) -> Option { + Some( + fbuild_paths::get_project_build_root(std::path::Path::new(project_dir)) + .to_string_lossy() + .into_owned(), + ) +} + +pub fn capture_src_dir() -> Option { + std::env::var("PLATFORMIO_SRC_DIR") + .ok() + .filter(|dir| !dir.is_empty()) +} + +/// Snapshot caller `PLATFORMIO_*` variables for the daemon request. pub fn capture_pio_env() -> BTreeMap { std::env::vars() .filter(|(k, _)| k.starts_with("PLATFORMIO_")) @@ -432,7 +441,11 @@ impl DaemonClient { match event.event_type.as_str() { "log" => { if let Some(msg) = event.message { - println!("{}", msg); + if msg.starts_with("WARNING:") { + crate::output::visible_warning(msg); + } else { + println!("{}", msg); + } } } "status" => { @@ -530,6 +543,28 @@ impl DaemonClient { Ok(()) } + /// Close admission and drain existing HTTP operations; never force-kill. + pub async fn shutdown_gracefully(&self) -> fbuild_core::Result<()> { + self.client + .post(format!("{}/api/daemon/shutdown?force=true", self.base_url)) + .headers(shutdown_caller_headers()) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|error| { + fbuild_core::FbuildError::DaemonError(format!( + "daemon drain request failed: {error}" + )) + })? + .error_for_status() + .map_err(|error| { + fbuild_core::FbuildError::DaemonError(format!( + "daemon refused graceful drain: {error}" + )) + })?; + Ok(()) + } + /// Get lock status from the daemon. pub async fn lock_status(&self) -> fbuild_core::Result { let resp = self @@ -831,10 +866,50 @@ async fn same_running_image( /// If the daemon binary has been updated since the running daemon started, /// gracefully restart it (stale source detection, matching Python behavior). pub async fn ensure_daemon_running() -> fbuild_core::Result<()> { - if try_acquire_broker_daemon().await? { - return Ok(()); + if fbuild_core::platform::host::is_windows() + && fbuild_paths::long_paths::current_resolution().is_none() + { + tokio::task::spawn_blocking(fbuild_paths::long_paths::initialize) + .await + .map_err(|error| { + fbuild_core::FbuildError::Other(format!("path selection worker failed: {error}")) + })??; } - ensure_direct_daemon_running().await + for attempt in 0..3 { + crate::cli::windows_long_paths::reconcile_daemon_owner().await?; + if !try_acquire_broker_daemon().await? { + ensure_direct_daemon_running().await?; + } + if !fbuild_core::platform::host::is_windows() { + return Ok(()); + } + let info = DaemonClient::new().daemon_info().await?; + let current = fbuild_paths::long_paths::probe(); + if let Some(resolution) = fbuild_paths::long_paths::current_resolution() { + if resolution.observation.status_key() != current.status_key() { + return Err(fbuild_core::FbuildError::DaemonError( + "Windows long-path setting changed during this command. Rerun it to select paths in a fresh process.".into(), + )); + } + } + if daemon_cache_identity_error(&info).is_none() + && !crate::cli::windows_long_paths::setting_changed( + info.long_path_support.as_ref(), + ¤t, + ) + { + return Ok(()); + } + if attempt < 2 { + crate::output::visible_warning( + "Another invocation changed Windows path settings while the daemon started. Renegotiating before submitting work.", + ); + } + } + Err(fbuild_core::FbuildError::DaemonError( + "Windows path settings are changing repeatedly. Retry when the other invocation finishes." + .into(), + )) } async fn try_acquire_broker_daemon() -> fbuild_core::Result { @@ -941,6 +1016,12 @@ async fn ensure_direct_daemon_running() -> fbuild_core::Result<()> { } let sibling = restart_diag::SiblingDaemon::discover(); + if fbuild_core::platform::host::is_windows() && client.health().await { + let info = client.daemon_info().await?; + if let Some(error) = daemon_cache_identity_error(&info) { + return Err(fbuild_core::FbuildError::DaemonError(error)); + } + } let restarted = match probe_running_daemon(&client, &sibling).await { DaemonProbe::Keep => return Ok(()), DaemonProbe::Restarted => true, diff --git a/crates/fbuild-cli/src/daemon_client/identity.rs b/crates/fbuild-cli/src/daemon_client/identity.rs index 6fe7c509e..c7adefc81 100644 --- a/crates/fbuild-cli/src/daemon_client/identity.rs +++ b/crates/fbuild-cli/src/daemon_client/identity.rs @@ -148,6 +148,7 @@ mod tests { dependency_install: None, client_count: 0, cache_identity: Some(identity.label_value()), + long_path_support: None, cache_schema_version: Some(fbuild_paths::running_process::CACHE_SCHEMA_VERSION), spawner_cwd: spawner_cwd.map(str::to_string), source_mtime: None, diff --git a/crates/fbuild-cli/src/daemon_client/tests.rs b/crates/fbuild-cli/src/daemon_client/tests.rs index f1522422b..2430bc597 100644 --- a/crates/fbuild-cli/src/daemon_client/tests.rs +++ b/crates/fbuild-cli/src/daemon_client/tests.rs @@ -74,6 +74,7 @@ fn daemon_info_for_cache_identity( dependency_install: None, client_count: 0, cache_identity, + long_path_support: None, cache_schema_version, spawner_cwd: None, source_mtime: None, diff --git a/crates/fbuild-cli/src/daemon_client/types.rs b/crates/fbuild-cli/src/daemon_client/types.rs index ae6864847..966efd7ea 100644 --- a/crates/fbuild-cli/src/daemon_client/types.rs +++ b/crates/fbuild-cli/src/daemon_client/types.rs @@ -4,6 +4,9 @@ use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize)] pub struct BuildRequest { + /// Selected by this invocation so a persistent daemon cannot reuse an old override. + #[serde(skip_serializing_if = "Option::is_none")] + pub build_dir_override: Option, pub project_dir: String, #[serde(skip_serializing_if = "Option::is_none")] pub environment: Option, @@ -78,6 +81,8 @@ pub struct InstallDepsRequest { #[derive(Clone, Debug, Serialize)] pub struct DeployRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub build_dir_override: Option, pub project_dir: String, #[serde(skip_serializing_if = "Option::is_none")] pub environment: Option, @@ -154,6 +159,8 @@ fn is_default_usb_recovery_policy(policy: &fbuild_core::usb::UsbRecoveryPolicy) #[derive(Debug, Serialize)] pub struct TestEmuRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub build_dir_override: Option, pub project_dir: String, #[serde(skip_serializing_if = "Option::is_none")] pub environment: Option, @@ -239,6 +246,9 @@ pub struct DaemonInfoResponse { #[serde(default)] pub client_count: usize, pub cache_identity: Option, + /// Registry observation captured before this daemon initialized caches. + #[serde(default)] + pub long_path_support: Option, #[serde(default)] pub cache_schema_version: Option, #[serde(default)] diff --git a/crates/fbuild-cli/src/main.rs b/crates/fbuild-cli/src/main.rs index 5c4eb41c0..0d90b6de4 100644 --- a/crates/fbuild-cli/src/main.rs +++ b/crates/fbuild-cli/src/main.rs @@ -27,7 +27,12 @@ fn async_main_entry() { // daemon β€” inherits it instead of re-hashing per invocation. Official // (non-dev) invocations export nothing. A hash failure is reported and // otherwise ignored: dev builds must keep working. - match fbuild_paths::dev_daemon_namespace::namespace_to_export() { + let repair_only = std::env::args().any(|arg| arg == "--fix-win" || arg == "--__fix-win-helper"); + match if repair_only { + Ok(None) + } else { + fbuild_paths::dev_daemon_namespace::namespace_to_export() + } { Ok(Some(namespace)) => unsafe { // SAFETY: single-threaded startup β€” before the tokio runtime or // any environment reader exists. diff --git a/crates/fbuild-cli/src/mcp/tools.rs b/crates/fbuild-cli/src/mcp/tools.rs index db2016d46..06d63d8c4 100644 --- a/crates/fbuild-cli/src/mcp/tools.rs +++ b/crates/fbuild-cli/src/mcp/tools.rs @@ -100,6 +100,7 @@ pub(super) async fn execute_tool( let (caller_pid, caller_cwd) = crate::daemon_client::caller_info(); let req = crate::daemon_client::BuildRequest { + build_dir_override: crate::daemon_client::capture_build_root(&project_dir), project_dir, environment, clean_build: clean, @@ -160,6 +161,7 @@ pub(super) async fn execute_tool( let (caller_pid, caller_cwd) = crate::daemon_client::caller_info(); let req = crate::daemon_client::DeployRequest { + build_dir_override: crate::daemon_client::capture_build_root(&project_dir), project_dir, environment, port, diff --git a/crates/fbuild-cli/src/output.rs b/crates/fbuild-cli/src/output.rs index 7cc4ed5b6..3488731b9 100644 --- a/crates/fbuild-cli/src/output.rs +++ b/crates/fbuild-cli/src/output.rs @@ -17,13 +17,50 @@ //! | `error` | `tracing::error!` | Fatal errors | //! | `debug` | `tracing::debug!` | `--verbose` / `RUST_LOG=debug` | //! -//! Everything except `result` and `diagnostic` is routed through `tracing` so -//! `--color={auto,always,never}`, `--quiet`, and `--verbose` flow through the -//! level filter in one place. Final results and daemon diagnostics use stdout -//! and stderr directly so actionable operation output survives redirection, -//! piping, and `--quiet`. +//! Progress follows tracing filters. Final results, daemon diagnostics, and +//! actionable warnings use stdout/stderr directly so operation output survives +//! redirection and filtering. Visible warnings use yellow only on a terminal +//! without `NO_COLOR`; JSON stdout remains unchanged. use std::fmt::Display; +use std::io::{IsTerminal, Write}; +use std::sync::{Mutex, OnceLock}; + +fn render_warning(message: &str, color: bool) -> String { + let message = if message.starts_with("WARNING:") { + message.to_string() + } else { + format!("WARNING: {message}") + }; + if color { + format!("\x1b[33m{message}\x1b[0m") + } else { + message + } +} + +/// Actionable warnings must survive tracing filters and never pollute JSON stdout. +pub fn visible_warning(message: impl Display) { + static SHOWN: OnceLock>> = OnceLock::new(); + let message = render_warning(&message.to_string(), false); + if let Ok(mut shown) = SHOWN.get_or_init(Mutex::default).lock() { + if !shown.insert(message.clone()) { + return; + } + } + let color = std::io::stderr().is_terminal() && std::env::var_os("NO_COLOR").is_none(); + eprintln!("{}", render_warning(&message, color)); +} + +/// Explicit consent prompt on stderr; callers establish interactivity first. +pub fn confirmation_prompt(message: &str) -> std::io::Result { + let mut stderr = std::io::stderr().lock(); + write!(stderr, "{message} [y/N] ")?; + stderr.flush()?; + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + Ok(crate::cli::windows_long_paths::consented(&answer)) +} /// In-progress narration. Emits at `INFO` so a default-verbosity user /// sees it; `--quiet` hides it. Examples: "Building env esp32dev…", @@ -36,14 +73,31 @@ pub fn progress(msg: impl Display) { /// pipe redirection (e.g. `fbuild show fqbn esp32dev | xargs ...`). /// Use sparingly β€” most output is `progress`, not `result`. pub fn result(msg: impl Display) { - println!("{msg}"); + let message = msg.to_string(); + if message.lines().any(|line| line.starts_with("WARNING:")) { + for line in message.split('\n') { + if line.starts_with("WARNING:") { + visible_warning(line); + } else { + println!("{line}"); + } + } + } else { + println!("{message}"); + } } /// Final operation diagnostics returned by the daemon. Unlike transient /// tracing warnings, these must remain visible with the default filter, /// `--quiet`, and when stderr is redirected by automation. pub fn diagnostic(msg: impl Display) { - eprintln!("{msg}"); + for line in msg.to_string().lines() { + if line.starts_with("WARNING:") { + visible_warning(line); + } else { + eprintln!("{line}"); + } + } } /// Non-fatal warning. Emits at `WARN`. Use for situations that don't @@ -64,3 +118,26 @@ pub fn error(msg: impl Display) { pub fn debug(msg: impl Display) { tracing::debug!("{msg}"); } + +#[cfg(test)] +mod warning_tests { + use super::*; + + #[test] + fn warning_is_yellow_only_when_color_is_allowed() { + let yellow = render_warning("Using short-path fallback", true); + assert!(yellow.contains("\x1b[33m")); + assert!(yellow.contains("WARNING: Using short-path fallback")); + let plain = render_warning("Using short-path fallback", false); + assert_eq!(plain, "WARNING: Using short-path fallback"); + assert!(!plain.contains('\x1b')); + } + + #[test] + fn warning_prefix_is_not_duplicated() { + assert_eq!( + render_warning("WARNING: existing", false), + "WARNING: existing" + ); + } +} diff --git a/crates/fbuild-cli/tests/it/main.rs b/crates/fbuild-cli/tests/it/main.rs index 511f5598e..30ae659cc 100644 --- a/crates/fbuild-cli/tests/it/main.rs +++ b/crates/fbuild-cli/tests/it/main.rs @@ -6,3 +6,4 @@ mod ci_command; mod daemon_crash_recovery; mod lib_select; mod test_emu_exit_code; +mod windows_long_paths; diff --git a/crates/fbuild-cli/tests/it/windows_long_paths.rs b/crates/fbuild-cli/tests/it/windows_long_paths.rs new file mode 100644 index 000000000..ba6aef260 --- /dev/null +++ b/crates/fbuild-cli/tests/it/windows_long_paths.rs @@ -0,0 +1,41 @@ +//! Public Windows repair entrypoint: discoverable and isolated from builds. + +use std::process::Command; + +#[test] +fn help_documents_fix_win_without_starting_a_daemon() { + // allow-direct-spawn: compiled CLI acceptance test. + let output = Command::new(env!("CARGO_BIN_EXE_fbuild")) + .arg("--help") + .env("CI", "true") + .output() + .expect("run help"); + assert!(output.status.success()); + let help = String::from_utf8_lossy(&output.stdout); + assert!(help.contains("--fix-win"), "missing repair flag: {help}"); + assert!( + help.contains("long paths"), + "missing repair purpose: {help}" + ); +} + +#[test] +fn fix_win_on_non_windows_exits_without_building_or_prompting() { + if fbuild_core::platform::host::is_windows() { + return; + } + let root = tempfile::tempdir().expect("isolated home"); + // allow-direct-spawn: compiled CLI acceptance test. + let output = Command::new(env!("CARGO_BIN_EXE_fbuild")) + .args(["--fix-win", "--no-update-check"]) + .current_dir(root.path()) + .env("HOME", root.path()) + .env("CI", "true") + .output() + .expect("run Windows repair flag"); + assert!(!output.status.success()); + let diagnostic = String::from_utf8_lossy(&output.stderr); + assert!(diagnostic.contains("Windows"), "wrong error: {diagnostic}"); + assert!(!root.path().join(".fbuild").exists()); + assert!(output.stdout.is_empty(), "repair emitted build output"); +} diff --git a/crates/fbuild-core/Cargo.toml b/crates/fbuild-core/Cargo.toml index d9e551b95..ab2ec619e 100644 --- a/crates/fbuild-core/Cargo.toml +++ b/crates/fbuild-core/Cargo.toml @@ -70,6 +70,7 @@ windows-sys = { version = "0.52", features = [ "Win32_Devices_Usb", "Win32_Foundation", "Win32_Security", + "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_IO", "Win32_System_Registry", diff --git a/crates/fbuild-core/src/platform/README.md b/crates/fbuild-core/src/platform/README.md index c96314132..272a03b1f 100644 --- a/crates/fbuild-core/src/platform/README.md +++ b/crates/fbuild-core/src/platform/README.md @@ -18,6 +18,21 @@ destination opening, atomic replacement, and blocked-I/O retirement. Cache sizing, archive traversal, authorization, locking, diagnostics, and retry policy remain with their product owners. +Windows long-path registry mechanics also live behind `fs`: `long_path_support` +reads the system policy, `enable_long_paths` writes and verifies only that +policy, `legacy_path_units` measures native path units, and `short_cache_base` +supplies a local drive candidate without creating it. Interactive repair, +UAC authorization, short-cache selection, warnings, and daemon restarts belong +to callers. Non-Windows hosts report `NotApplicable`; repair and short-cache +base selection report `Unsupported`. + +`ensure_private_directory` establishes the automatic short-cache hierarchy. +On Windows it verifies current-user ownership, rejects reparse points, creates +directories with protected user-only permissions, and holds ancestor handles +while traversing. The volume root is validated without changing its ACL. +Callers retain layout policy and must not use this operation to claim foreign +or explicitly configured cache directories. + `ipc` owns fbuild local-endpoint bind/connect/accept and peer facts, owner-only Unix endpoint creation, TCP listener socket policy, and endpoint readiness probing. Broker framing/routing, daemon retry/yield policy, and HTTP/protobuf diff --git a/crates/fbuild-core/src/platform/fs.rs b/crates/fbuild-core/src/platform/fs.rs index 87d22e61d..dd6b3393f 100644 --- a/crates/fbuild-core/src/platform/fs.rs +++ b/crates/fbuild-core/src/platform/fs.rs @@ -1,7 +1,7 @@ //! Neutral filesystem identity, permission, and replacement APIs. use std::fs::{File, OpenOptions}; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::path::NormalizedPath; @@ -36,6 +36,57 @@ pub enum ErrorClass { Other, } +/// Current host policy for ordinary Windows paths longer than `MAX_PATH`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LongPathSupport { + /// This host does not use the Windows long-path registry policy. + NotApplicable, + Enabled, + Disabled, +} + +/// Read the current long-path policy without changing system settings. +/// +/// Missing Windows values are disabled; malformed values and read failures +/// remain errors so callers can explain why the policy could not be checked. +pub fn long_path_support() -> std::io::Result { + super::selected::fs::long_path_support() +} + +/// Enable and verify the Windows long-path registry policy. +/// +/// The caller owns authorization, elevation, and restarting processes that +/// cached the previous policy. Other hosts return `Unsupported`. +pub fn enable_long_paths() -> std::io::Result<()> { + super::selected::fs::enable_long_paths() +} + +/// Measure native path units, excluding a trailing NUL terminator. +/// +/// Windows counts UTF-16 code units; Unix hosts count native pathname bytes. +#[must_use] +pub fn legacy_path_units(path: &Path) -> usize { + super::selected::fs::legacy_path_units(path) +} + +/// Return a short absolute local-drive root without creating directories. +/// +/// User isolation, directory creation, permissions, and cache policy belong +/// to callers. Other hosts return `Unsupported`. +pub fn short_cache_base() -> std::io::Result { + super::selected::fs::short_cache_base() +} + +/// Establish an automatic cache directory owned and writable only by this user. +/// +/// Windows requires a direct local-drive child or its descendants, rejects +/// reparse points, verifies ownership before changing permissions, and protects +/// every non-root ancestor. The drive root itself is never changed. Callers +/// must not pass arbitrary configured roots under other users' directories. +pub fn ensure_private_directory(path: &Path) -> std::io::Result<()> { + super::selected::fs::ensure_private_directory(path) +} + /// Open a path identity that compares by underlying filesystem object. pub fn file_identity(path: &Path) -> std::io::Result { super::selected::fs::file_identity(path).map(FileIdentity) @@ -163,6 +214,68 @@ pub fn cancel_blocking_io(worker: &std::thread::JoinHandle<()>) -> std::io::Resu mod tests { use super::*; + #[test] + fn private_directory_creation_is_repeatable_and_preserves_existing_files() { + if crate::platform::host::is_windows() { + return; // Native Windows fixture uses a direct drive child. + } + let temp = tempfile::tempdir().unwrap(); + let private = temp.path().join("private"); + ensure_private_directory(&private).unwrap(); + ensure_private_directory(&private).unwrap(); + assert!(private.is_dir()); + let file = temp.path().join("file"); + std::fs::write(&file, b"untouched").unwrap(); + assert!(ensure_private_directory(&file).is_err()); + assert_eq!(std::fs::read(file).unwrap(), b"untouched"); + } + + #[test] + fn private_directory_rejects_symlinks_without_modifying_the_target() { + if crate::platform::host::is_windows() { + return; // Windows symlink creation requires external privileges. + } + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("target"); + std::fs::create_dir(&target).unwrap(); + std::fs::write(target.join("sentinel"), b"untouched").unwrap(); + let link = temp.path().join("link"); + symlink_dir(&target, &link).unwrap(); + assert!(ensure_private_directory(&link).is_err()); + assert_eq!( + std::fs::read(target.join("sentinel")).unwrap(), + b"untouched" + ); + } + + #[test] + fn long_path_policy_is_not_applicable_on_non_windows_hosts() { + if crate::platform::host::is_windows() { + return; + } + assert_eq!(long_path_support().unwrap(), LongPathSupport::NotApplicable); + assert_eq!( + enable_long_paths().unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + assert_eq!( + short_cache_base().unwrap_err().kind(), + std::io::ErrorKind::Unsupported + ); + } + + #[test] + fn legacy_path_units_measure_native_units_without_nul_terminator() { + let path = Path::new("a/πŸ˜€/Γ©"); + let expected = if crate::platform::host::is_windows() { + 6 + } else { + 9 + }; + assert_eq!(legacy_path_units(path), expected); + assert_eq!(legacy_path_units(Path::new("")), 0); + } + #[test] fn selected_path_rules_match_the_current_host() { let mixed = Path::new(r"Case\Mixed"); diff --git a/crates/fbuild-core/src/platform/linux/fs.rs b/crates/fbuild-core/src/platform/linux/fs.rs index 912184b12..a26b5ee96 100644 --- a/crates/fbuild-core/src/platform/linux/fs.rs +++ b/crates/fbuild-core/src/platform/linux/fs.rs @@ -1,9 +1,64 @@ use std::fs::OpenOptions; use std::os::unix::fs::PermissionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::platform::fs::{ErrorClass, VolumeFacts}; use crate::path::NormalizedPath; +use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts}; + +pub(crate) fn long_path_support() -> std::io::Result { + Ok(LongPathSupport::NotApplicable) +} + +pub(crate) fn enable_long_paths() -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows long-path repair is unavailable on this host", + )) +} + +pub(crate) fn legacy_path_units(path: &Path) -> usize { + path.as_os_str().as_encoded_bytes().len() +} + +pub(crate) fn short_cache_base() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows short-cache drive selection is unavailable on this host", + )) +} + +pub(crate) fn ensure_private_directory(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; + + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private directory must not be a symlink or file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + } + Err(error) => return Err(error), + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(path)?; + // SAFETY: geteuid has no pointer arguments or preconditions. + if directory.metadata()?.uid() != unsafe { libc::geteuid() } { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private directory belongs to another user", + )); + } + directory.set_permissions(std::fs::Permissions::from_mode(0o700)) +} pub(crate) fn file_identity(path: &Path) -> std::io::Result { same_file::Handle::from_path(path) @@ -95,9 +150,7 @@ pub(crate) fn classify_error(error: &std::io::Error) -> ErrorClass { } } -pub(crate) fn cancel_blocking_io( - _worker: &std::thread::JoinHandle<()>, -) -> std::io::Result { +pub(crate) fn cancel_blocking_io(_worker: &std::thread::JoinHandle<()>) -> std::io::Result { Ok(false) } @@ -124,7 +177,9 @@ fn volume_facts_from_statvfs(path: &Path) -> std::io::Result { } fn byte_count(blocks: u128, fragment_size: u128) -> u64 { - blocks.saturating_mul(fragment_size).min(u128::from(u64::MAX)) as u64 + blocks + .saturating_mul(fragment_size) + .min(u128::from(u64::MAX)) as u64 } #[cfg(test)] @@ -136,6 +191,9 @@ mod tests { let temp = tempfile::NamedTempFile::new().unwrap(); std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600)).unwrap(); set_executable(temp.path()).unwrap(); - assert_eq!(std::fs::metadata(temp.path()).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!( + std::fs::metadata(temp.path()).unwrap().permissions().mode() & 0o777, + 0o700 + ); } } diff --git a/crates/fbuild-core/src/platform/macos/fs.rs b/crates/fbuild-core/src/platform/macos/fs.rs index 0f870ed41..b7a9209af 100644 --- a/crates/fbuild-core/src/platform/macos/fs.rs +++ b/crates/fbuild-core/src/platform/macos/fs.rs @@ -1,9 +1,64 @@ use std::fs::OpenOptions; use std::os::unix::fs::PermissionsExt; -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::platform::fs::{ErrorClass, VolumeFacts}; use crate::path::NormalizedPath; +use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts}; + +pub(crate) fn long_path_support() -> std::io::Result { + Ok(LongPathSupport::NotApplicable) +} + +pub(crate) fn enable_long_paths() -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows long-path repair is unavailable on this host", + )) +} + +pub(crate) fn legacy_path_units(path: &Path) -> usize { + path.as_os_str().as_encoded_bytes().len() +} + +pub(crate) fn short_cache_base() -> std::io::Result { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "Windows short-cache drive selection is unavailable on this host", + )) +} + +pub(crate) fn ensure_private_directory(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::{DirBuilderExt, MetadataExt, OpenOptionsExt}; + + match std::fs::symlink_metadata(path) { + Ok(metadata) if !metadata.is_dir() || metadata.file_type().is_symlink() => { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private directory must not be a symlink or file", + )); + } + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(path)?; + } + Err(error) => return Err(error), + } + let directory = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_DIRECTORY) + .open(path)?; + // SAFETY: geteuid has no pointer arguments or preconditions. + if directory.metadata()?.uid() != unsafe { libc::geteuid() } { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private directory belongs to another user", + )); + } + directory.set_permissions(std::fs::Permissions::from_mode(0o700)) +} pub(crate) fn file_identity(path: &Path) -> std::io::Result { same_file::Handle::from_path(path) @@ -95,9 +150,7 @@ pub(crate) fn classify_error(error: &std::io::Error) -> ErrorClass { } } -pub(crate) fn cancel_blocking_io( - _worker: &std::thread::JoinHandle<()>, -) -> std::io::Result { +pub(crate) fn cancel_blocking_io(_worker: &std::thread::JoinHandle<()>) -> std::io::Result { Ok(false) } @@ -124,7 +177,9 @@ fn volume_facts_from_statvfs(path: &Path) -> std::io::Result { } fn byte_count(blocks: u128, fragment_size: u128) -> u64 { - blocks.saturating_mul(fragment_size).min(u128::from(u64::MAX)) as u64 + blocks + .saturating_mul(fragment_size) + .min(u128::from(u64::MAX)) as u64 } #[cfg(test)] @@ -136,6 +191,9 @@ mod tests { let temp = tempfile::NamedTempFile::new().unwrap(); std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600)).unwrap(); set_executable(temp.path()).unwrap(); - assert_eq!(std::fs::metadata(temp.path()).unwrap().permissions().mode() & 0o777, 0o700); + assert_eq!( + std::fs::metadata(temp.path()).unwrap().permissions().mode() & 0o777, + 0o700 + ); } } diff --git a/crates/fbuild-core/src/platform/windows/README.md b/crates/fbuild-core/src/platform/windows/README.md index 883612681..90c95ad70 100644 --- a/crates/fbuild-core/src/platform/windows/README.md +++ b/crates/fbuild-core/src/platform/windows/README.md @@ -2,3 +2,28 @@ Private Windows implementation details for `fbuild_core::platform`. Native types must not escape through the neutral facade. + +The filesystem implementation reads the fixed +`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` value. +Only a four-byte `REG_DWORD` equal to `1` is enabled. Missing values are +disabled; malformed values and native read failures remain errors. Repair +writes only that value and reads it back. The caller owns elevation and +restarting processes after repair; this module never requests UAC. + +Legacy path lengths count UTF-16 code units without a trailing NUL. The short +cache base uses a validated local `SystemDrive` root and does not create any +directories or choose user/cache identities. + +Automatic cache directories use a direct drive child and descendants beneath +it. `fs.rs` delegates their native mechanics to `private_directory.rs`. +`ensure_private_directory` installs a protected current-user-only DACL at +creation, verifies existing ownership before replacing the DACL, verifies the +result, and rejects files and reparse points. Existing foreign-owned objects +remain untouched. No-delete-sharing handles keep checked ancestors stable +through traversal; the drive-root ACL is never changed. +Concurrent verification retries only native sharing contention for up to five +seconds, releasing the entire attempt's handles before waiting. + +`long-paths.manifest` is embedded by the CLI and daemon build scripts. It +declares `longPathAware` and `asInvoker`, so enabling the registry setting +does not cause ordinary builds to run elevated. diff --git a/crates/fbuild-core/src/platform/windows/fs.rs b/crates/fbuild-core/src/platform/windows/fs.rs index 3fe10626d..5ca3dcc1b 100644 --- a/crates/fbuild-core/src/platform/windows/fs.rs +++ b/crates/fbuild-core/src/platform/windows/fs.rs @@ -2,10 +2,209 @@ use std::fs::OpenOptions; use std::os::windows::ffi::OsStrExt; use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; use std::os::windows::io::AsRawHandle; -use std::path::Path; +use std::path::{Path, PathBuf}; -use crate::platform::fs::{ErrorClass, VolumeFacts, VolumeKind}; use crate::path::NormalizedPath; +use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts, VolumeKind}; + +/// A handle to exactly the system filesystem policy key, never an arbitrary +/// registry location supplied by a caller. +struct FileSystemPolicyKey(windows_sys::Win32::System::Registry::HKEY); + +impl FileSystemPolicyKey { + fn open(access: u32) -> std::io::Result { + use windows_sys::Win32::System::Registry::{HKEY_LOCAL_MACHINE, RegOpenKeyExW}; + + let path = r"SYSTEM\CurrentControlSet\Control\FileSystem" + .encode_utf16() + .chain(Some(0)) + .collect::>(); + let mut key = 0; + // SAFETY: `path` is a live NUL-terminated UTF-16 buffer and `key` is + // writable storage. This opens only the fixed HKLM filesystem key. + let status = + unsafe { RegOpenKeyExW(HKEY_LOCAL_MACHINE, path.as_ptr(), 0, access, &mut key) }; + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + Ok(Self(key)) + } +} + +impl Drop for FileSystemPolicyKey { + fn drop(&mut self) { + // SAFETY: the handle belongs to this object after a successful open, + // and it is closed exactly once without escaping the selected module. + unsafe { windows_sys::Win32::System::Registry::RegCloseKey(self.0) }; + } +} + +fn long_path_value_name() -> Vec { + "LongPathsEnabled".encode_utf16().chain(Some(0)).collect() +} + +pub(crate) fn long_path_support() -> std::io::Result { + use windows_sys::Win32::System::Registry::{KEY_QUERY_VALUE, RegQueryValueExW}; + + let key = match FileSystemPolicyKey::open(KEY_QUERY_VALUE) { + Ok(key) => key, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(LongPathSupport::Disabled); + } + Err(error) => return Err(error), + }; + let name = long_path_value_name(); + let mut value = [0_u8; 4]; + let mut value_type = 0; + let mut value_length = value.len() as u32; + // SAFETY: the handle remains open; `name` is NUL-terminated; the writable + // output pointers remain live and `value_length` advertises buffer size. + let status = unsafe { + RegQueryValueExW( + key.0, + name.as_ptr(), + std::ptr::null(), + &mut value_type, + value.as_mut_ptr(), + &mut value_length, + ) + }; + decode_long_path_value(status, value_type, &value, value_length) +} + +fn decode_long_path_value( + status: u32, + value_type: u32, + value: &[u8], + reported_length: u32, +) -> std::io::Result { + use windows_sys::Win32::Foundation::{ERROR_FILE_NOT_FOUND, ERROR_MORE_DATA}; + use windows_sys::Win32::System::Registry::REG_DWORD; + + if status == ERROR_FILE_NOT_FOUND { + return Ok(LongPathSupport::Disabled); + } + if status == ERROR_MORE_DATA { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LongPathsEnabled exceeds the size of a REG_DWORD", + )); + } + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + if value_type != REG_DWORD || reported_length != 4 { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LongPathsEnabled must be a four-byte REG_DWORD", + )); + } + let value: [u8; 4] = value.try_into().map_err(|_| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LongPathsEnabled returned incomplete DWORD data", + ) + })?; + Ok(if u32::from_le_bytes(value) == 1 { + LongPathSupport::Enabled + } else { + LongPathSupport::Disabled + }) +} + +pub(crate) fn enable_long_paths() -> std::io::Result<()> { + use windows_sys::Win32::System::Registry::{KEY_SET_VALUE, REG_DWORD, RegSetValueExW}; + + let key = FileSystemPolicyKey::open(KEY_SET_VALUE)?; + let name = long_path_value_name(); + let value = 1_u32.to_le_bytes(); + // SAFETY: `name` is NUL-terminated; `value` is a live four-byte DWORD; + // the handle permits setting values on only the fixed filesystem key. + let status = unsafe { + RegSetValueExW( + key.0, + name.as_ptr(), + 0, + REG_DWORD, + value.as_ptr(), + value.len() as u32, + ) + }; + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + drop(key); + verify_long_paths_enabled(long_path_support()?) +} + +fn verify_long_paths_enabled(support: LongPathSupport) -> std::io::Result<()> { + if support == LongPathSupport::Enabled { + Ok(()) + } else { + Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "LongPathsEnabled was not enabled after repair", + )) + } +} + +pub(crate) fn legacy_path_units(path: &Path) -> usize { + path.as_os_str().encode_wide().count() +} + +pub(crate) fn short_cache_base() -> std::io::Result { + use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; + + let drive = std::env::var_os("SystemDrive").ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + "SystemDrive is unavailable; cannot select a short Windows cache root", + ) + })?; + let base = short_cache_base_from_drive(&drive)?; + let root = base + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: `root` is a live NUL-terminated absolute drive-root buffer. + let drive_type = unsafe { GetDriveTypeW(root.as_ptr()) }; + // Fixed drives (3) keep the automatic cache on a stable local volume. + if drive_type != 3 { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "SystemDrive does not refer to an available fixed local cache drive", + )); + } + Ok(base) +} + +fn short_cache_base_from_drive(drive: &std::ffi::OsStr) -> std::io::Result { + let value = drive.to_str().ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "SystemDrive is not a local drive letter", + ) + })?; + let bytes = value.as_bytes(); + if !(bytes.len() == 2 || bytes.len() == 3 && matches!(bytes[2], b'\\' | b'/')) + || !bytes[0].is_ascii_alphabetic() + || bytes[1] != b':' + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "SystemDrive must be a local drive root such as C:", + )); + } + Ok(PathBuf::from(format!( + "{}:\\", + char::from(bytes[0].to_ascii_uppercase()) + ))) +} + +pub(crate) fn ensure_private_directory(path: &Path) -> std::io::Result<()> { + super::private_directory::ensure_private_directory(path) +} pub(crate) fn file_identity(path: &Path) -> std::io::Result { same_file::Handle::from_path(path) @@ -43,9 +242,10 @@ pub(crate) fn strip_extended_prefix(path: &Path) -> Box { let stripped = format!(r"\\{rest}"); return Path::new(&stripped).into(); } - value - .strip_prefix(r"\\?\") - .map_or_else(|| Path::new(value.as_ref()).into(), |rest| Path::new(rest).into()) + value.strip_prefix(r"\\?\").map_or_else( + || Path::new(value.as_ref()).into(), + |rest| Path::new(rest).into(), + ) } pub(crate) fn set_executable(_path: &Path) -> std::io::Result<()> { @@ -89,7 +289,9 @@ pub(crate) fn rename_path(source: &Path, destination: &Path) -> std::io::Result< } pub(crate) fn volume_facts(path: &Path) -> std::io::Result { - use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetVolumeInformationW, GetVolumePathNameW}; + use windows_sys::Win32::Storage::FileSystem::{ + GetDriveTypeW, GetVolumeInformationW, GetVolumePathNameW, + }; const DRIVE_REMOVABLE: u32 = 2; const FILE_READ_ONLY_VOLUME: u32 = 0x0008_0000; @@ -107,7 +309,10 @@ pub(crate) fn volume_facts(path: &Path) -> std::io::Result { { return Err(std::io::Error::last_os_error()); } - let root_len = root.iter().position(|unit| *unit == 0).unwrap_or(root.len() - 1); + let root_len = root + .iter() + .position(|unit| *unit == 0) + .unwrap_or(root.len() - 1); root.truncate(root_len + 1); let mut flags = 0_u32; @@ -185,9 +390,7 @@ pub(crate) fn classify_error(error: &std::io::Error) -> ErrorClass { } } -pub(crate) fn cancel_blocking_io( - worker: &std::thread::JoinHandle<()>, -) -> std::io::Result { +pub(crate) fn cancel_blocking_io(worker: &std::thread::JoinHandle<()>) -> std::io::Result { use windows_sys::Win32::System::IO::CancelSynchronousIo; // SAFETY: `as_raw_handle` is valid for the lifetime of `worker`; Windows @@ -199,6 +402,98 @@ pub(crate) fn cancel_blocking_io( mod tests { use super::*; + #[cfg(windows)] + #[test] + fn native_long_path_registry_query_is_read_only() { + let support = long_path_support().expect("read system filesystem policy"); + assert!(matches!( + support, + LongPathSupport::Enabled | LongPathSupport::Disabled + )); + } + + #[test] + fn long_path_registry_missing_and_disabled_values_are_disabled() { + use windows_sys::Win32::Foundation::ERROR_FILE_NOT_FOUND; + use windows_sys::Win32::System::Registry::REG_DWORD; + + assert_eq!( + decode_long_path_value(ERROR_FILE_NOT_FOUND, 0, &[], 0).unwrap(), + LongPathSupport::Disabled + ); + for value in [0_u32, 2, u32::MAX] { + assert_eq!( + decode_long_path_value(0, REG_DWORD, &value.to_le_bytes(), 4).unwrap(), + LongPathSupport::Disabled + ); + } + assert_eq!( + decode_long_path_value(0, REG_DWORD, &1_u32.to_le_bytes(), 4).unwrap(), + LongPathSupport::Enabled + ); + } + + #[test] + fn malformed_long_path_registry_values_report_invalid_data() { + use windows_sys::Win32::Foundation::ERROR_MORE_DATA; + use windows_sys::Win32::System::Registry::{REG_DWORD, REG_SZ}; + + for (status, kind, bytes, length) in [ + (0, REG_SZ, &[1_u8, 0, 0, 0][..], 4), + (0, REG_DWORD, &[1_u8, 0][..], 2), + (0, REG_DWORD, &[1_u8, 0, 0, 0][..], 8), + (ERROR_MORE_DATA, REG_DWORD, &[1_u8, 0, 0, 0][..], 8), + ] { + assert_eq!( + decode_long_path_value(status, kind, bytes, length) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidData + ); + } + } + + #[test] + fn long_path_registry_read_errors_preserve_the_native_error() { + use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED; + + let error = decode_long_path_value(ERROR_ACCESS_DENIED, 0, &[], 0).unwrap_err(); + assert_eq!(error.raw_os_error(), Some(ERROR_ACCESS_DENIED as i32)); + } + + #[test] + fn long_path_repair_requires_verified_enabled_state() { + assert!(verify_long_paths_enabled(LongPathSupport::Enabled).is_ok()); + for support in [LongPathSupport::Disabled, LongPathSupport::NotApplicable] { + assert_eq!( + verify_long_paths_enabled(support).unwrap_err().kind(), + std::io::ErrorKind::InvalidData + ); + } + } + + #[test] + fn short_cache_base_accepts_only_a_local_drive_root() { + use std::ffi::OsStr; + + assert_eq!( + short_cache_base_from_drive(OsStr::new("C:")).unwrap(), + Path::new(r"C:\") + ); + assert_eq!( + short_cache_base_from_drive(OsStr::new(r"D:\")).unwrap(), + Path::new(r"D:\") + ); + for drive in ["", "C", r"C:\nested", r"\\server\share", "1:", "C:relative"] { + assert_eq!( + short_cache_base_from_drive(OsStr::new(drive)) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + } + #[test] fn shared_writer_denies_delete_access() { const DELETE_ACCESS: u32 = 0x0001_0000; diff --git a/crates/fbuild-core/src/platform/windows/long-paths.manifest b/crates/fbuild-core/src/platform/windows/long-paths.manifest new file mode 100644 index 000000000..d307c547f --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/long-paths.manifest @@ -0,0 +1,15 @@ + + + + + + + + + + + + true + + + diff --git a/crates/fbuild-core/src/platform/windows/mod.rs b/crates/fbuild-core/src/platform/windows/mod.rs index bbd714bcf..397e59c1a 100644 --- a/crates/fbuild-core/src/platform/windows/mod.rs +++ b/crates/fbuild-core/src/platform/windows/mod.rs @@ -4,6 +4,7 @@ pub(super) mod device; pub(super) mod fs; pub(super) mod host; pub(super) mod ipc; +mod private_directory; pub(super) mod process; pub(super) mod usb_pnp; diff --git a/crates/fbuild-core/src/platform/windows/private_directory.rs b/crates/fbuild-core/src/platform/windows/private_directory.rs new file mode 100644 index 000000000..cd7c662fe --- /dev/null +++ b/crates/fbuild-core/src/platform/windows/private_directory.rs @@ -0,0 +1,627 @@ +//! Native ownership and protected permissions for automatic cache directories. + +use std::os::windows::ffi::OsStrExt; +use std::path::{Path, PathBuf}; + +struct NativeHandle(windows_sys::Win32::Foundation::HANDLE); + +impl Drop for NativeHandle { + fn drop(&mut self) { + // SAFETY: this object owns a successfully opened native handle. + unsafe { windows_sys::Win32::Foundation::CloseHandle(self.0) }; + } +} + +struct LocalAllocation(*mut std::ffi::c_void); + +impl Drop for LocalAllocation { + fn drop(&mut self) { + // SAFETY: security APIs return LocalAlloc-owned storage; null is + // accepted by LocalFree and successful allocations are freed once. + unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) }; + } +} + +struct PrivateDirectorySecurity { + // Keeps the current-user SID pointer live through every owner comparison. + _token_user: Vec, + sid: windows_sys::Win32::Foundation::PSID, + descriptor: LocalAllocation, + acl: *mut windows_sys::Win32::Security::ACL, +} + +fn current_token_user() -> std::io::Result> { + use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER; + use windows_sys::Win32::Security::{GetTokenInformation, TOKEN_QUERY, TokenUser}; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + + let mut token = 0; + // SAFETY: current-process pseudo-handle is valid and `token` is writable. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let token = NativeHandle(token); + let mut length = 0; + // SAFETY: null plus zero length is the documented size query. + let result = + unsafe { GetTokenInformation(token.0, TokenUser, std::ptr::null_mut(), 0, &mut length) }; + if result != 0 + || std::io::Error::last_os_error().raw_os_error() != Some(ERROR_INSUFFICIENT_BUFFER as i32) + { + return Err(std::io::Error::last_os_error()); + } + if (length as usize) < std::mem::size_of::() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "Windows returned an incomplete token user", + )); + } + // usize storage supplies TOKEN_USER's native pointer alignment, including + // the SID bytes appended by GetTokenInformation. + let mut user = vec![0_usize; (length as usize).div_ceil(std::mem::size_of::())]; + // SAFETY: aligned storage advertises at least `length` writable bytes. + if unsafe { + GetTokenInformation( + token.0, + TokenUser, + user.as_mut_ptr().cast(), + length, + &mut length, + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + Ok(user) +} + +fn private_directory_security() -> std::io::Result { + use windows_sys::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, + SDDL_REVISION_1, + }; + use windows_sys::Win32::Security::{GetSecurityDescriptorDacl, TOKEN_USER}; + + let user = current_token_user()?; + // SAFETY: current_token_user returned an aligned, initialized TOKEN_USER. + let sid = unsafe { (*user.as_ptr().cast::()).User.Sid }; + let mut sid_text = std::ptr::null_mut(); + // SAFETY: SID remains live in `user`; output points to owned allocation. + if unsafe { ConvertSidToStringSidW(sid, &mut sid_text) } == 0 { + return Err(std::io::Error::last_os_error()); + } + let allocated_sid = LocalAllocation(sid_text.cast()); + // SAFETY: ConvertSidToStringSidW returns a NUL-terminated UTF-16 string + // that remains live until `allocated_sid` is dropped. + let sid_text = unsafe { widestring::U16CStr::from_ptr_str(sid_text) } + .to_string() + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let descriptor_text = format!("O:{sid_text}D:P(A;OICI;FA;;;{sid_text})") + .encode_utf16() + .chain(Some(0)) + .collect::>(); + let mut descriptor = std::ptr::null_mut(); + // SAFETY: descriptor text is NUL-terminated and output is writable. + if unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + descriptor_text.as_ptr(), + SDDL_REVISION_1, + &mut descriptor, + std::ptr::null_mut(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + let descriptor = LocalAllocation(descriptor); + let mut present = 0; + let mut defaulted = 0; + let mut acl = std::ptr::null_mut(); + // SAFETY: initialized descriptor is live; all output pointers are writable. + if unsafe { GetSecurityDescriptorDacl(descriptor.0, &mut present, &mut acl, &mut defaulted) } + == 0 + || present == 0 + || acl.is_null() + { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "private directory security descriptor has no DACL", + )); + } + drop(allocated_sid); + Ok(PrivateDirectorySecurity { + _token_user: user, + sid, + descriptor, + acl, + }) +} + +fn private_directory_components(path: &Path) -> std::io::Result> { + use std::path::{Component, Prefix}; + + let invalid = || { + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "automatic private cache must be a local drive child or its descendants", + ) + }; + if path.as_os_str().encode_wide().any(|unit| unit == 0) { + return Err(invalid()); + } + let mut components = path.components(); + let Some(Component::Prefix(prefix)) = components.next() else { + return Err(invalid()); + }; + let Prefix::Disk(drive) = prefix.kind() else { + return Err(invalid()); + }; + if !matches!(components.next(), Some(Component::RootDir)) { + return Err(invalid()); + } + let mut current = PathBuf::from(format!("{}:\\", char::from(drive))); + let mut paths = vec![current.clone()]; + for component in components { + let Component::Normal(name) = component else { + return Err(invalid()); + }; + current.push(name); + paths.push(current.clone()); + } + if paths.len() < 2 { + return Err(invalid()); + } + Ok(paths) +} + +fn open_private_directory(path: &Path, writable_dacl: bool) -> std::io::Result { + use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_SHARE_READ, + FILE_SHARE_WRITE, OPEN_EXISTING, READ_CONTROL, + }; + + let path = path + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SetSecurityInfo documents that MAXIMUM_ALLOWED handles suppress ACE + // propagation to existing children. Secure each ownership-verified path + // individually so an untargeted/foreign child is never changed as a side + // effect of protecting its parent. Current owners receive WRITE_DAC. + // https://learn.microsoft.com/en-us/windows/win32/api/aclapi/nf-aclapi-setsecurityinfo + const MAXIMUM_ALLOWED: u32 = 0x0200_0000; + let access = if writable_dacl { + MAXIMUM_ALLOWED + } else { + READ_CONTROL + }; + // SAFETY: path is NUL-terminated. No delete sharing keeps this directory + // from being replaced while child traversal and ACL verification run. + let handle = unsafe { + CreateFileW( + path.as_ptr(), + access, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null(), + OPEN_EXISTING, + FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, + 0, + ) + }; + if handle == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + let handle = NativeHandle(handle); + let mut info = std::mem::MaybeUninit::uninit(); + // SAFETY: handle is live and info points to writable native output storage. + if unsafe { + windows_sys::Win32::Storage::FileSystem::GetFileInformationByHandle( + handle.0, + info.as_mut_ptr(), + ) + } == 0 + { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: successful GetFileInformationByHandle initialized `info`. + let info = unsafe { info.assume_init() }; + use windows_sys::Win32::Storage::FileSystem::{ + FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_REPARSE_POINT, + }; + if info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY == 0 + || info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT != 0 + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "private cache directory must not be a file, junction, or reparse point", + )); + } + Ok(handle) +} + +fn verify_private_directory_owner( + handle: &NativeHandle, + security: &PrivateDirectorySecurity, +) -> std::io::Result<()> { + use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::{EqualSid, OWNER_SECURITY_INFORMATION}; + + let mut owner = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + // SAFETY: handle remains open; all non-null output pointers are writable. + let status = unsafe { + GetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION, + &mut owner, + std::ptr::null_mut(), + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut descriptor, + ) + }; + let descriptor = LocalAllocation(descriptor); + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + // SAFETY: descriptor keeps owner live and security keeps the token SID live. + if owner.is_null() || unsafe { EqualSid(owner, security.sid) } == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "automatic cache directory belongs to another user; choose another short root", + )); + } + drop(descriptor); + Ok(()) +} + +fn protect_private_directory( + handle: &NativeHandle, + security: &PrivateDirectorySecurity, +) -> std::io::Result<()> { + use windows_sys::Win32::Security::Authorization::{ + GetSecurityInfo, SE_FILE_OBJECT, SetSecurityInfo, + }; + use windows_sys::Win32::Security::{ + DACL_SECURITY_INFORMATION, GetSecurityDescriptorControl, + PROTECTED_DACL_SECURITY_INFORMATION, SE_DACL_PROTECTED, + }; + + verify_private_directory_owner(handle, security)?; + // SAFETY: only the verified current-user-owned directory's DACL changes; + // the descriptor and ACL stay live and ownership is never assigned here. + let status = unsafe { + SetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + security.acl, + std::ptr::null_mut(), + ) + }; + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + let mut acl = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + // SAFETY: handle is live and output pointers are writable. + let status = unsafe { + GetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut descriptor, + ) + }; + let descriptor = LocalAllocation(descriptor); + if status != 0 { + return Err(std::io::Error::from_raw_os_error(status as i32)); + } + let mut control = 0; + let mut revision = 0; + // SAFETY: descriptor is initialized by the successful query above. + if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 + || control & SE_DACL_PROTECTED == 0 + || acl.is_null() + { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "automatic cache DACL was not protected", + )); + } + // SAFETY: Windows returned valid ACLs whose AclSize bounds initialized + // storage. Exact comparison proves no additional users received access. + let actual = + unsafe { std::slice::from_raw_parts(acl.cast::(), usize::from((*acl).AclSize)) }; + let expected = unsafe { + std::slice::from_raw_parts( + security.acl.cast::(), + usize::from((*security.acl).AclSize), + ) + }; + if actual != expected { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "automatic cache DACL does not grant exclusive access to the current user", + )); + } + Ok(()) +} + +pub(crate) fn ensure_private_directory(path: &Path) -> std::io::Result<()> { + use windows_sys::Win32::Foundation::ERROR_SHARING_VIOLATION; + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + // A complete attempt owns its ancestor handles locally. Returning an + // error releases every handle before this loop waits, so concurrent + // initializers never hold competing partial hierarchies while retrying. + match ensure_private_directory_once(path) { + Err(error) + if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION as i32) + && std::time::Instant::now() < deadline => + { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + result => return result, + } + } +} + +fn ensure_private_directory_once(path: &Path) -> std::io::Result<()> { + use windows_sys::Win32::Foundation::ERROR_ALREADY_EXISTS; + use windows_sys::Win32::Security::SECURITY_ATTRIBUTES; + use windows_sys::Win32::Storage::FileSystem::{CreateDirectoryW, GetDriveTypeW}; + + let paths = private_directory_components(path)?; + let root = paths[0] + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: root is a live NUL-terminated local-drive root buffer. + if unsafe { GetDriveTypeW(root.as_ptr()) } != 3 { + return Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "automatic cache root must be an available fixed local drive", + )); + } + let security = private_directory_security()?; + let attributes = SECURITY_ATTRIBUTES { + nLength: std::mem::size_of::() as u32, + lpSecurityDescriptor: security.descriptor.0, + bInheritHandle: 0, + }; + let mut handles = vec![open_private_directory(&paths[0], false)?]; + for directory in &paths[1..] { + let wide = directory + .as_os_str() + .encode_wide() + .chain(Some(0)) + .collect::>(); + // SAFETY: path/descriptor stay live. The protected owner-only DACL is + // installed atomically with creation, before the directory is usable. + if unsafe { CreateDirectoryW(wide.as_ptr(), &attributes) } == 0 { + let error = std::io::Error::last_os_error(); + if error.raw_os_error() != Some(ERROR_ALREADY_EXISTS as i32) { + return Err(error); + } + } + let handle = open_private_directory(directory, true)?; + protect_private_directory(&handle, &security)?; + // Keep every ancestor locked against deletion/replacement until the + // full hierarchy has been verified and protected. + handles.push(handle); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::super::fs::short_cache_base; + use super::*; + + /// Default Windows TempDir ownership can be TokenOwner (a group) rather + /// than TokenUser. Use its random filename, then create our directory with + /// explicit current-user ownership through the API under test. + struct PrivateFixture { + path: PathBuf, + _name: tempfile::NamedTempFile, + } + + impl PrivateFixture { + fn new() -> Self { + let name = tempfile::Builder::new() + .prefix("fb-private-") + .tempfile() + .unwrap(); + let path = short_cache_base() + .unwrap() + .join(name.path().file_name().unwrap()); + ensure_private_directory(&path).unwrap(); + Self { path, _name: name } + } + + fn path(&self) -> &Path { + &self.path + } + } + + impl Drop for PrivateFixture { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.path); + } + } + + #[test] + fn private_directory_rejects_relative_unc_and_drive_roots() { + for path in [r"relative", r"C:relative", r"\\server\share\fb", r"C:\"] { + assert_eq!( + ensure_private_directory(Path::new(path)) + .unwrap_err() + .kind(), + std::io::ErrorKind::InvalidInput + ); + } + } + + #[test] + fn private_directory_is_repeatable_and_denies_anonymous_access() { + use windows_sys::Win32::Security::{ImpersonateAnonymousToken, RevertToSelf}; + use windows_sys::Win32::System::Threading::GetCurrentThread; + + let temp = PrivateFixture::new(); + let path = temp.path().join("private"); + ensure_private_directory(&path).unwrap(); + ensure_private_directory(&path).unwrap(); + std::fs::write(path.join("owned"), b"private").unwrap(); + let denied_path = path.join("unauthorized"); + let error = std::thread::spawn(move || { + // SAFETY: this test thread enters anonymous impersonation and + // always reverts before returning to its caller. + assert_ne!(unsafe { ImpersonateAnonymousToken(GetCurrentThread()) }, 0); + let result = std::fs::write(denied_path, b"not allowed"); + assert_ne!(unsafe { RevertToSelf() }, 0); + result.unwrap_err() + }) + .join() + .unwrap(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + } + + #[test] + fn private_directory_concurrent_initializers_finish_on_the_same_root() { + let temp = PrivateFixture::new(); + let path = temp.path().join("shared").join("cache"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(4)); + let workers = (0..4) + .map(|_| { + let barrier = std::sync::Arc::clone(&barrier); + let path = path.clone(); + std::thread::spawn(move || { + barrier.wait(); + ensure_private_directory(&path) + }) + }) + .collect::>(); + for worker in workers { + worker.join().unwrap().unwrap(); + } + std::fs::write(path.join("complete"), b"private").unwrap(); + } + + fn dacl_bytes(path: &Path) -> Vec { + use windows_sys::Win32::Security::Authorization::{GetSecurityInfo, SE_FILE_OBJECT}; + use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION; + + let handle = open_private_directory(path, false).unwrap(); + let mut acl = std::ptr::null_mut(); + let mut descriptor = std::ptr::null_mut(); + // SAFETY: live directory handle and writable output pointers. + assert_eq!( + unsafe { + GetSecurityInfo( + handle.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut descriptor, + ) + }, + 0 + ); + let _descriptor = LocalAllocation(descriptor); + assert!(!acl.is_null()); + // SAFETY: initialized ACL lives in the owned descriptor allocation. + unsafe { std::slice::from_raw_parts(acl.cast::(), usize::from((*acl).AclSize)) } + .to_vec() + } + + #[test] + fn private_directory_owner_mismatch_preserves_existing_permissions() { + use windows_sys::Win32::Security::{CreateWellKnownSid, WinAnonymousSid}; + + let temp = PrivateFixture::new(); + let before = dacl_bytes(temp.path()); + let mut security = private_directory_security().unwrap(); + let mut anonymous = [0_u64; 16]; + let mut length = std::mem::size_of_val(&anonymous) as u32; + // SAFETY: aligned storage is large enough for a well-known SID. + assert_ne!( + unsafe { + CreateWellKnownSid( + WinAnonymousSid, + std::ptr::null_mut(), + anonymous.as_mut_ptr().cast(), + &mut length, + ) + }, + 0 + ); + // Compare the real existing directory owner against another identity + // without assigning ownership or requiring administrator privileges. + security.sid = anonymous.as_mut_ptr().cast(); + let handle = open_private_directory(temp.path(), true).unwrap(); + let error = protect_private_directory(&handle, &security).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied); + drop(handle); + assert_eq!(dacl_bytes(temp.path()), before); + } + + #[test] + fn private_directory_hardening_preserves_untargeted_child_permissions() { + use windows_sys::Win32::Security::Authorization::{SE_FILE_OBJECT, SetSecurityInfo}; + use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, DACL_SECURITY_INFORMATION, GetAce, + PROTECTED_DACL_SECURITY_INFORMATION, + }; + use windows_sys::Win32::Storage::FileSystem::DELETE; + + let temp = PrivateFixture::new(); + let security = private_directory_security().unwrap(); + let mut ace = std::ptr::null_mut(); + // SAFETY: generated private ACL has one initialized current-user ACE. + assert_ne!(unsafe { GetAce(security.acl, 0, &mut ace) }, 0); + // SAFETY: GetAce returned the generated ACL's live ACCESS_ALLOWED_ACE. + // Keep all creation rights but remove DELETE so parent hardening would + // observably change an inheriting child's ACL if it propagated. + unsafe { + (*ace.cast::()).Mask &= !DELETE; + } + let parent = open_private_directory(temp.path(), true).unwrap(); + // SAFETY: this fixture owns the parent and the restricted DACL remains + // current-user-only. No children exist yet to receive propagation. + assert_eq!( + unsafe { + SetSecurityInfo( + parent.0, + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + security.acl, + std::ptr::null_mut(), + ) + }, + 0 + ); + drop(parent); + let child = temp.path().join("untargeted"); + std::fs::create_dir(&child).unwrap(); + let before = dacl_bytes(&child); + ensure_private_directory(temp.path()).unwrap(); + assert_eq!(dacl_bytes(&child), before); + } +} diff --git a/crates/fbuild-daemon/build.rs b/crates/fbuild-daemon/build.rs new file mode 100644 index 000000000..d45c1bdea --- /dev/null +++ b/crates/fbuild-daemon/build.rs @@ -0,0 +1,15 @@ +//! Declare long-path support without elevating the daemon (#1673). + +fn main() { + let target = std::env::var("TARGET").unwrap_or_default(); + if target.contains("pc-windows-msvc") { + let manifest = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()) + .join("../fbuild-core/src/platform/windows/long-paths.manifest"); + println!("cargo:rerun-if-changed={}", manifest.display()); + println!("cargo:rustc-link-arg-bin=fbuild-daemon=/MANIFEST:EMBED"); + println!( + "cargo:rustc-link-arg-bin=fbuild-daemon=/MANIFESTINPUT:{}", + manifest.display() + ); + } +} diff --git a/crates/fbuild-daemon/src/README.md b/crates/fbuild-daemon/src/README.md index 323ba09ec..3ddc2cc63 100644 --- a/crates/fbuild-daemon/src/README.md +++ b/crates/fbuild-daemon/src/README.md @@ -7,6 +7,7 @@ - **`context.rs`** -- `DaemonContext` (shared state), `BroadcastHub`, self-eviction/idle timeout constants - **`device_manager.rs`** -- `DeviceManager` with exclusive/monitor leases, preemption, and stale device cleanup - **`models.rs`** -- Request/response serde types for all API endpoints (build, deploy, monitor, devices, locks, reset) +- **`long_paths.rs`** -- Read-only Windows preflight for package/build HTTP operations, plain warning delivery for JSON and NDJSON responses, and graceful daemon retirement after a registry support-state change - **`shutdown.rs`** -- Exit paths: `refuse_new_operations_when_shutting_down` middleware (503 once shutdown starts), SIGTERM controlled exit (`exit_on_terminate`: drain in-flight operations up to 5 s, then flush), and `persist_and_clean_up` (pid/port/status cleanup + bounded zccache flush) shared by every clean exit - **`startup.rs`** -- `StartupGate`: answers every request with `503 {"status":"starting","phase":...}` on a duplicate of the bound listener while `main` initializes, then hands the endpoint to the full router (FastLED/fbuild#1480) - **`status_manager.rs`** -- `StatusManager` for atomic read-modify-write of `daemon_status.json` diff --git a/crates/fbuild-daemon/src/broker/backend.rs b/crates/fbuild-daemon/src/broker/backend.rs index dc5bdda6a..cee7d3f02 100644 --- a/crates/fbuild-daemon/src/broker/backend.rs +++ b/crates/fbuild-daemon/src/broker/backend.rs @@ -183,6 +183,7 @@ fn daemon_info_response(ctx: &DaemonContext) -> DaemonInfoResponse { spawner_cwd: ctx.spawner_cwd.clone(), mcp_url: format!("http://127.0.0.1:{}/mcp", ctx.port), watch_set_cache: Some(ctx.watch_set_cache.stats()), + long_path_support: ctx.long_path_support.clone(), } } diff --git a/crates/fbuild-daemon/src/context.rs b/crates/fbuild-daemon/src/context.rs index 33b2a8df8..fe117c46e 100644 --- a/crates/fbuild-daemon/src/context.rs +++ b/crates/fbuild-daemon/src/context.rs @@ -203,6 +203,12 @@ pub struct DaemonContext { pub status_manager: StatusManager, /// Shutdown signal sender. pub shutdown_tx: tokio::sync::watch::Sender, + /// Registry observation used to select this process's fixed path roots. + /// A changed support state requires a fresh process, never a mid-build + /// change to cache or scratch directories. + pub long_path_support: Option, + /// Plain advisory captured with the startup resolution for HTTP clients. + pub long_path_advisory: Option, /// Modification time of the daemon binary at startup (for stale detection). pub source_mtime: f64, /// Path of the daemon binary at startup (FastLED/fbuild#1476 diagnostics). @@ -290,6 +296,9 @@ impl DaemonContext { now_unix, ), shutdown_tx, + long_path_support: fbuild_paths::long_paths::current_resolution() + .map(|resolution| resolution.observation.clone()), + long_path_advisory: fbuild_paths::long_paths::advisory(), source_mtime, source_exe, source_hash: OnceLock::new(), diff --git a/crates/fbuild-daemon/src/handlers/health.rs b/crates/fbuild-daemon/src/handlers/health.rs index 15e672721..54c868310 100644 --- a/crates/fbuild-daemon/src/handlers/health.rs +++ b/crates/fbuild-daemon/src/handlers/health.rs @@ -148,6 +148,7 @@ pub async fn daemon_info(State(ctx): State>) -> Json>, + request: Request, + next: Next, +) -> Response { + if !is_package_operation(request.uri().path()) { + return next.run(request).await; + } + apply_operation_policy( + &ctx, + ctx.long_path_support.as_ref(), + &fbuild_paths::long_paths::probe(), + ctx.long_path_advisory.as_deref(), + request, + next, + ) + .await +} + +async fn apply_operation_policy( + ctx: &DaemonContext, + startup: Option<&Observation>, + fresh: &Observation, + advisory: Option<&str>, + request: Request, + next: Next, +) -> Response { + if !is_package_operation(request.uri().path()) { + return next.run(request).await; + } + + if startup.is_some_and(|observation| observation.status_key() != fresh.status_key()) { + // Close admission atomically before telling axum to drain its existing + // requests. `force` here means accepting retirement while busy; it + // neither cancels operations nor kills child processes. + let in_flight = ctx.try_begin_shutdown(true); + tracing::warn!( + ?startup, + ?fresh, + ?in_flight, + "retiring daemon after Windows long-path policy change" + ); + let _ = ctx.shutdown_tx.send(true); + let mut response = OperationResponse::fail(String::new(), SETTING_CHANGED_MESSAGE.into()); + response.stderr = Some(SETTING_CHANGED_MESSAGE.into()); + return (StatusCode::SERVICE_UNAVAILABLE, Json(response)).into_response(); + } + + let response = next.run(request).await; + match advisory { + Some(message) => attach_advisory(response, message).await, + None => response, + } +} + +fn is_package_operation(path: &str) -> bool { + matches!( + path, + "/api/build" | "/api/deploy" | "/api/test-emu" | "/api/install-deps" + ) +} + +/// Preserve tool diagnostics and the original status while adding plain +/// warning text. Streamed bodies remain lazy, so a warning reaches the +/// caller even while package resolution has not produced its first log. +async fn attach_advisory(response: Response, advisory: &str) -> Response { + let warning = if advisory.starts_with("WARNING:") { + advisory.to_string() + } else { + format!("WARNING: {advisory}") + }; + let (mut parts, body) = response.into_parts(); + let content_type = parts + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim(); + let is_stream = content_type == "application/x-ndjson"; + let is_json = content_type == "application/json"; + // The warning changes body length; the HTTP stack computes it anew. + parts.headers.remove(header::CONTENT_LENGTH); + + if is_stream { + let event = serde_json::json!({"type": "log", "message": warning}); + return Response::from_parts( + parts, + prepend_body(body, bytes::Bytes::from(format!("{event}\n"))), + ); + } + + if !is_json { + // Axum extractor errors are plain text. Keep their diagnostics and + // status intact rather than trying to deserialize an operation model. + return Response::from_parts( + parts, + prepend_body(body, bytes::Bytes::from(format!("{warning}\n"))), + ); + } + + let original = match to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes, + Err(error) => { + let mut response = OperationResponse::fail( + String::new(), + format!("could not read operation response: {error}\n{warning}"), + ); + response.stderr = Some(warning); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(response)).into_response(); + } + }; + match serde_json::from_slice::(&original) { + Ok(mut document) if document.is_object() => { + let stderr = document.get("stderr").and_then(|value| value.as_str()); + let stderr = match stderr.filter(|diagnostics| !diagnostics.is_empty()) { + Some(diagnostics) => format!("{warning}\n{diagnostics}"), + None => warning.clone(), + }; + let message = document.get("message").and_then(|value| value.as_str()); + let message = match message.filter(|message| !message.is_empty()) { + Some(message) => format!("{message}\n{warning}"), + None => warning, + }; + document["stderr"] = serde_json::Value::String(stderr); + document["message"] = serde_json::Value::String(message); + Response::from_parts(parts, Body::from(document.to_string())) + } + _ => Response::from_parts( + parts, + prepend_body( + Body::from(original), + bytes::Bytes::from(format!("{warning}\n")), + ), + ), + } +} + +fn prepend_body(body: Body, prefix: bytes::Bytes) -> Body { + let first = futures::stream::once(std::future::ready(Ok::<_, axum::Error>(prefix))); + Body::from_stream(first.chain(body.into_data_stream())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::handlers::{emulator, health, operations, websockets}; + use crate::models::OperationResponse; + use axum::body::Body; + use axum::extract::State; + use axum::http::StatusCode; + use axum::response::IntoResponse; + use axum::routing::{get, post}; + use axum::{Json, Router}; + use futures::StreamExt; + use std::sync::Arc; + use std::time::Duration; + + const WARNING: &str = "WARNING: Using short-path fallback cache. Run fbuild --fix-win to enable Windows long paths."; + + #[derive(Clone)] + struct Policy { + ctx: Arc, + startup: Option, + fresh: Observation, + advisory: Option, + } + + async fn fixture_policy( + State(policy): State, + request: Request, + next: Next, + ) -> Response { + apply_operation_policy( + &policy.ctx, + policy.startup.as_ref(), + &policy.fresh, + policy.advisory.as_deref(), + request, + next, + ) + .await + } + + fn context() -> (Arc, tokio::sync::watch::Receiver) { + let (tx, rx) = tokio::sync::watch::channel(false); + ( + Arc::new(DaemonContext::new(0, tx, "long-path-http-test".into())), + rx, + ) + } + + fn disabled_policy(ctx: Arc) -> Policy { + Policy { + ctx, + startup: Some(Observation::Disabled), + fresh: Observation::Disabled, + advisory: Some(WARNING.into()), + } + } + + async fn serve(app: Router) -> String { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + format!("http://{addr}") + } + + async fn outcome() -> Response { + let mut response = OperationResponse::ok("fixture".into(), "Build succeeded".into()); + response.stdout = Some("compiler output".into()); + response.stderr = Some("existing compiler diagnostic".into()); + Json(response).into_response() + } + + #[tokio::test] + async fn fallback_advisory_preserves_success_and_original_diagnostics() { + let (ctx, _rx) = context(); + let app = Router::new() + .route("/api/build", post(outcome)) + .route_layer(axum::middleware::from_fn_with_state( + disabled_policy(ctx), + fixture_policy, + )); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["success"], true); + assert_eq!(body["exit_code"], 0); + assert_eq!(body["stdout"], "compiler output"); + assert_eq!( + body["stderr"], + format!("{WARNING}\nexisting compiler diagnostic") + ); + assert_eq!(body["message"], format!("Build succeeded\n{WARNING}")); + assert!(!body.to_string().contains("\\u001b")); + } + + #[tokio::test] + async fn all_package_endpoints_preserve_advisory_on_early_validation_failure() { + let (ctx, _rx) = context(); + let app = Router::new() + .route("/api/build", post(operations::build)) + .route("/api/deploy", post(operations::deploy)) + .route("/api/test-emu", post(emulator::test_emu)) + .route("/api/install-deps", post(operations::install_deps)) + .route_layer(axum::middleware::from_fn_with_state( + disabled_policy(Arc::clone(&ctx)), + fixture_policy, + )) + .with_state(ctx); + let base = serve(app).await; + let fixture = tempfile::tempdir().unwrap(); + let missing_project = fixture.path().join("missing-project"); + for endpoint in ["build", "deploy", "test-emu", "install-deps"] { + let response = fbuild_core::http::client() + .post(format!("{base}/api/{endpoint}")) + .json(&serde_json::json!({"project_dir": missing_project, "stream": true})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{endpoint}"); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["success"], false, "{endpoint}"); + assert_eq!(body["stderr"], WARNING, "{endpoint}"); + assert!( + body["message"].as_str().unwrap().contains(WARNING), + "{endpoint}" + ); + assert!( + body["message"].as_str().unwrap().contains("does not exist"), + "{endpoint}" + ); + } + } + + #[tokio::test] + async fn streaming_advisory_precedes_pending_body_and_keeps_terminal_result() { + let (ctx, _rx) = context(); + let release = Arc::new(tokio::sync::Notify::new()); + let release_handler = Arc::clone(&release); + let app = Router::new() + .route("/api/build", post(move || { + let release = Arc::clone(&release_handler); + async move { + let stream = futures::stream::once(async move { + release.notified().await; + Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static( + b"{\"type\":\"log\",\"message\":\"package error\"}\n{\"type\":\"result\",\"success\":false,\"exit_code\":1}\n", + )) + }); + ([("content-type", "application/x-ndjson")], Body::from_stream(stream)) + } + })) + .route_layer(axum::middleware::from_fn_with_state(disabled_policy(ctx), fixture_policy)); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .send() + .await + .unwrap(); + let mut chunks = response.bytes_stream(); + let first = tokio::time::timeout(Duration::from_secs(2), chunks.next()) + .await + .expect("warning must be available while the operation body is pending") + .unwrap() + .unwrap(); + let event: serde_json::Value = serde_json::from_slice(&first).unwrap(); + assert_eq!(event["type"], "log"); + assert_eq!(event["message"], WARNING); + release.notify_one(); + let mut remainder = Vec::new(); + while let Some(chunk) = chunks.next().await { + remainder.extend(chunk.unwrap()); + } + let remainder = String::from_utf8(remainder).unwrap(); + assert!(remainder.contains("package error")); + assert!(remainder.contains("\"type\":\"result\"")); + assert!(!remainder.contains(WARNING)); + } + + #[tokio::test] + async fn setting_change_refuses_work_and_retires_without_interrupting_active_operation() { + let (ctx, rx) = context(); + let admission = ctx.begin_operation_admission().unwrap(); + let operation = operations::OperationGuard::new( + &ctx, + fbuild_core::DaemonState::Building, + Some("existing build".into()), + ); + let mut policy = disabled_policy(Arc::clone(&ctx)); + policy.fresh = Observation::Enabled; + let app = Router::new() + .route("/api/build", post(outcome)) + .route_layer(axum::middleware::from_fn_with_state( + Arc::clone(&ctx), + crate::shutdown::refuse_new_operations_when_shutting_down, + )) + .route_layer(axum::middleware::from_fn_with_state(policy, fixture_policy)); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["success"], false); + assert_eq!(body["message"], SETTING_CHANGED_MESSAGE); + assert_eq!(body["stderr"], SETTING_CHANGED_MESSAGE); + assert!(*rx.borrow()); + assert!(ctx.begin_operation_admission().is_none()); + assert_eq!(ctx.operation_drain_count(), 2); + assert!(!ctx.wait_for_operations(Duration::ZERO).await); + drop(admission); + assert_eq!(ctx.operation_drain_count(), 1); + assert!(!ctx.wait_for_operations(Duration::ZERO).await); + drop(operation); + assert!(ctx.wait_for_operations(Duration::ZERO).await); + } + + #[tokio::test] + async fn changing_unknown_diagnostic_keeps_same_daemon() { + let (ctx, rx) = context(); + let mut policy = disabled_policy(ctx); + policy.startup = Some(Observation::Unknown("access denied".into())); + policy.fresh = Observation::Unknown("query failed".into()); + let app = Router::new() + .route("/api/build", post(outcome)) + .route_layer(axum::middleware::from_fn_with_state(policy, fixture_policy)); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(!*rx.borrow()); + } + + #[tokio::test] + async fn non_package_commands_ignore_fallback_and_setting_changes() { + let (ctx, rx) = context(); + let mut policy = disabled_policy(ctx); + policy.fresh = Observation::Enabled; + let app = Router::new() + .route("/api/reset", post(outcome)) + .route_layer(axum::middleware::from_fn_with_state(policy, fixture_policy)); + let response = fbuild_core::http::client() + .post(format!("{}/api/reset", serve(app).await)) + .send() + .await + .unwrap(); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["stderr"], "existing compiler diagnostic"); + assert!(!*rx.borrow()); + } + + #[tokio::test] + async fn enabled_policy_preserves_response_without_advisory() { + let (ctx, _rx) = context(); + let policy = Policy { + ctx, + startup: Some(Observation::Enabled), + fresh: Observation::Enabled, + advisory: None, + }; + let app = Router::new() + .route("/api/build", post(outcome)) + .route_layer(axum::middleware::from_fn_with_state(policy, fixture_policy)); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .send() + .await + .unwrap(); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!(body["message"], "Build succeeded"); + assert_eq!(body["stderr"], "existing compiler diagnostic"); + } + + #[tokio::test] + async fn daemon_info_reports_startup_observation_for_client_handoff() { + let (ctx, _rx) = context(); + let mut ctx = Arc::try_unwrap(ctx).ok().unwrap(); + ctx.long_path_support = Some(Observation::Disabled); + let app = Router::new() + .route("/api/daemon/info", get(health::daemon_info)) + .with_state(Arc::new(ctx)); + let response = fbuild_core::http::client() + .get(format!("{}/api/daemon/info", serve(app).await)) + .send() + .await + .unwrap(); + let body: serde_json::Value = response.json().await.unwrap(); + assert_eq!( + body["long_path_support"], + serde_json::json!({"status":"disabled"}) + ); + } + + #[tokio::test] + async fn extractor_rejection_keeps_plain_warning_and_parse_error() { + let (ctx, _rx) = context(); + let app = Router::new() + .route("/api/build", post(operations::build)) + .route_layer(axum::middleware::from_fn_with_state( + disabled_policy(Arc::clone(&ctx)), + fixture_policy, + )) + .with_state(ctx); + let response = fbuild_core::http::client() + .post(format!("{}/api/build", serve(app).await)) + .header("content-type", "application/json") + .body("{") + .send() + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = response.text().await.unwrap(); + assert!(body.starts_with(WARNING)); + assert!(body.contains("Failed to parse")); + } + + async fn serve_draining_build( + ctx: Arc, + mut shutdown_rx: tokio::sync::watch::Receiver, + started: Arc, + release: Arc, + ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) { + let handler_ctx = Arc::clone(&ctx); + let handler_started = Arc::clone(&started); + let handler_release = Arc::clone(&release); + let app = Router::new() + .route( + "/api/build", + post(move || { + let ctx = Arc::clone(&handler_ctx); + let started = Arc::clone(&handler_started); + let release = Arc::clone(&handler_release); + async move { + let _operation = operations::OperationGuard::new( + &ctx, + fbuild_core::DaemonState::Building, + Some("admitted build".into()), + ); + started.notify_one(); + release.notified().await; + Json(OperationResponse::ok( + "drain-fixture".into(), + "Build finished".into(), + )) + } + }), + ) + .route_layer(axum::middleware::from_fn_with_state( + Arc::clone(&ctx), + crate::shutdown::refuse_new_operations_when_shutting_down, + )) + .route("/ws/serial-monitor", get(websockets::ws_serial_monitor)) + .route( + "/ws/monitor/:session_id", + get(websockets::ws_monitor_session), + ) + .route("/ws/status", get(websockets::ws_status)) + .route("/ws/logs", get(websockets::ws_logs)) + .with_state(Arc::clone(&ctx)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve(listener, app) + .with_graceful_shutdown(async move { + let _ = shutdown_rx.changed().await; + }) + .await + .unwrap(); + }); + (address, server) + } + + #[tokio::test] + async fn retirement_drains_admitted_build_without_waiting_for_idle_websockets() { + let (ctx, shutdown_rx) = context(); + let started = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let (address, mut server) = serve_draining_build( + Arc::clone(&ctx), + shutdown_rx, + Arc::clone(&started), + Arc::clone(&release), + ) + .await; + + let mut sockets = Vec::new(); + for endpoint in ["serial-monitor", "monitor/drain-fixture", "status", "logs"] { + let (socket, _) = + tokio_tungstenite::connect_async(format!("ws://{address}/ws/{endpoint}")) + .await + .unwrap(); + sockets.push(socket); + } + tokio::time::timeout(Duration::from_secs(2), async { + while ctx + .pending_serial_attaches + .load(std::sync::atomic::Ordering::Acquire) + == 0 + { + tokio::task::yield_now().await; + } + }) + .await + .expect("real serial WebSocket must be waiting for its attach frame"); + + let build = tokio::spawn(async move { + fbuild_core::http::client() + .post(format!("http://{address}/api/build")) + .send() + .await + .unwrap() + .json::() + .await + .unwrap() + }); + tokio::time::timeout(Duration::from_secs(2), started.notified()) + .await + .unwrap(); + ctx.begin_shutdown(); + ctx.shutdown_tx.send(true).unwrap(); + assert!(ctx.begin_operation_admission().is_none()); + assert!( + tokio::time::timeout(Duration::from_millis(100), &mut server) + .await + .is_err(), + "retirement must wait for the admitted build" + ); + + release.notify_one(); + let result = tokio::time::timeout(Duration::from_secs(2), build) + .await + .unwrap() + .unwrap(); + assert_eq!(result["success"], true); + assert_eq!(result["message"], "Build finished"); + tokio::time::timeout(Duration::from_secs(2), server) + .await + .expect("idle upgraded WebSockets must not hold the HTTP drain") + .unwrap(); + assert_eq!(ctx.operation_drain_count(), 0); + assert_eq!( + ctx.pending_serial_attaches + .load(std::sync::atomic::Ordering::Acquire), + 1, + "the upgraded serial handler is detached from the completed HTTP server" + ); + for mut socket in sockets { + let _ = socket.close(None).await; + } + } +} diff --git a/crates/fbuild-daemon/src/main.rs b/crates/fbuild-daemon/src/main.rs index a93a5f17e..604121d1c 100644 --- a/crates/fbuild-daemon/src/main.rs +++ b/crates/fbuild-daemon/src/main.rs @@ -55,6 +55,14 @@ async fn main() { unsafe { std::env::set_var("FBUILD_DEV_MODE", "1") }; } + // Resolve Windows fallback roots before cache identity, ownership locks, + // endpoint selection, or package managers read any paths. The daemon + // never prompts or elevates; interactive repair belongs to the CLI. + if let Err(error) = fbuild_paths::long_paths::initialize() { + eprintln!("fatal: could not initialize Windows long-path policy: {error}"); + std::process::exit(1); + } + // FastLED/fbuild#1285: adopt the dev daemon-identity stamp the spawning // CLI exported, or derive one from this binary's content β€” once per // daemon, inherited by every build child. Official (non-dev) runs export @@ -275,6 +283,10 @@ async fn main() { .route_layer(axum::middleware::from_fn_with_state( context.clone(), fbuild_daemon::shutdown::refuse_new_operations_when_shutting_down, + )) + .route_layer(axum::middleware::from_fn_with_state( + context.clone(), + fbuild_daemon::long_paths::operation_policy, )); let app = Router::new() diff --git a/crates/fbuild-daemon/src/models.rs b/crates/fbuild-daemon/src/models.rs index 75141cafb..4dd3b4c23 100644 --- a/crates/fbuild-daemon/src/models.rs +++ b/crates/fbuild-daemon/src/models.rs @@ -297,6 +297,10 @@ pub struct DaemonInfoResponse { pub cache_identity: String, pub cache_schema_version: u32, pub daemon_dir: String, + /// Windows policy captured at daemon startup. Optional for compatibility + /// with clients and daemons that predate the long-path preflight. + #[serde(skip_serializing_if = "Option::is_none")] + pub long_path_support: Option, pub source_mtime: f64, pub spawner_cwd: String, /// MCP (Model Context Protocol) server URL. @@ -966,6 +970,7 @@ mod tests { spawner_cwd: "/home/user/project".into(), mcp_url: "http://127.0.0.1:8765/mcp".into(), watch_set_cache: None, + long_path_support: None, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"started_at\"")); @@ -1010,6 +1015,7 @@ mod tests { spawner_cwd: "unknown".into(), mcp_url: "http://127.0.0.1:8765/mcp".into(), watch_set_cache: None, + long_path_support: None, }; let json = serde_json::to_string(&resp).unwrap(); assert!(!json.contains("current_operation")); @@ -1046,6 +1052,7 @@ mod tests { spawner_cwd: "unknown".into(), mcp_url: "http://127.0.0.1:8765/mcp".into(), watch_set_cache: None, + long_path_support: None, }; let json = serde_json::to_string(&resp).unwrap(); assert!(json.contains("\"current_operation\"")); diff --git a/crates/fbuild-packages-fetch/src/extractor.rs b/crates/fbuild-packages-fetch/src/extractor.rs index f36507065..23a11844f 100644 --- a/crates/fbuild-packages-fetch/src/extractor.rs +++ b/crates/fbuild-packages-fetch/src/extractor.rs @@ -6,6 +6,36 @@ use std::path::Path; use fbuild_core::{FbuildError, Result}; +/// Check the actual extracted layout before an installer marks it complete. +fn validate_extraction_tree(root: &Path, required: bool) -> Result<()> { + const LEGACY_DIRECTORY_UNITS: usize = 248; + if !required { + return Ok(()); + } + for entry in walkdir::WalkDir::new(root).follow_links(false) { + let entry = entry.map_err(|error| { + FbuildError::PackageError(format!( + "could not validate extracted Windows paths: {error}" + )) + })?; + if fbuild_core::platform::fs::legacy_path_units(entry.path()) + 1 > LEGACY_DIRECTORY_UNITS { + return Err(FbuildError::PackageError(format!( + "Windows short-path fallback cannot safely use extracted path {}. Run fbuild --fix-win or use a shorter package location.", + entry.path().display() + ))); + } + } + Ok(()) +} + +fn validate_extraction(root: &Path) -> Result<()> { + validate_extraction_tree( + root, + fbuild_paths::long_paths::current_resolution() + .is_some_and(|resolution| resolution.observation.requires_fallback()), + ) +} + /// Extract an archive into the given directory. /// /// Supported formats: .tar.gz, .tgz, .tar.bz2, .tar.xz, .txz, .tar.zst, .zip @@ -57,6 +87,7 @@ fn extract_tar_gz(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + validate_extraction(dest_dir)?; Ok(()) } @@ -71,6 +102,7 @@ fn extract_tar_bz2(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + validate_extraction(dest_dir)?; Ok(()) } @@ -91,6 +123,7 @@ fn extract_tar_zst(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + validate_extraction(dest_dir)?; Ok(()) } @@ -105,6 +138,7 @@ fn extract_tar_xz(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + validate_extraction(dest_dir)?; Ok(()) } @@ -125,6 +159,7 @@ fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<()> { e )) })?; + validate_extraction(dest_dir)?; Ok(()) } @@ -132,6 +167,33 @@ fn extract_zip(archive_path: &Path, dest_dir: &Path) -> Result<()> { mod tests { use super::*; + #[test] + fn windows_path_fallback_rejects_sdk_members_that_still_exceed_the_limit() { + let root = tempfile::tempdir().unwrap(); + let deep = (0..18).fold(root.path().to_path_buf(), |path, _| { + path.join("sdk-component") + }); + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join("header.h"), b"// SDK header").unwrap(); + let error = validate_extraction_tree(root.path(), true) + .unwrap_err() + .to_string(); + assert!(error.contains("--fix-win"), "{error}"); + assert!(error.contains("sdk-component"), "{error}"); + } + + #[test] + fn windows_path_enabled_keeps_extended_sdk_members_available() { + let root = tempfile::tempdir().unwrap(); + let deep = (0..18).fold(root.path().to_path_buf(), |path, _| { + path.join("sdk-component") + }); + std::fs::create_dir_all(&deep).unwrap(); + std::fs::write(deep.join("header.h"), b"// SDK header").unwrap(); + validate_extraction_tree(root.path(), false).unwrap(); + assert!(deep.join("header.h").exists()); + } + #[test] fn test_unsupported_format() { let result = extract(Path::new("file.xyz"), Path::new("/tmp")); diff --git a/crates/fbuild-paths/README.md b/crates/fbuild-paths/README.md index 5b943ff5d..66488784b 100644 --- a/crates/fbuild-paths/README.md +++ b/crates/fbuild-paths/README.md @@ -12,6 +12,10 @@ Single source of truth for all `.fbuild` directory paths, with dev/prod isolatio - `get_daemon_port()` -- Port resolution priority: explicit environment override, live endpoint-keyed port file, then a deterministic version/cache-identity default (10000–49151 on Windows to avoid dynamic exclusion blocks; 49152–65535 elsewhere) - `get_daemon_url()` -- Daemon HTTP URL (`http://127.0.0.1:{port}`) - `get_cache_root()` -- Global cache dir (`FBUILD_CACHE_DIR` override or `~/.fbuild/{mode}/cache`) +- `long_paths::initialize()` -- Captures the Windows long-path policy and the requested/effective paths once, before cache or daemon work. When the policy is disabled or cannot be read, selects a writable short cache/scratch root. Automatic roots are direct local-drive children such as `C:\fb-\{dev|prod}`, protected for the current user; foreign-owned directories and links/reparse points are rejected. A suitably short `FBUILD_CACHE_DIR` wins and uses `t`/`b` sidecars within that intentionally configured cache, without requiring access to the drive root or changing the configured root's ACL. A deep override is reported and redirected. The automatic selection is persisted under the normal fbuild root and its private hierarchy verified on reuse, so enabling long paths later reuses downloaded packages. Environment changes take effect in the next process. +- `long_paths::advisory()` -- Plain warning text for CLI/HTTP callers; terminal callers render the warning yellow. Includes the effective fallback paths and `fbuild --fix-win` guidance. +- `long_paths::validate_legacy_path()` -- Rejects paths exceeding legacy Windows limits while the fallback is active, with repair guidance. Shorter roots cannot make arbitrary project or archive depths safe. +- `long_paths::prepare_build_directory()` -- Creates and verifies the full selected automatic build hierarchy before output writes, rejecting foreign ownership and links/reparse points. Intentional cache overrides and project-local output directories retain their existing ACLs. - `get_project_build_root()` -- Per-project build-dir root (`FBUILD_BUILD_DIR` override or `/.fbuild/build`). Returns the *root* β€” does not append `/`. Most callers should use `BuildLayout` instead. - `BuildLayout` -- Single source of truth for the env-and-profile-rooted build dir. Encapsulates: explicit per-request `override_root` (highest precedence), `FBUILD_BUILD_DIR` env var, default `/.fbuild/build`, and the `/` append. Drops the `` segment when `flatten_env` is set or when `project_dir.file_name() == env_name` (the FastLED `.build/pio//` case, FastLED/fbuild#432). - `get_platformio_home()` / `get_platformio_package()` -- PlatformIO directory resolution @@ -21,3 +25,4 @@ Single source of truth for all `.fbuild` directory paths, with dev/prod isolatio - **lib.rs** -- All path functions in a single module - **daemon_ownership** -- `RootOwnershipGuard`, `SpawnLockGuard`, and `OwnerClaim` (root-owner.lock / spawn.lock / root-owner.json) β€” see Key Types above +- **long_paths** -- Immutable Windows policy snapshot, short-path selection and durable cache retention. Fallback build roots are keyed by normalized project and requested output root so different projects and explicit build overrides cannot overwrite one another. diff --git a/crates/fbuild-paths/src/README.md b/crates/fbuild-paths/src/README.md index ed9e6b7f5..e62b761bc 100644 --- a/crates/fbuild-paths/src/README.md +++ b/crates/fbuild-paths/src/README.md @@ -3,3 +3,4 @@ ## Modules - **`lib.rs`** -- All path resolution functions: dev/prod root, daemon file paths, port resolution, cache dirs, project build dirs, PlatformIO home, and firmware file discovery +- **`long_paths.rs`** -- Windows long-path policy snapshot and validated short cache, extraction scratch and project build fallback. Keeps the selected automatic cache in a durable marker under the normal fbuild configuration root. diff --git a/crates/fbuild-paths/src/lib.rs b/crates/fbuild-paths/src/lib.rs index 00134c265..c9ac3c780 100644 --- a/crates/fbuild-paths/src/lib.rs +++ b/crates/fbuild-paths/src/lib.rs @@ -10,6 +10,7 @@ use fbuild_core::BuildProfile; pub mod daemon_ownership; pub mod dev_daemon_namespace; pub mod executable_hash; +pub mod long_paths; pub mod running_process; /// The project-local and home-local fbuild directory segment: `.fbuild`. @@ -35,6 +36,9 @@ pub const BUILD_DIR_NAME: &str = "build"; /// Check if running in development mode. pub fn is_dev_mode() -> bool { + if let Some(resolution) = long_paths::current_resolution() { + return resolution.dev_mode; + } std::env::var("FBUILD_DEV_MODE") .map(|v| v == "1") .unwrap_or(false) @@ -55,6 +59,9 @@ pub fn get_fbuild_root() -> PathBuf { /// so a home-less environment is a diagnosable failure and not a crash; they /// had each hand-rolled this path to keep that property (FastLED/fbuild#1349). pub fn try_get_fbuild_root() -> Option { + if let Some(resolution) = long_paths::current_resolution() { + return Some(resolution.fbuild_root.clone()); + } let mode = if is_dev_mode() { "dev" } else { "prod" }; Some(dirs_next()?.join(FBUILD_DIR_NAME).join(mode)) } @@ -95,7 +102,10 @@ pub fn tools_dir_label() -> String { /// If current mode is dev, returns prod root, and vice versa. /// Used for cross-mode daemon discovery. pub fn get_other_fbuild_root() -> PathBuf { - let home = dirs_next().expect("could not determine home directory"); + let home = long_paths::current_resolution() + .map(|resolution| resolution.home.clone()) + .or_else(dirs_next) + .expect("could not determine home directory"); let mode = if is_dev_mode() { "prod" } else { "dev" }; home.join(FBUILD_DIR_NAME).join(mode) } @@ -186,7 +196,13 @@ pub fn get_daemon_status_file() -> PathBuf { } /// Global cache root (or `FBUILD_CACHE_DIR` override). +/// +/// After [`long_paths::initialize`], returns the fixed effective selection, +/// including a Windows short-path fallback when required. pub fn get_cache_root() -> PathBuf { + if let Some(resolution) = long_paths::current_resolution() { + return resolution.cache_root.clone(); + } if let Ok(dir) = std::env::var("FBUILD_CACHE_DIR") { return PathBuf::from(dir); } @@ -195,7 +211,8 @@ pub fn get_cache_root() -> PathBuf { /// Root directory for short-lived working / scratch dirs. /// -/// FastLED/fbuild#844 ("Bridge pair 10"). Returns `~/.fbuild/{dev|prod}/tmp`. +/// FastLED/fbuild#844 ("Bridge pair 10"). Defaults to `~/.fbuild/{dev|prod}/tmp`; +/// an initialized Windows fallback uses its short extraction scratch root. /// This is the rooted alternative to `std::env::temp_dir()` and /// `tempfile::tempdir()`; per-platform package extractors, framework /// hydration, linker scratch dirs, etc. all live under this root so @@ -205,6 +222,9 @@ pub fn get_cache_root() -> PathBuf { /// Note: this does NOT create the directory β€” pair with /// [`temp_subdir`] for the create-on-use pattern. pub fn dev_or_prod_temp_root() -> PathBuf { + if let Some(resolution) = long_paths::current_resolution() { + return resolution.scratch_root.clone(); + } get_fbuild_root().join("tmp") } @@ -231,7 +251,17 @@ pub fn get_project_fbuild_dir(project_dir: &Path) -> PathBuf { /// 1. `FBUILD_BUILD_DIR` environment variable (explicit override, useful for /// Windows where long project paths can exceed the 260 character limit) /// 2. Default: `/.fbuild/build/` +/// +/// An initialized Windows fallback redirects deep roots to a directory keyed +/// by the normalized project and requested root under its short build root. pub fn get_project_build_root(project_dir: &Path) -> PathBuf { + if let Some(resolution) = long_paths::current_resolution() { + let requested = resolution + .build_override + .clone() + .unwrap_or_else(|| get_project_fbuild_dir(project_dir).join(BUILD_DIR_NAME)); + return resolution.resolve_build_root(project_dir, &requested); + } if let Ok(dir) = std::env::var("FBUILD_BUILD_DIR") { return PathBuf::from(dir); } @@ -255,6 +285,9 @@ pub fn get_project_build_root(project_dir: &Path) -> PathBuf { /// Windows long-path workarounds). /// 3. `/.fbuild/build` (the default). /// +/// When the Windows fallback is active, the selected root is shortened if +/// needed before appending the environment/profile segments. +/// /// The `/` segments are appended on top of whichever /// root was selected, *unless* `flatten_env` is true or the /// project_dir's basename already equals `env_name` β€” in which case @@ -315,11 +348,9 @@ impl BuildLayout { /// Resolve the env-rooted build directory. pub fn resolve(&self) -> PathBuf { let root = if let Some(ref r) = self.override_root { - r.clone() - } else if let Ok(dir) = std::env::var("FBUILD_BUILD_DIR") { - PathBuf::from(dir) + long_paths::resolve_build_root(&self.project_dir, r) } else { - get_project_fbuild_dir(&self.project_dir).join(BUILD_DIR_NAME) + get_project_build_root(&self.project_dir) }; let collapse_env = self.flatten_env || self.project_basename_matches_env(); diff --git a/crates/fbuild-paths/src/long_paths.rs b/crates/fbuild-paths/src/long_paths.rs new file mode 100644 index 000000000..41a6c59e8 --- /dev/null +++ b/crates/fbuild-paths/src/long_paths.rs @@ -0,0 +1,570 @@ +//! Windows long-path policy and immutable cache/build fallback selection. +//! +//! Entry points call [`initialize`] before cache or daemon work. The resolver +//! never changes the environment or emits output; each caller delivers the +//! advisory through its own terminal or HTTP output channel. + +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; + +use fbuild_core::path::NormalizedPath; +use fbuild_core::platform::fs; +use fbuild_core::{FbuildError, Result}; +use serde::{Deserialize, Serialize}; + +/// Leaves at least 180 characters for SDK/package paths below a cache root. +const MAX_SHORT_ROOT_UNITS: usize = 64; +/// Conservative legacy directory limit, including the terminating NUL. +const MAX_LEGACY_DIRECTORY_UNITS: usize = 248; +const MARKER_FILE: &str = "windows-short-paths.json"; +const MARKER_SCHEMA: u32 = 1; +const HASH_HEX_UNITS: usize = 12; + +static RESOLUTION: OnceLock = OnceLock::new(); +static INITIALIZE_LOCK: Mutex<()> = Mutex::new(()); + +/// Observed system policy, including an unreadable or malformed value. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "status", content = "reason", rename_all = "kebab-case")] +pub enum Observation { + NotApplicable, + Enabled, + Disabled, + Unknown(String), +} + +impl Observation { + /// Stable state key suitable for comparing daemon startup and current policy. + pub fn status_key(&self) -> &'static str { + match self { + Self::NotApplicable => "not-applicable", + Self::Enabled => "enabled", + Self::Disabled => "disabled", + Self::Unknown(_) => "unknown", + } + } + + /// An unreadable policy receives the same conservative fallback as disabled. + pub fn requires_fallback(&self) -> bool { + matches!(self, Self::Disabled | Self::Unknown(_)) + } +} + +/// Paths selected once per process. Existing operations retain this snapshot +/// even when another process changes the system setting or shell environment. +#[derive(Debug, Clone, Serialize)] +pub struct Resolution { + pub observation: Observation, + pub home: PathBuf, + pub fbuild_root: PathBuf, + pub dev_mode: bool, + pub requested_cache_root: PathBuf, + pub cache_root: PathBuf, + pub scratch_root: PathBuf, + pub short_build_root: Option, + pub build_override: Option, + pub cache_dir_source: &'static str, +} + +impl Resolution { + /// Plain advisory text; callers choose how to render yellow warnings. + pub fn advisory(&self) -> Option { + let reason = match &self.observation { + Observation::Disabled => "Windows long-path support is disabled.".to_string(), + Observation::Unknown(reason) => { + format!("Windows long-path support could not be checked: {reason}.") + } + Observation::NotApplicable | Observation::Enabled => return None, + }; + let requested = if NormalizedPath::new(&self.requested_cache_root) + != NormalizedPath::new(&self.cache_root) + { + format!(" Requested cache: {}.", self.requested_cache_root.display()) + } else { + String::new() + }; + let builds = self + .short_build_root + .as_ref() + .map_or_else(String::new, |root| { + format!( + " Deep build outputs are redirected under {}.", + root.display() + ) + }); + Some(format!( + "{reason} Using short-path fallback cache: {}. Extraction scratch: {}.{requested}{builds} Run fbuild --fix-win to enable Windows long paths.", + self.cache_root.display(), + self.scratch_root.display(), + )) + } + + /// Redirect a deep build root to a directory keyed by the normalized + /// project and requested output root. + /// The `.fbuild/build` suffix preserves the compiler workspace convention. + pub fn resolve_build_root(&self, project_dir: &Path, requested_root: &Path) -> PathBuf { + let Some(base) = &self.short_build_root else { + return requested_root.to_path_buf(); + }; + let requested = + absolute_normalized(requested_root).unwrap_or_else(|_| requested_root.to_path_buf()); + if fs::legacy_path_units(&requested) < MAX_SHORT_ROOT_UNITS { + return requested; + } + let project = project_dir + .canonicalize() + .ok() + .and_then(|path| absolute_normalized(&path).ok()) + .or_else(|| absolute_normalized(project_dir).ok()) + .unwrap_or_else(|| project_dir.to_path_buf()); + base.join(build_root_hash(&project, &requested)) + .join(crate::FBUILD_DIR_NAME) + .join(crate::BUILD_DIR_NAME) + } + + /// Reject paths that remain too deep after fallback selection. Applies to + /// directories as well as files so extraction cannot cross the stricter limit. + pub fn validate_legacy_path(&self, path: &Path) -> Result<()> { + if !self.observation.requires_fallback() { + return Ok(()); + } + let absolute = absolute_normalized(path)?; + let units = fs::legacy_path_units(&absolute) + 1; + if units > MAX_LEGACY_DIRECTORY_UNITS { + return Err(FbuildError::ConfigError(format!( + "Windows short-path fallback cannot safely use {} ({units} path units including NUL; limit {MAX_LEGACY_DIRECTORY_UNITS}). Run fbuild --fix-win, or shorten the project/package path.", + absolute.display() + ))); + } + Ok(()) + } + + /// Secure automatic output directories before any build writes. User + /// configured cache roots and project-local outputs retain their own ACLs. + pub fn prepare_build_directory(&self, path: &Path) -> Result<()> { + self.validate_legacy_path(path)?; + if self.cache_dir_source != "windows-short-path-fallback" { + return Ok(()); + } + let Some(base) = &self.short_build_root else { + return Ok(()); + }; + let absolute = absolute_normalized(path)?; + let candidate = NormalizedPath::new(&absolute); + let base = NormalizedPath::new(base); + let selected = candidate + .key() + .strip_prefix(base.key()) + .is_some_and(|tail| tail.is_empty() || tail.starts_with('/')); + if selected { + // Verify each selected ancestor before creating its children. + // This also prevents portable fixture helpers from recursively + // creating through a linked ancestor before the final check. + let base_depth = base.as_path().components().count(); + let directories: Vec<&Path> = absolute + .ancestors() + .take_while(|path| path.components().count() >= base_depth) + .collect(); + for directory in directories.into_iter().rev() { + fs::ensure_private_directory(directory).map_err(|error| { + fallback_error( + &format!("secure build directory {}", directory.display()), + &error, + ) + })?; + } + } + Ok(()) + } +} + +/// Read the current registry policy without changing this process's snapshot. +pub fn probe() -> Observation { + match fs::long_path_support() { + Ok(fs::LongPathSupport::NotApplicable) => Observation::NotApplicable, + Ok(fs::LongPathSupport::Enabled) => Observation::Enabled, + Ok(fs::LongPathSupport::Disabled) => Observation::Disabled, + Err(error) => Observation::Unknown(error.to_string()), + } +} + +/// Capture policy and path overrides exactly once, before any cache/daemon work. +/// A failed initialization remains retryable and never installs a partial snapshot. +pub fn initialize() -> Result<&'static Resolution> { + if let Some(resolution) = RESOLUTION.get() { + return Ok(resolution); + } + let _guard = INITIALIZE_LOCK.lock().map_err(|error| { + FbuildError::Other(format!("long-path initialization lock failed: {error}")) + })?; + if let Some(resolution) = RESOLUTION.get() { + return Ok(resolution); + } + let observation = probe(); + let home = crate::dirs_next() + .ok_or_else(|| FbuildError::ConfigError("could not determine home directory".into()))?; + let home = absolute_normalized(&home)?; + let dev_mode = std::env::var("FBUILD_DEV_MODE").is_ok_and(|value| value == "1"); + let fbuild_root = home + .join(crate::FBUILD_DIR_NAME) + .join(if dev_mode { "dev" } else { "prod" }); + let inputs = Inputs { + #[cfg(test)] + short_base: fbuild_root.clone(), + home, + fbuild_root, + cache_override: std::env::var_os("FBUILD_CACHE_DIR").map(PathBuf::from), + build_override: std::env::var_os("FBUILD_BUILD_DIR").map(PathBuf::from), + dev_mode, + observation, + }; + let resolution = resolve_with_short_base(inputs, fs::short_cache_base)?; + let _ = RESOLUTION.set(resolution); + RESOLUTION + .get() + .ok_or_else(|| FbuildError::Other("long-path resolution was not initialized".into())) +} + +/// Inspect the startup snapshot without triggering policy reads or filesystem work. +pub fn current_resolution() -> Option<&'static Resolution> { + RESOLUTION.get() +} + +/// Advisory from the startup snapshot, if fallback is active. +pub fn advisory() -> Option { + current_resolution().and_then(Resolution::advisory) +} + +/// Validate paths using the startup snapshot; uninitialized legacy callers retain +/// their existing behavior until their entry point explicitly initializes policy. +pub fn validate_legacy_path(path: &Path) -> Result<()> { + current_resolution().map_or(Ok(()), |resolution| resolution.validate_legacy_path(path)) +} + +/// Prepare automatic fallback output hierarchies using the startup snapshot. +pub fn prepare_build_directory(path: &Path) -> Result<()> { + current_resolution().map_or(Ok(()), |resolution| { + resolution.prepare_build_directory(path) + }) +} + +/// Resolve a potentially deep build root using the initialized snapshot. +pub fn resolve_build_root(project_dir: &Path, requested_root: &Path) -> PathBuf { + current_resolution().map_or_else( + || requested_root.to_path_buf(), + |resolution| resolution.resolve_build_root(project_dir, requested_root), + ) +} + +struct Inputs { + home: PathBuf, + fbuild_root: PathBuf, + #[cfg(test)] + short_base: PathBuf, + cache_override: Option, + build_override: Option, + dev_mode: bool, + observation: Observation, +} + +#[derive(Debug, Serialize, Deserialize)] +struct SelectionMarker { + schema: u32, + cache_root: PathBuf, + scratch_root: PathBuf, +} + +#[cfg(test)] +fn resolve(input: Inputs) -> Result { + let short_base = input.short_base.clone(); + resolve_with_short_base(input, || Ok(short_base)) +} + +fn resolve_with_short_base(input: Inputs, short_base: F) -> Result +where + F: FnOnce() -> std::io::Result, +{ + let requested = input + .cache_override + .as_ref() + .unwrap_or(&input.fbuild_root.join("cache")) + .to_path_buf(); + let requested_cache_root = absolute_normalized(&requested)?; + let build_override = input + .build_override + .as_ref() + .map(|path| absolute_normalized(path)) + .transpose()?; + let mut resolution = Resolution { + observation: input.observation.clone(), + home: input.home.clone(), + fbuild_root: input.fbuild_root.clone(), + dev_mode: input.dev_mode, + requested_cache_root: requested_cache_root.clone(), + cache_root: requested_cache_root, + scratch_root: input.fbuild_root.join("tmp"), + short_build_root: None, + build_override, + cache_dir_source: if input.cache_override.is_some() { + "FBUILD_CACHE_DIR" + } else { + "default" + }, + }; + if matches!(input.observation, Observation::NotApplicable) { + return Ok(resolution); + } + let marker_path = input.fbuild_root.join(MARKER_FILE); + if !input.observation.requires_fallback() { + if input.cache_override.is_none() { + if let Some(marker) = read_marker(&marker_path)? { + let roots = automatic_roots(&input, short_base)?; + validate_marker(&marker_path, &marker, &roots.cache, &roots.scratch)?; + ensure_automatic_roots(&roots, false)?; + resolution.cache_root = marker.cache_root; + resolution.scratch_root = marker.scratch_root; + resolution.cache_dir_source = "retained-windows-short-path-fallback"; + } + } + return Ok(resolution); + } + if input.cache_override.is_some() + && fs::legacy_path_units(&resolution.requested_cache_root) < MAX_SHORT_ROOT_UNITS + { + // This root is intentionally configured by the user. Do not rewrite its + // ACL or depend on an automatic drive-root location on restricted hosts. + let scratch = resolution.cache_root.join("t"); + let builds = resolution.cache_root.join("b"); + validate_short_root(&scratch)?; + validate_short_root(&builds)?; + ensure_writable(&resolution.cache_root)?; + ensure_sidecar(&scratch)?; + ensure_sidecar(&builds)?; + resolution.scratch_root = scratch; + resolution.short_build_root = Some(builds); + } else { + let roots = automatic_roots(&input, short_base)?; + validate_short_root(&roots.cache)?; + validate_short_root(&roots.scratch)?; + ensure_automatic_roots(&roots, true)?; + resolution.cache_root = roots.cache.clone(); + resolution.scratch_root = roots.scratch.clone(); + resolution.short_build_root = Some(roots.builds); + resolution.cache_dir_source = "windows-short-path-fallback"; + write_marker( + &marker_path, + &SelectionMarker { + schema: MARKER_SCHEMA, + cache_root: roots.cache, + scratch_root: roots.scratch, + }, + )?; + } + Ok(resolution) +} + +struct ShortRoots { + user: PathBuf, + mode: PathBuf, + cache: PathBuf, + scratch: PathBuf, + builds: PathBuf, +} + +fn automatic_roots(input: &Inputs, short_base: F) -> Result +where + F: FnOnce() -> std::io::Result, +{ + let base = short_base().map_err(|error| fallback_error("resolve the short root", &error))?; + let user = absolute_normalized(&base)?.join(format!("fb-{}", path_hash(&input.home))); + let mode = user.join(if input.dev_mode { "dev" } else { "prod" }); + Ok(ShortRoots { + cache: mode.join("cache"), + scratch: mode.join("tmp"), + builds: mode.join("b"), + mode, + user, + }) +} + +fn ensure_automatic_roots(roots: &ShortRoots, include_builds: bool) -> Result<()> { + for path in [&roots.user, &roots.mode, &roots.cache, &roots.scratch] { + fs::ensure_private_directory(path).map_err(|error| { + fallback_error( + &format!("secure private directory {}", path.display()), + &error, + ) + })?; + } + ensure_writable(&roots.cache)?; + ensure_writable(&roots.scratch)?; + if include_builds { + fs::ensure_private_directory(&roots.builds).map_err(|error| { + fallback_error( + &format!("secure private directory {}", roots.builds.display()), + &error, + ) + })?; + ensure_writable(&roots.builds)?; + } + Ok(()) +} + +fn ensure_sidecar(path: &Path) -> Result<()> { + match std::fs::symlink_metadata(path) { + Ok(metadata) => { + if fs::is_link_or_reparse(path)? || !metadata.is_dir() { + return Err(FbuildError::ConfigError(format!( + "Windows short-path fallback sidecar {} must be a real directory, not a link or file. Choose another short FBUILD_CACHE_DIR or run fbuild --fix-win.", + path.display() + ))); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + std::fs::create_dir(path).map_err(|error| { + fallback_error(&format!("create sidecar {}", path.display()), &error) + })?; + } + Err(error) => { + return Err(fallback_error( + &format!("inspect sidecar {}", path.display()), + &error, + )); + } + } + ensure_writable(path) +} + +fn absolute_normalized(path: &Path) -> Result { + if path.as_os_str().is_empty() { + return Err(FbuildError::ConfigError( + "fbuild path overrides must not be empty".into(), + )); + } + let absolute = std::path::absolute(path)?; + Ok(NormalizedPath::new(fs::strip_extended_prefix(&absolute)).into_path_buf()) +} + +fn path_hash(path: &Path) -> String { + let normalized = NormalizedPath::new(path); + blake3::hash(normalized.key().as_bytes()).to_hex().as_str()[..HASH_HEX_UNITS].to_string() +} + +fn build_root_hash(project: &Path, requested_root: &Path) -> String { + let project = NormalizedPath::new(project); + let requested_root = NormalizedPath::new(requested_root); + let mut hasher = blake3::Hasher::new(); + // Length framing keeps the two identity dimensions distinct even when + // pathname characters would otherwise make a textual separator ambiguous. + for key in [project.key(), requested_root.key()] { + hasher.update(&(key.len() as u64).to_le_bytes()); + hasher.update(key.as_bytes()); + } + hasher.finalize().to_hex().as_str()[..HASH_HEX_UNITS].to_string() +} + +fn validate_short_root(path: &Path) -> Result<()> { + let units = fs::legacy_path_units(path) + 1; + if units > MAX_SHORT_ROOT_UNITS { + return Err(FbuildError::ConfigError(format!( + "Windows short-path fallback root {} is too long ({units} path units including NUL; maximum {MAX_SHORT_ROOT_UNITS} to reserve SDK path space). Run fbuild --fix-win or set FBUILD_CACHE_DIR to a shorter writable location.", + path.display() + ))); + } + Ok(()) +} + +fn ensure_writable(path: &Path) -> Result<()> { + std::fs::create_dir_all(path) + .map_err(|error| fallback_error(&format!("create {}", path.display()), &error))?; + let mut probe = tempfile::Builder::new() + .prefix(".write-probe-") + .tempfile_in(path) + .map_err(|error| fallback_error(&format!("write to {}", path.display()), &error))?; + probe + .write_all(b"fbuild") + .and_then(|()| probe.as_file().sync_all()) + .map_err(|error| fallback_error(&format!("write to {}", path.display()), &error))?; + Ok(()) +} + +fn fallback_error(action: &str, error: &std::io::Error) -> FbuildError { + FbuildError::ConfigError(format!( + "Cannot establish Windows short-path fallback: unable to {action}: {error}. Run fbuild --fix-win, or choose a writable short FBUILD_CACHE_DIR." + )) +} + +fn read_marker(path: &Path) -> Result> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(fallback_error( + &format!("read cache selection {}", path.display()), + &error, + )); + } + }; + let marker: SelectionMarker = serde_json::from_slice(&bytes).map_err(|error| { + FbuildError::ConfigError(format!( + "Invalid Windows short-path cache selection {}: {error}", + path.display() + )) + })?; + Ok(Some(marker)) +} + +fn validate_marker( + path: &Path, + marker: &SelectionMarker, + expected_cache: &Path, + expected_scratch: &Path, +) -> Result<()> { + if marker.schema != MARKER_SCHEMA + || NormalizedPath::new(&marker.cache_root) != NormalizedPath::new(expected_cache) + || NormalizedPath::new(&marker.scratch_root) != NormalizedPath::new(expected_scratch) + { + return Err(FbuildError::ConfigError(format!( + "Invalid Windows short-path cache selection {}: the saved roots do not match this user's mode and short-path root", + path.display() + ))); + } + Ok(()) +} + +fn write_marker(path: &Path, marker: &SelectionMarker) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| FbuildError::ConfigError("cache selection marker has no parent".into()))?; + std::fs::create_dir_all(parent).map_err(|error| { + fallback_error( + &format!("create cache selection directory {}", parent.display()), + &error, + ) + })?; + let bytes = + serde_json::to_vec(marker).map_err(|error| FbuildError::Other(error.to_string()))?; + let mut pending = tempfile::Builder::new() + .prefix(".short-paths-") + .tempfile_in(parent) + .map_err(|error| { + fallback_error(&format!("write cache selection {}", path.display()), &error) + })?; + pending + .write_all(&bytes) + .and_then(|()| pending.as_file().sync_all()) + .map_err(|error| { + fallback_error(&format!("write cache selection {}", path.display()), &error) + })?; + fs::replace_file(pending.path(), path).map_err(|error| { + fallback_error( + &format!("replace cache selection {}", path.display()), + &error, + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/fbuild-paths/src/long_paths/README.md b/crates/fbuild-paths/src/long_paths/README.md new file mode 100644 index 000000000..2fc47080f --- /dev/null +++ b/crates/fbuild-paths/src/long_paths/README.md @@ -0,0 +1,3 @@ +# Long-path resolver tests + +`tests.rs` exercises policy snapshots, writable fallback paths, durable cache retention, normalized build-root isolation, private hierarchy rejection, and manual-cache sidecars with real temporary directories. diff --git a/crates/fbuild-paths/src/long_paths/tests.rs b/crates/fbuild-paths/src/long_paths/tests.rs new file mode 100644 index 000000000..b20f52e95 --- /dev/null +++ b/crates/fbuild-paths/src/long_paths/tests.rs @@ -0,0 +1,522 @@ +use super::*; + +struct Fixture { + path: PathBuf, + temp: Option, +} + +impl Fixture { + fn path(&self) -> &Path { + &self.path + } +} + +impl Drop for Fixture { + fn drop(&mut self) { + if self.temp.is_none() { + let _ = std::fs::remove_dir_all(&self.path); + } + } +} + +fn tempdir() -> Fixture { + if fbuild_core::platform::host::is_windows() { + let base = fbuild_core::platform::fs::short_cache_base().unwrap(); + static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + let nonce = format!( + "{}|{}|{}", + std::process::id(), + std::time::UNIX_EPOCH.elapsed().unwrap().as_nanos(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + ); + let path = base.join(format!( + "p{}", + &blake3::hash(nonce.as_bytes()).to_hex().as_str()[..12] + )); + // Native creation explicitly sets TokenUser ownership. A default + // TempDir's TokenOwner may be the Administrators group when elevated. + fs::ensure_private_directory(&path).unwrap(); + Fixture { path, temp: None } + } else { + let temp = tempfile::Builder::new().prefix("p").tempdir().unwrap(); + Fixture { + path: temp.path().to_path_buf(), + temp: Some(temp), + } + } +} + +fn inputs(temp: &Fixture, observation: Observation) -> Inputs { + let home = temp.path().join("home"); + let short_base = temp.path().to_path_buf(); + Inputs { + fbuild_root: home.join(crate::FBUILD_DIR_NAME).join("prod"), + home, + short_base, + cache_override: None, + build_override: None, + dev_mode: false, + observation, + } +} + +#[test] +fn disabled_policy_creates_short_writable_cache_and_scratch() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + assert!(resolution.cache_root.starts_with(temp.path())); + assert!(resolution.cache_root.is_dir()); + assert!(resolution.scratch_root.is_dir()); + assert_ne!(resolution.cache_root, resolution.requested_cache_root); + assert_eq!(resolution.cache_dir_source, "windows-short-path-fallback"); + let advisory = resolution.advisory().unwrap(); + assert!(advisory.contains("disabled")); + assert!(advisory.contains("fbuild --fix-win")); + assert!(advisory.contains(&resolution.cache_root.display().to_string())); +} + +#[test] +fn short_explicit_cache_is_preserved_and_still_warns() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + let selected = temp.path().join("explicit"); + input.cache_override = Some(selected.clone()); + let resolution = resolve(input).unwrap(); + assert_eq!(resolution.cache_root, selected); + assert_eq!(resolution.cache_dir_source, "FBUILD_CACHE_DIR"); + assert!(resolution.advisory().is_some()); + assert!(resolution.scratch_root.starts_with(&selected)); +} + +#[test] +fn deep_explicit_cache_uses_fallback_and_reports_both_paths() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + let requested = temp.path().join("long".repeat(30)); + input.cache_override = Some(requested.clone()); + let resolution = resolve(input).unwrap(); + assert_ne!(resolution.cache_root, requested); + assert_eq!(resolution.requested_cache_root, requested); + let advisory = resolution.advisory().unwrap(); + assert!(advisory.contains(&requested.display().to_string())); + assert!(advisory.contains(&resolution.cache_root.display().to_string())); +} + +#[test] +fn enabled_policy_retains_automatic_cache_from_durable_marker() { + let temp = tempdir(); + let first = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + std::fs::write(first.cache_root.join("package"), b"existing package").unwrap(); + let next = resolve(inputs(&temp, Observation::Enabled)).unwrap(); + assert_eq!(first.cache_root, next.cache_root); + assert_eq!( + next.cache_dir_source, + "retained-windows-short-path-fallback" + ); + assert!(next.advisory().is_none()); + assert_eq!( + std::fs::read(next.cache_root.join("package")).unwrap(), + b"existing package" + ); +} + +#[test] +fn explicit_cache_change_wins_over_retained_selection() { + let temp = tempdir(); + resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let mut input = inputs(&temp, Observation::Enabled); + let new_cache = temp.path().join("new-cache"); + input.cache_override = Some(new_cache.clone()); + assert_eq!(resolve(input).unwrap().cache_root, new_cache); +} + +#[test] +fn short_explicit_cache_works_when_automatic_base_is_unavailable() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + input.short_base = temp.path().join("unavailable-base"); + std::fs::write(&input.short_base, b"not a directory").unwrap(); + let selected = temp.path().join("explicit"); + input.cache_override = Some(selected.clone()); + let resolution = resolve(input).unwrap(); + assert_eq!(resolution.cache_root, selected); + assert!(resolution.scratch_root.starts_with(&selected)); + assert!(resolution.short_build_root.unwrap().starts_with(&selected)); +} + +#[test] +fn enabled_policy_does_not_query_an_unused_short_base() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Enabled); + let queried = std::cell::Cell::new(false); + let resolution = resolve_with_short_base(input, || { + queried.set(true); + Err(std::io::Error::other("SystemDrive unavailable")) + }) + .unwrap(); + assert!(!queried.get()); + assert_eq!(resolution.cache_dir_source, "default"); +} + +#[test] +fn enabled_explicit_cache_has_no_legacy_budget_or_drive_dependency() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Enabled); + let selected = temp.path().join("long".repeat(30)); + input.cache_override = Some(selected.clone()); + let resolution = resolve_with_short_base(input, || { + panic!("an enabled explicit cache must not query the automatic drive") + }) + .unwrap(); + assert_eq!(resolution.cache_root, selected); + assert_eq!(resolution.cache_dir_source, "FBUILD_CACHE_DIR"); +} + +#[test] +fn retained_marker_cannot_select_a_different_users_cache() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let marker_path = input.fbuild_root.join(MARKER_FILE); + let first = resolve(input).unwrap(); + let marker = SelectionMarker { + schema: MARKER_SCHEMA, + cache_root: temp.path().join("unrelated-cache"), + scratch_root: first.scratch_root, + }; + std::fs::write(&marker_path, serde_json::to_vec(&marker).unwrap()).unwrap(); + assert!(resolve(inputs(&temp, Observation::Enabled)).is_err()); +} + +#[test] +fn malformed_retention_marker_is_reported() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let marker_path = input.fbuild_root.join(MARKER_FILE); + resolve(input).unwrap(); + std::fs::write(marker_path, b"not JSON").unwrap(); + assert!(resolve(inputs(&temp, Observation::Enabled)).is_err()); +} + +#[test] +fn explicit_cache_is_normalized_before_shortness_is_checked() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + input.cache_override = Some(temp.path().join("long".repeat(30)).join("..").join("cache")); + let resolution = resolve(input).unwrap(); + assert_eq!(resolution.cache_root, temp.path().join("cache")); + assert_eq!(resolution.cache_dir_source, "FBUILD_CACHE_DIR"); +} + +#[test] +fn repeated_fallback_initialization_atomically_replaces_marker() { + let temp = tempdir(); + let first = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let second = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + assert_eq!(first.cache_root, second.cache_root); + let enabled = resolve(inputs(&temp, Observation::Enabled)).unwrap(); + assert_eq!(first.cache_root, enabled.cache_root); +} + +#[test] +fn unknown_policy_uses_fallback_with_query_error_in_warning() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Unknown("access denied".into()))).unwrap(); + assert!(resolution.short_build_root.is_some()); + let advisory = resolution.advisory().unwrap(); + assert!(advisory.contains("could not be checked")); + assert!(advisory.contains("access denied")); +} + +#[test] +fn dev_and_prod_fallbacks_do_not_share_artifacts() { + let temp = tempdir(); + let prod = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let mut input = inputs(&temp, Observation::Disabled); + input.dev_mode = true; + input.fbuild_root = input.home.join(crate::FBUILD_DIR_NAME).join("dev"); + let dev = resolve(input).unwrap(); + assert_ne!(dev.cache_root, prod.cache_root); + assert_ne!(dev.scratch_root, prod.scratch_root); + assert_ne!(dev.short_build_root, prod.short_build_root); +} + +#[test] +fn deep_build_outputs_are_keyed_by_normalized_project() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let first = temp.path().join("project-one"); + let second = temp.path().join("project-two"); + std::fs::create_dir_all(&first).unwrap(); + std::fs::create_dir_all(&second).unwrap(); + let deep = first.join("deep".repeat(35)); + let first_root = resolution.resolve_build_root(&first, &deep); + let second_root = resolution.resolve_build_root(&second, &deep); + assert!(first_root.starts_with(resolution.short_build_root.as_ref().unwrap())); + assert_ne!(first_root, second_root); + assert_eq!( + first_root, + resolution.resolve_build_root(&first.join("."), &deep) + ); + let short = temp.path().join("build"); + assert_eq!(resolution.resolve_build_root(&first, &short), short); +} + +#[test] +fn short_build_override_returns_normalized_path() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let requested = temp.path().join("long".repeat(30)).join("..").join("build"); + assert_eq!( + resolution.resolve_build_root(&project, &requested), + temp.path().join("build"), + ); +} + +#[test] +fn distinct_deep_build_overrides_for_same_project_do_not_collide() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let project = temp.path().join("project"); + std::fs::create_dir_all(&project).unwrap(); + let first = project.join("first".repeat(30)); + let second = project.join("second".repeat(30)); + let first_resolved = resolution.resolve_build_root(&project, &first); + let second_resolved = resolution.resolve_build_root(&project, &second); + assert_ne!(first_resolved, second_resolved); + assert_eq!( + first_resolved, + resolution.resolve_build_root(&project.join("."), &first.join(".")), + ); + resolution.validate_legacy_path(&first_resolved).unwrap(); + resolution.validate_legacy_path(&second_resolved).unwrap(); +} + +#[test] +fn prepares_private_automatic_build_directory_before_writing_outputs() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let project = temp.path().join("project"); + let deep = project.join("deep".repeat(30)); + let build = resolution + .resolve_build_root(&project, &deep) + .join("env") + .join("release"); + resolution.prepare_build_directory(&build).unwrap(); + assert!(build.is_dir()); + assert!(!fs::is_link_or_reparse(&build).unwrap()); +} + +#[test] +fn private_build_preparation_rejects_linked_project_ancestor() { + let temp = tempdir(); + let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let project = temp.path().join("project"); + let deep = project.join("deep".repeat(30)); + let build = resolution.resolve_build_root(&project, &deep); + let base = resolution.short_build_root.as_ref().unwrap(); + let project_root = base.join( + build + .strip_prefix(base) + .unwrap() + .components() + .next() + .unwrap() + .as_os_str(), + ); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &project_root) { + return; + } + assert!( + resolution + .prepare_build_directory(&build.join("env").join("release")) + .is_err() + ); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn build_preparation_leaves_intentional_cache_and_project_paths_untouched() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + input.cache_override = Some(temp.path().join("explicit")); + let explicit = resolve(input).unwrap(); + let project = temp.path().join("project"); + let deep = project.join("deep".repeat(30)); + let manual_build = explicit.resolve_build_root(&project, &deep); + explicit.prepare_build_directory(&manual_build).unwrap(); + assert!(!manual_build.exists()); + let automatic = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let local = temp.path().join("build"); + automatic.prepare_build_directory(&local).unwrap(); + assert!(!local.exists()); +} + +fn link_fixture(original: &Path, link: &Path) -> bool { + match fs::symlink_dir(original, link) { + Ok(()) => true, + Err(error) + if fbuild_core::platform::host::is_windows() + && error.kind() == std::io::ErrorKind::PermissionDenied => + { + false + } + Err(error) => panic!("cannot create directory link fixture: {error}"), + } +} + +#[test] +fn fallback_rejects_linked_private_user_root_without_writing_through_it() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let private_root = input + .short_base + .join(format!("fb-{}", path_hash(&input.home))); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &private_root) { + return; + } + assert!(resolve(input).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn fallback_rejects_linked_mode_without_writing_through_it() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let private_root = input + .short_base + .join(format!("fb-{}", path_hash(&input.home))); + fs::ensure_private_directory(&private_root).unwrap(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &private_root.join("prod")) { + return; + } + assert!(resolve(input).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn fallback_rejects_linked_cache_without_writing_through_it() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let mode_root = input + .short_base + .join(format!("fb-{}", path_hash(&input.home))) + .join("prod"); + fs::ensure_private_directory(&mode_root).unwrap(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &mode_root.join("cache")) { + return; + } + assert!(resolve(input).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn fallback_rejects_linked_scratch_without_writing_through_it() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + let mode_root = input + .short_base + .join(format!("fb-{}", path_hash(&input.home))) + .join("prod"); + fs::ensure_private_directory(&mode_root).unwrap(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &mode_root.join("tmp")) { + return; + } + assert!(resolve(input).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn enabled_policy_rejects_linked_retained_cache_before_reusing_it() { + let temp = tempdir(); + let first = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let saved = temp.path().join("saved-cache"); + std::fs::rename(&first.cache_root, &saved).unwrap(); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &first.cache_root) { + return; + } + assert!(resolve(inputs(&temp, Observation::Enabled)).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); +} + +#[test] +fn explicit_cache_rejects_linked_sidecars_without_writing_through_them() { + for name in ["t", "b"] { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + let configured = temp.path().join("explicit"); + let outside = temp.path().join("outside"); + std::fs::create_dir_all(&configured).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + if !link_fixture(&outside, &configured.join(name)) { + return; + } + input.cache_override = Some(configured); + assert!(resolve(input).is_err()); + assert_eq!(std::fs::read_dir(outside).unwrap().count(), 0); + } +} + +#[test] +fn fallback_failure_does_not_continue_with_deep_paths() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + input.short_base = temp.path().join("blocked-base"); + std::fs::write(&input.short_base, b"not a directory").unwrap(); + input.cache_override = Some(temp.path().join("long".repeat(30))); + let error = resolve(input).unwrap_err().to_string(); + assert!(error.contains("fbuild --fix-win")); + assert!(error.contains("short-path")); +} + +#[test] +fn marker_write_failure_is_reported() { + let temp = tempdir(); + let input = inputs(&temp, Observation::Disabled); + std::fs::create_dir_all(input.fbuild_root.parent().unwrap()).unwrap(); + std::fs::write(&input.fbuild_root, b"not a directory").unwrap(); + assert!(resolve(input).is_err()); +} + +#[test] +fn oversized_short_base_is_rejected() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + input.short_base = temp.path().join("too-long".repeat(15)); + assert!(resolve(input).is_err()); +} + +#[test] +fn legacy_path_gate_is_active_only_while_fallback_is_needed() { + let temp = tempdir(); + let deep = temp.path().join("long".repeat(70)); + let fallback = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + assert!(fallback.validate_legacy_path(&deep).is_err()); + let enabled = resolve(inputs(&temp, Observation::Enabled)).unwrap(); + assert!(enabled.validate_legacy_path(&deep).is_ok()); +} + +#[test] +fn non_windows_resolution_does_not_create_fallback_or_warning() { + let temp = tempdir(); + let input = inputs(&temp, Observation::NotApplicable); + let expected = input.fbuild_root.join("cache"); + let resolution = resolve(input).unwrap(); + assert_eq!(resolution.cache_root, expected); + assert!(resolution.short_build_root.is_none()); + assert!(resolution.advisory().is_none()); + assert_eq!(std::fs::read_dir(temp.path()).unwrap().count(), 0); +} diff --git a/crates/fbuild-paths/src/running_process.rs b/crates/fbuild-paths/src/running_process.rs index 2d576fad4..5defeb16b 100644 --- a/crates/fbuild-paths/src/running_process.rs +++ b/crates/fbuild-paths/src/running_process.rs @@ -134,11 +134,15 @@ pub struct DaemonCacheIdentity { impl DaemonCacheIdentity { pub fn discover() -> Self { let cache_root = crate::get_cache_root(); - Self::from_resolved( + let mut identity = Self::from_resolved( cache_root, crate::is_dev_mode(), std::env::var_os(FBUILD_CACHE_DIR_ENV).is_some(), - ) + ); + if let Some(resolution) = crate::long_paths::current_resolution() { + identity.cache_dir_source = resolution.cache_dir_source; + } + identity } fn from_resolved(cache_root: PathBuf, dev_mode: bool, cache_dir_overridden: bool) -> Self { @@ -217,7 +221,9 @@ impl CacheRoots { /// this crate's path resolution. pub fn discover(runtime_dir: impl Into) -> Self { let cache = crate::get_cache_root(); - Self::from_resolved(runtime_dir.into(), cache, crate::get_fbuild_root()) + let mut roots = Self::from_resolved(runtime_dir.into(), cache, crate::get_fbuild_root()); + roots.temp = crate::dev_or_prod_temp_root(); + roots } fn from_resolved(runtime_dir: PathBuf, cache: PathBuf, fbuild_root: PathBuf) -> Self { diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 78f1db383..93a181a19 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -31,6 +31,50 @@ FBUILD_BUILD_RELEASE=1 pip install . setuptools backend does not interpret that setting. On Windows, set the same environment variable before invoking pip. +## Windows Long Paths + +ESP32 SDKs can exceed Windows' legacy path limits. Enable long paths with: + +```powershell +fbuild --fix-win +``` + +This requests Windows administrator approval and changes only +`HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled` to DWORD `1`. +If it is already enabled, no elevation is requested. The elevated helper exits +without starting a daemon or a build. Interactive ESP32 commands also offer a +`[y/N]` repair prompt; after approval, the pending command resumes in a fresh +process with ordinary privileges. Existing daemon operations finish before +replacement. Help, version, diagnostics, and the repair command remain usable. + +When repair is declined, cancelled, blocked by policy, or unavailable in CI, +fbuild selects a short cache and warns on stderr. Terminal warnings are yellow; +redirected output and HTTP responses contain plain text. The warning gives the +effective cache, extraction scratch, build fallback, and repair command. + +The automatic location is a private directory directly under the Windows +system drive, for example `C:\fb-\prod\{cache,tmp,b}`. Dev mode +uses a separate `dev` directory. fbuild rejects foreign-owned directories and +junctions. If it cannot create this directory, set `FBUILD_CACHE_DIR` to a +short writable location, for example `C:\Users\me\fc`. A suitable explicit +cache uses its `t` and `b` subdirectories for scratch and deep build outputs; +fbuild reports when a deep explicit cache requires an automatic replacement. +`FBUILD_BUILD_DIR` can also shorten build output paths. These environment +variables shorten locations; they do not enable the Windows registry setting. + +After enabling long paths, fbuild retains an automatic cache to avoid another +download. An explicit cache change takes effect on the next invocation. Running +operations keep their selected paths; a changed setting or cache causes the +daemon to drain before replacement. A command started during repeated changes +may ask you to retry. Projects, compiler inputs, or archive members that still +exceed legacy limits fail with the offending path and repair guidance. Moving +the project closer to the drive root is another option. Some external tools +retain their own limits even with Windows long paths enabled. + +Windows requires both the registry setting and a long-path-aware application; +fbuild's CLI and daemon include that declaration. See Microsoft's +[long-path documentation](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation). + ## First Project Create a project with a PlatformIO-compatible layout: diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 58521e0b5..0fe8241af 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -19,6 +19,12 @@ Global options: | `--platformio` | Use PlatformIO compatibility mode where supported. | | `--shrink[=]` | Flash-size reduction mode: `auto`, `off`, `safe`, `aggressive`, or `printf`. | | `--no-shrink` | Disable all shrink optimizations. | +| `--fix-win` | Enable Windows long paths with UAC approval, then exit without building. | + +Windows ESP32 commands offer a `[y/N]` repair prompt in an interactive terminal. +CI and redirected input use a short-path fallback with a warning. See +[Windows long paths](../getting-started/README.md#windows-long-paths) for cache +overrides, restart behavior, and remaining tool limits. ## Core Workflows From ec2b2c1171107ac3e322da77256eff4c7ecc69de Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 22:42:49 -0700 Subject: [PATCH 02/11] ci: refresh platform inventory and document contained command restart --- ci/platform_boundary_research.tsv | 94 +++++++++++++++---- .../fbuild-cli/src/cli/windows_long_paths.rs | 1 + 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index d27207355..4f00a5328 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -10,13 +10,17 @@ crates/fbuild-core/src/platform/linux/device.rs 33 native_path std::os::unix::fs crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/device.rs 41 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 57 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 61 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 105 native_path std::os::unix::ffi::OsStrExt process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 110 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 112 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/linux/fs.rs 122 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 31 native_path std::os::unix::fs fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 51 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 51 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 54 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 68 native_path std::os::unix::fs::MetadataExt fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 112 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 116 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 158 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 163 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 165 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/linux/fs.rs 175 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/linux/ipc.rs 3 native_path interprocess::os::unix ipc host_mechanic @@ -25,13 +29,17 @@ crates/fbuild-core/src/platform/linux/ipc.rs 66 native_path std::os::unix::fs::P crates/fbuild-core/src/platform/linux/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/linux/process.rs 1 native_path std::os::unix::process::ExitStatusExt process host_mechanic crates/fbuild-core/src/platform/macos/fs.rs 2 native_path std::os::unix::fs::PermissionsExt fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 13 native_path std::os::unix::fs::MetadataExt fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 57 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 61 native_path std::os::unix::fs::symlink fs host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 105 native_path std::os::unix::ffi::OsStrExt process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 110 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 112 native_path libc:: process host_mechanic -crates/fbuild-core/src/platform/macos/fs.rs 122 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 31 native_path std::os::unix::fs fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 51 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 51 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 54 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 68 native_path std::os::unix::fs::MetadataExt fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 112 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 116 native_path std::os::unix::fs::symlink fs host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 158 native_path std::os::unix::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 163 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 165 native_path libc:: process host_mechanic +crates/fbuild-core/src/platform/macos/fs.rs 175 native_path libc:: process host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/macos/ipc.rs 3 native_path socket2:: ipc host_mechanic @@ -47,18 +55,64 @@ crates/fbuild-core/src/platform/windows/device.rs 35 native_path windows_sys:: p crates/fbuild-core/src/platform/windows/fs.rs 2 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 3 native_path std::os::windows::fs fs host_mechanic crates/fbuild-core/src/platform/windows/fs.rs 4 native_path std::os::windows::io::AsRawHandle process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 60 native_path std::os::windows::fs::symlink_dir fs host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 64 native_path std::os::windows::fs::symlink_file fs host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 92 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 147 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/fs.rs 191 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 12 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 16 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 38 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 47 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 81 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 82 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 116 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 156 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 260 native_path std::os::windows::fs::symlink_dir fs host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 264 native_path std::os::windows::fs::symlink_file fs host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 292 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 352 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 394 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 405 attr_cfg #[cfg(windows)] host host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 417 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 418 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 438 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 439 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/fs.rs 458 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 1 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 2 native_path interprocess::local_socket ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 3 native_path interprocess::os::windows ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 4 native_path interprocess::os::windows ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 5 native_path socket2:: ipc host_mechanic crates/fbuild-core/src/platform/windows/ipc.rs 6 native_path std::os::windows::io::AsRawSocket ipc host_mechanic -crates/fbuild-core/src/platform/windows/mod.rs 13 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/windows/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 3 native_path std::os::windows::ffi::OsStrExt process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 6 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 11 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 21 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 28 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 30 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 34 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 35 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 36 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 53 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 79 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 83 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 178 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 179 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 220 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 230 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 248 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 249 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 285 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 288 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 361 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 381 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 382 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 383 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 479 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 480 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 523 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 524 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 554 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 585 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 586 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 590 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 87 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 89 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 92 native_path windows_sys:: process host_mechanic diff --git a/crates/fbuild-cli/src/cli/windows_long_paths.rs b/crates/fbuild-cli/src/cli/windows_long_paths.rs index 56ed1feb2..de86a6559 100644 --- a/crates/fbuild-cli/src/cli/windows_long_paths.rs +++ b/crates/fbuild-cli/src/cli/windows_long_paths.rs @@ -268,6 +268,7 @@ pub async fn prepare(cli: &Cli) -> Result> { async fn relaunch_command() -> Result { tokio::task::spawn_blocking(|| { let executable = fbuild_core::platform::executable::current_image()?; + // allow-direct-spawn: command construction only; spawn_contained owns execution and inherited terminal streams. let mut command = std::process::Command::new(executable.as_path()); command.args(std::env::args_os().skip(1)); let mut child = process::spawn_contained( From 1748b3a643e520fa08d89eb9ba845db0df459d65 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:04:13 -0700 Subject: [PATCH 03/11] fix: preserve selected Windows build roots and normalized path overrides --- ci/platform_boundary_research.tsv | 59 +++++------ .../fbuild-cli/src/cli/windows_long_paths.rs | 8 +- crates/fbuild-core/src/platform/fs.rs | 6 +- .../src/platform/windows/private_directory.rs | 47 +++++---- crates/fbuild-paths/src/lib.rs | 20 ++-- crates/fbuild-paths/src/long_paths.rs | 98 +++++++++++-------- crates/fbuild-paths/src/long_paths/tests.rs | 40 +++++++- 7 files changed, 173 insertions(+), 105 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index 4f00a5328..b1d259e8d 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -82,37 +82,38 @@ crates/fbuild-core/src/platform/windows/ipc.rs 5 native_path socket2:: ipc host_ crates/fbuild-core/src/platform/windows/ipc.rs 6 native_path std::os::windows::io::AsRawSocket ipc host_mechanic crates/fbuild-core/src/platform/windows/mod.rs 14 compile_host_fact std::env::consts::ARCH host host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 3 native_path std::os::windows::ffi::OsStrExt process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 6 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 11 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 21 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 28 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 8 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 13 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 23 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 30 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 34 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 35 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 32 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 36 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 53 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 79 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 83 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 178 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 179 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 220 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 230 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 248 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 249 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 285 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 288 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 361 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 381 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 382 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 383 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 479 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 480 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 523 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 524 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 554 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 585 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 586 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 590 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 37 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 38 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 55 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 81 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 85 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 180 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 181 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 222 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 232 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 250 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 251 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 287 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 290 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 363 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 377 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 385 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 386 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 387 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 492 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 493 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 536 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 537 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 567 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 598 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 599 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 603 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 87 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 89 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 92 native_path windows_sys:: process host_mechanic diff --git a/crates/fbuild-cli/src/cli/windows_long_paths.rs b/crates/fbuild-cli/src/cli/windows_long_paths.rs index de86a6559..132d77a76 100644 --- a/crates/fbuild-cli/src/cli/windows_long_paths.rs +++ b/crates/fbuild-cli/src/cli/windows_long_paths.rs @@ -121,11 +121,13 @@ fn is_esp32_operation(cli: &Cli) -> bool { }; let install = matches!(&cli.command, Some(Commands::Install { .. })); let config_path = if install { - Path::new(project).join("platformio.ini") + fbuild_core::path::NormalizedPath::new(Path::new(project).join("platformio.ini")) } else { std::env::var_os("PLATFORMIO_PROJECT_CONFIG") - .map(std::path::PathBuf::from) - .unwrap_or_else(|| Path::new(project).join("platformio.ini")) + .map(fbuild_core::path::NormalizedPath::new) + .unwrap_or_else(|| { + fbuild_core::path::NormalizedPath::new(Path::new(project).join("platformio.ini")) + }) }; let Ok(config) = fbuild_config::PlatformIOConfig::from_path_with_overrides( &config_path, diff --git a/crates/fbuild-core/src/platform/fs.rs b/crates/fbuild-core/src/platform/fs.rs index dd6b3393f..2574082b3 100644 --- a/crates/fbuild-core/src/platform/fs.rs +++ b/crates/fbuild-core/src/platform/fs.rs @@ -1,7 +1,7 @@ //! Neutral filesystem identity, permission, and replacement APIs. use std::fs::{File, OpenOptions}; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::path::NormalizedPath; @@ -73,8 +73,8 @@ pub fn legacy_path_units(path: &Path) -> usize { /// /// User isolation, directory creation, permissions, and cache policy belong /// to callers. Other hosts return `Unsupported`. -pub fn short_cache_base() -> std::io::Result { - super::selected::fs::short_cache_base() +pub fn short_cache_base() -> std::io::Result { + super::selected::fs::short_cache_base().map(NormalizedPath::new) } /// Establish an automatic cache directory owned and writable only by this user. diff --git a/crates/fbuild-core/src/platform/windows/private_directory.rs b/crates/fbuild-core/src/platform/windows/private_directory.rs index cd7c662fe..b6efe03f5 100644 --- a/crates/fbuild-core/src/platform/windows/private_directory.rs +++ b/crates/fbuild-core/src/platform/windows/private_directory.rs @@ -1,7 +1,9 @@ //! Native ownership and protected permissions for automatic cache directories. use std::os::windows::ffi::OsStrExt; -use std::path::{Path, PathBuf}; +use std::path::Path; + +use crate::path::NormalizedPath; struct NativeHandle(windows_sys::Win32::Foundation::HANDLE); @@ -137,7 +139,7 @@ fn private_directory_security() -> std::io::Result { }) } -fn private_directory_components(path: &Path) -> std::io::Result> { +fn private_directory_components(path: &Path) -> std::io::Result> { use std::path::{Component, Prefix}; let invalid = || { @@ -159,13 +161,13 @@ fn private_directory_components(path: &Path) -> std::io::Result> { if !matches!(components.next(), Some(Component::RootDir)) { return Err(invalid()); } - let mut current = PathBuf::from(format!("{}:\\", char::from(drive))); + let mut current = NormalizedPath::new(format!("{}:\\", char::from(drive))); let mut paths = vec![current.clone()]; for component in components { let Component::Normal(name) = component else { return Err(invalid()); }; - current.push(name); + current = current.join(Path::new(name)); paths.push(current.clone()); } if paths.len() < 2 { @@ -370,7 +372,9 @@ pub(crate) fn ensure_private_directory(path: &Path) -> std::io::Result<()> { if error.raw_os_error() == Some(ERROR_SHARING_VIOLATION as i32) && std::time::Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(20)); + // SAFETY: this native delay has no preconditions. This + // blocking capability runs on a dedicated blocking worker. + unsafe { windows_sys::Win32::System::Threading::Sleep(20) }; } result => return result, } @@ -431,28 +435,37 @@ mod tests { use super::*; /// Default Windows TempDir ownership can be TokenOwner (a group) rather - /// than TokenUser. Use its random filename, then create our directory with - /// explicit current-user ownership through the API under test. + /// than TokenUser. Create a uniquely named direct drive child with explicit + /// current-user ownership through the API under test. struct PrivateFixture { - path: PathBuf, - _name: tempfile::NamedTempFile, + path: NormalizedPath, } impl PrivateFixture { fn new() -> Self { - let name = tempfile::Builder::new() - .prefix("fb-private-") - .tempfile() - .unwrap(); - let path = short_cache_base() + use sha2::Digest; + use std::sync::atomic::{AtomicU64, Ordering}; + + static NEXT_FIXTURE: AtomicU64 = AtomicU64::new(0); + let counter = NEXT_FIXTURE.fetch_add(1, Ordering::Relaxed); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) .unwrap() - .join(name.path().file_name().unwrap()); + .as_nanos(); + let nonce = format!("{}-{timestamp}-{counter}", std::process::id()); + let digest = sha2::Sha256::digest(nonce.as_bytes()); + let suffix = digest[..6] + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let path = NormalizedPath::new(short_cache_base().unwrap()) + .join(format!("fb-private-{suffix}")); ensure_private_directory(&path).unwrap(); - Self { path, _name: name } + Self { path } } fn path(&self) -> &Path { - &self.path + self.path.as_path() } } diff --git a/crates/fbuild-paths/src/lib.rs b/crates/fbuild-paths/src/lib.rs index c9ac3c780..54155b48a 100644 --- a/crates/fbuild-paths/src/lib.rs +++ b/crates/fbuild-paths/src/lib.rs @@ -60,7 +60,7 @@ pub fn get_fbuild_root() -> PathBuf { /// had each hand-rolled this path to keep that property (FastLED/fbuild#1349). pub fn try_get_fbuild_root() -> Option { if let Some(resolution) = long_paths::current_resolution() { - return Some(resolution.fbuild_root.clone()); + return Some(resolution.fbuild_root.clone().into_path_buf()); } let mode = if is_dev_mode() { "dev" } else { "prod" }; Some(dirs_next()?.join(FBUILD_DIR_NAME).join(mode)) @@ -103,7 +103,7 @@ pub fn tools_dir_label() -> String { /// Used for cross-mode daemon discovery. pub fn get_other_fbuild_root() -> PathBuf { let home = long_paths::current_resolution() - .map(|resolution| resolution.home.clone()) + .map(|resolution| resolution.home.clone().into_path_buf()) .or_else(dirs_next) .expect("could not determine home directory"); let mode = if is_dev_mode() { "prod" } else { "dev" }; @@ -201,7 +201,7 @@ pub fn get_daemon_status_file() -> PathBuf { /// including a Windows short-path fallback when required. pub fn get_cache_root() -> PathBuf { if let Some(resolution) = long_paths::current_resolution() { - return resolution.cache_root.clone(); + return resolution.cache_root.clone().into_path_buf(); } if let Ok(dir) = std::env::var("FBUILD_CACHE_DIR") { return PathBuf::from(dir); @@ -223,7 +223,7 @@ pub fn get_cache_root() -> PathBuf { /// [`temp_subdir`] for the create-on-use pattern. pub fn dev_or_prod_temp_root() -> PathBuf { if let Some(resolution) = long_paths::current_resolution() { - return resolution.scratch_root.clone(); + return resolution.scratch_root.clone().into_path_buf(); } get_fbuild_root().join("tmp") } @@ -259,8 +259,11 @@ pub fn get_project_build_root(project_dir: &Path) -> PathBuf { let requested = resolution .build_override .clone() + .map(|root| root.into_path_buf()) .unwrap_or_else(|| get_project_fbuild_dir(project_dir).join(BUILD_DIR_NAME)); - return resolution.resolve_build_root(project_dir, &requested); + return resolution + .resolve_build_root(project_dir, &requested) + .into_path_buf(); } if let Ok(dir) = std::env::var("FBUILD_BUILD_DIR") { return PathBuf::from(dir); @@ -348,7 +351,7 @@ impl BuildLayout { /// Resolve the env-rooted build directory. pub fn resolve(&self) -> PathBuf { let root = if let Some(ref r) = self.override_root { - long_paths::resolve_build_root(&self.project_dir, r) + long_paths::resolve_build_root(&self.project_dir, r).into_path_buf() } else { get_project_build_root(&self.project_dir) }; @@ -547,6 +550,11 @@ fn dirs_next() -> Option { } } +/// Normalize the legacy home-directory boundary for new path resolvers. +pub(crate) fn normalized_home() -> Option { + dirs_next().map(fbuild_core::path::NormalizedPath::new) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/fbuild-paths/src/long_paths.rs b/crates/fbuild-paths/src/long_paths.rs index 41a6c59e8..6f3db2490 100644 --- a/crates/fbuild-paths/src/long_paths.rs +++ b/crates/fbuild-paths/src/long_paths.rs @@ -4,8 +4,9 @@ //! never changes the environment or emits output; each caller delivers the //! advisory through its own terminal or HTTP output channel. +use std::ffi::OsString; use std::io::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::{Mutex, OnceLock}; use fbuild_core::path::NormalizedPath; @@ -56,14 +57,14 @@ impl Observation { #[derive(Debug, Clone, Serialize)] pub struct Resolution { pub observation: Observation, - pub home: PathBuf, - pub fbuild_root: PathBuf, + pub home: NormalizedPath, + pub fbuild_root: NormalizedPath, pub dev_mode: bool, - pub requested_cache_root: PathBuf, - pub cache_root: PathBuf, - pub scratch_root: PathBuf, - pub short_build_root: Option, - pub build_override: Option, + pub requested_cache_root: NormalizedPath, + pub cache_root: NormalizedPath, + pub scratch_root: NormalizedPath, + pub short_build_root: Option, + pub build_override: Option, pub cache_dir_source: &'static str, } @@ -103,12 +104,15 @@ impl Resolution { /// Redirect a deep build root to a directory keyed by the normalized /// project and requested output root. /// The `.fbuild/build` suffix preserves the compiler workspace convention. - pub fn resolve_build_root(&self, project_dir: &Path, requested_root: &Path) -> PathBuf { + pub fn resolve_build_root(&self, project_dir: &Path, requested_root: &Path) -> NormalizedPath { let Some(base) = &self.short_build_root else { - return requested_root.to_path_buf(); + return NormalizedPath::new(requested_root); }; - let requested = - absolute_normalized(requested_root).unwrap_or_else(|_| requested_root.to_path_buf()); + let requested = absolute_normalized(requested_root) + .unwrap_or_else(|_| NormalizedPath::new(requested_root)); + if is_within(&requested, base) { + return requested; + } if fs::legacy_path_units(&requested) < MAX_SHORT_ROOT_UNITS { return requested; } @@ -117,7 +121,7 @@ impl Resolution { .ok() .and_then(|path| absolute_normalized(&path).ok()) .or_else(|| absolute_normalized(project_dir).ok()) - .unwrap_or_else(|| project_dir.to_path_buf()); + .unwrap_or_else(|| NormalizedPath::new(project_dir)); base.join(build_root_hash(&project, &requested)) .join(crate::FBUILD_DIR_NAME) .join(crate::BUILD_DIR_NAME) @@ -151,13 +155,7 @@ impl Resolution { return Ok(()); }; let absolute = absolute_normalized(path)?; - let candidate = NormalizedPath::new(&absolute); - let base = NormalizedPath::new(base); - let selected = candidate - .key() - .strip_prefix(base.key()) - .is_some_and(|tail| tail.is_empty() || tail.starts_with('/')); - if selected { + if is_within(&absolute, base) { // Verify each selected ancestor before creating its children. // This also prevents portable fixture helpers from recursively // creating through a linked ancestor before the final check. @@ -202,7 +200,7 @@ pub fn initialize() -> Result<&'static Resolution> { return Ok(resolution); } let observation = probe(); - let home = crate::dirs_next() + let home = crate::normalized_home() .ok_or_else(|| FbuildError::ConfigError("could not determine home directory".into()))?; let home = absolute_normalized(&home)?; let dev_mode = std::env::var("FBUILD_DEV_MODE").is_ok_and(|value| value == "1"); @@ -214,8 +212,8 @@ pub fn initialize() -> Result<&'static Resolution> { short_base: fbuild_root.clone(), home, fbuild_root, - cache_override: std::env::var_os("FBUILD_CACHE_DIR").map(PathBuf::from), - build_override: std::env::var_os("FBUILD_BUILD_DIR").map(PathBuf::from), + cache_override: override_from_value(std::env::var_os("FBUILD_CACHE_DIR"))?, + build_override: override_from_value(std::env::var_os("FBUILD_BUILD_DIR"))?, dev_mode, observation, }; @@ -250,20 +248,20 @@ pub fn prepare_build_directory(path: &Path) -> Result<()> { } /// Resolve a potentially deep build root using the initialized snapshot. -pub fn resolve_build_root(project_dir: &Path, requested_root: &Path) -> PathBuf { +pub fn resolve_build_root(project_dir: &Path, requested_root: &Path) -> NormalizedPath { current_resolution().map_or_else( - || requested_root.to_path_buf(), + || NormalizedPath::new(requested_root), |resolution| resolution.resolve_build_root(project_dir, requested_root), ) } struct Inputs { - home: PathBuf, - fbuild_root: PathBuf, + home: NormalizedPath, + fbuild_root: NormalizedPath, #[cfg(test)] - short_base: PathBuf, - cache_override: Option, - build_override: Option, + short_base: NormalizedPath, + cache_override: Option, + build_override: Option, dev_mode: bool, observation: Observation, } @@ -271,8 +269,8 @@ struct Inputs { #[derive(Debug, Serialize, Deserialize)] struct SelectionMarker { schema: u32, - cache_root: PathBuf, - scratch_root: PathBuf, + cache_root: NormalizedPath, + scratch_root: NormalizedPath, } #[cfg(test)] @@ -283,13 +281,13 @@ fn resolve(input: Inputs) -> Result { fn resolve_with_short_base(input: Inputs, short_base: F) -> Result where - F: FnOnce() -> std::io::Result, + F: FnOnce() -> std::io::Result, { let requested = input .cache_override .as_ref() .unwrap_or(&input.fbuild_root.join("cache")) - .to_path_buf(); + .clone(); let requested_cache_root = absolute_normalized(&requested)?; let build_override = input .build_override @@ -365,16 +363,16 @@ where } struct ShortRoots { - user: PathBuf, - mode: PathBuf, - cache: PathBuf, - scratch: PathBuf, - builds: PathBuf, + user: NormalizedPath, + mode: NormalizedPath, + cache: NormalizedPath, + scratch: NormalizedPath, + builds: NormalizedPath, } fn automatic_roots(input: &Inputs, short_base: F) -> Result where - F: FnOnce() -> std::io::Result, + F: FnOnce() -> std::io::Result, { let base = short_base().map_err(|error| fallback_error("resolve the short root", &error))?; let user = absolute_normalized(&base)?.join(format!("fb-{}", path_hash(&input.home))); @@ -436,14 +434,30 @@ fn ensure_sidecar(path: &Path) -> Result<()> { ensure_writable(path) } -fn absolute_normalized(path: &Path) -> Result { +fn absolute_normalized(path: &Path) -> Result { if path.as_os_str().is_empty() { return Err(FbuildError::ConfigError( "fbuild path overrides must not be empty".into(), )); } let absolute = std::path::absolute(path)?; - Ok(NormalizedPath::new(fs::strip_extended_prefix(&absolute)).into_path_buf()) + Ok(NormalizedPath::new(fs::strip_extended_prefix(&absolute))) +} + +/// Resolve raw override spellings before normalization can collapse `.` or +/// `child/..` into an empty relative path. Actual empty values remain errors. +fn override_from_value(value: Option) -> Result> { + value + .as_deref() + .map(|raw| absolute_normalized(Path::new(raw))) + .transpose() +} + +/// Lexical, component-bounded membership never follows an untrusted link. +fn is_within(path: &NormalizedPath, root: &NormalizedPath) -> bool { + path.key() + .strip_prefix(root.key()) + .is_some_and(|tail| tail.is_empty() || tail.starts_with('/')) } fn path_hash(path: &Path) -> String { diff --git a/crates/fbuild-paths/src/long_paths/tests.rs b/crates/fbuild-paths/src/long_paths/tests.rs index b20f52e95..646009bdf 100644 --- a/crates/fbuild-paths/src/long_paths/tests.rs +++ b/crates/fbuild-paths/src/long_paths/tests.rs @@ -1,12 +1,12 @@ use super::*; struct Fixture { - path: PathBuf, + path: NormalizedPath, temp: Option, } impl Fixture { - fn path(&self) -> &Path { + fn path(&self) -> &NormalizedPath { &self.path } } @@ -21,7 +21,7 @@ impl Drop for Fixture { fn tempdir() -> Fixture { if fbuild_core::platform::host::is_windows() { - let base = fbuild_core::platform::fs::short_cache_base().unwrap(); + let base = NormalizedPath::new(fbuild_core::platform::fs::short_cache_base().unwrap()); static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); let nonce = format!( "{}|{}|{}", @@ -40,7 +40,7 @@ fn tempdir() -> Fixture { } else { let temp = tempfile::Builder::new().prefix("p").tempdir().unwrap(); Fixture { - path: temp.path().to_path_buf(), + path: NormalizedPath::new(temp.path()), temp: Some(temp), } } @@ -48,7 +48,7 @@ fn tempdir() -> Fixture { fn inputs(temp: &Fixture, observation: Observation) -> Inputs { let home = temp.path().join("home"); - let short_base = temp.path().to_path_buf(); + let short_base = temp.path().clone(); Inputs { fbuild_root: home.join(crate::FBUILD_DIR_NAME).join("prod"), home, @@ -60,6 +60,23 @@ fn inputs(temp: &Fixture, observation: Observation) -> Inputs { } } +#[test] +fn both_path_override_knobs_accept_current_directory_spellings() { + let expected = NormalizedPath::new(std::env::current_dir().unwrap()); + for knob in ["FBUILD_CACHE_DIR", "FBUILD_BUILD_DIR"] { + for value in [".", "child/.."] { + let selected = override_from_value(Some(std::ffi::OsString::from(value))).unwrap(); + assert_eq!(selected, Some(expected.clone()), "{knob}={value}"); + } + } +} + +#[test] +fn path_override_parser_rejects_actual_empty_value_and_preserves_absence() { + assert!(override_from_value(Some(std::ffi::OsString::new())).is_err()); + assert_eq!(override_from_value(None).unwrap(), None); +} + #[test] fn disabled_policy_creates_short_writable_cache_and_scratch() { let temp = tempdir(); @@ -75,6 +92,19 @@ fn disabled_policy_creates_short_writable_cache_and_scratch() { assert!(advisory.contains(&resolution.cache_root.display().to_string())); } +#[test] +fn forwarding_a_selected_fallback_build_root_preserves_its_identity() { + let temp = tempdir(); + let mut input = inputs(&temp, Observation::Disabled); + let padding = 50 - fs::legacy_path_units(temp.path()) - 1; + input.cache_override = Some(temp.path().join("c".repeat(padding))); + let resolution = resolve(input).unwrap(); + let project = temp.path().join("project"); + let requested = project.join("deep-component".repeat(12)).join("build"); + let selected = resolution.resolve_build_root(&project, &requested); + assert_eq!(resolution.resolve_build_root(&project, &selected), selected); +} + #[test] fn short_explicit_cache_is_preserved_and_still_warns() { let temp = tempdir(); From feb3cb816664c2635b34619521623faee64d3955 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:12:18 -0700 Subject: [PATCH 04/11] test: supply negotiated Windows cache identity in daemon fixtures --- .../fbuild-cli/tests/it/test_emu_exit_code.rs | 54 +++++++++++++++---- 1 file changed, 44 insertions(+), 10 deletions(-) diff --git a/crates/fbuild-cli/tests/it/test_emu_exit_code.rs b/crates/fbuild-cli/tests/it/test_emu_exit_code.rs index 1e679353e..c91ec47be 100644 --- a/crates/fbuild-cli/tests/it/test_emu_exit_code.rs +++ b/crates/fbuild-cli/tests/it/test_emu_exit_code.rs @@ -101,16 +101,34 @@ fn write_response(stream: &mut TcpStream, status_line: &str, body: &str) { /// try to restart the "stale" daemon. /// - POST /api/test-emu β€” 500 + structured OperationResponse JSON. /// - POST /api/deploy β€” 500 + an actionable structured deploy failure. -fn spawn_mock_daemon(stop: Arc) -> u16 { +fn spawn_mock_daemon(stop: Arc, cache_root: &std::path::Path) -> u16 { let listener = TcpListener::bind("127.0.0.1:0").expect("bind ephemeral"); listener .set_nonblocking(true) .expect("set listener nonblocking"); let port = listener.local_addr().expect("local_addr").port(); + let identity = fbuild_paths::running_process::DaemonCacheIdentity { + mode: "prod", + cache_root: cache_root.to_path_buf(), + cache_root_key: fbuild_core::path::NormalizedPath::new(cache_root) + .key() + .to_string(), + cache_dir_source: "FBUILD_CACHE_DIR", + trust_domain: fbuild_paths::running_process::LOCAL_TRUST_DOMAIN, + }; + let info_body = serde_json::json!({ + "status": "healthy", "uptime_seconds": 1.0, "version": "test", + "pid": 1, "port": port, "dev_mode": false, "operation_in_progress": false, + "daemon_state": "idle", "current_operation": null, "client_count": 0, + "spawner_cwd": null, "source_mtime": 0.0, + "cache_identity": identity.label_value(), + "long_path_support": fbuild_paths::long_paths::probe(), + }) + .to_string(); + std::thread::spawn(move || { let healthy_body = "{\"status\":\"healthy\",\"uptime_seconds\":1.0,\"version\":\"test\",\"pid\":1,\"source_mtime\":0.0}"; - let info_body = "{\"status\":\"healthy\",\"uptime_seconds\":1.0,\"version\":\"test\",\"pid\":1,\"port\":0,\"dev_mode\":true,\"operation_in_progress\":false,\"daemon_state\":\"idle\",\"current_operation\":null,\"client_count\":0,\"spawner_cwd\":null,\"source_mtime\":0.0}"; let fail_body = "{\"success\":false,\"request_id\":\"mock-1\",\"message\":\"mock daemon: simulated test-emu failure\",\"exit_code\":0,\"output_file\":null,\"output_dir\":null,\"launch_url\":null,\"stdout\":null,\"stderr\":null}"; let deploy_fail_body = "{\"success\":false,\"request_id\":\"mock-2\",\"message\":\"deploy failed\",\"exit_code\":1,\"output_file\":null,\"output_dir\":null,\"launch_url\":null,\"stdout\":null,\"stderr\":\"RP2040 UF2 transfer failed with Windows error 121\"}"; @@ -125,7 +143,7 @@ fn spawn_mock_daemon(stop: Arc) -> u16 { if request_line.starts_with("GET /health") { write_response(&mut stream, "HTTP/1.1 200 OK", healthy_body); } else if request_line.starts_with("GET /api/daemon/info") { - write_response(&mut stream, "HTTP/1.1 200 OK", info_body); + write_response(&mut stream, "HTTP/1.1 200 OK", &info_body); } else if request_line.starts_with("POST /api/test-emu") { // Return 500 with structured body + exit_code=0 to // stress-test the CLI's fallback: it must still @@ -167,10 +185,22 @@ fn make_test_project() -> tempfile::TempDir { dir } +fn make_test_cache() -> tempfile::TempDir { + if fbuild_core::platform::host::is_windows() { + tempfile::Builder::new() + .prefix("fc-") + .tempdir_in(fbuild_core::platform::fs::short_cache_base().unwrap()) + .unwrap() + } else { + tempfile::tempdir().unwrap() + } +} + #[test] fn deploy_output_survives_nonzero_exit() { let stop = Arc::new(AtomicBool::new(false)); - let port = spawn_mock_daemon(Arc::clone(&stop)); + let cache = make_test_cache(); + let port = spawn_mock_daemon(Arc::clone(&stop), cache.path()); let project = make_test_project(); let bin = std::env::var("CARGO_BIN_EXE_fbuild").expect("fbuild binary path"); @@ -185,7 +215,9 @@ fn deploy_output_survives_nonzero_exit() { "--skip-build", ]) .env("FBUILD_DAEMON_PORT", port.to_string()) - .env_remove("FBUILD_DEV_MODE") + .env("FBUILD_DEV_MODE", "0") + .env("FBUILD_CACHE_DIR", cache.path()) + .env("RUNNING_PROCESS_DISABLE", "1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); @@ -212,14 +244,14 @@ fn deploy_output_survives_nonzero_exit() { #[test] fn test_emu_exits_non_zero_when_daemon_returns_failure() { let stop = Arc::new(AtomicBool::new(false)); - let port = spawn_mock_daemon(Arc::clone(&stop)); + let cache = make_test_cache(); + let port = spawn_mock_daemon(Arc::clone(&stop), cache.path()); let project = make_test_project(); let bin = std::env::var("CARGO_BIN_EXE_fbuild").expect("fbuild binary path"); - // Drive the CLI at the mock daemon. We clear FBUILD_DEV_MODE so the - // CLI sticks to prod-mode path assumptions, and pin - // FBUILD_DAEMON_PORT so the client calls 127.0.0.1:. + // Pin the CLI to this fixture's prod cache and HTTP endpoint, bypassing + // broker adoption so the failure is supplied by this test server. // allow-direct-spawn: integration test driver that invokes the compiled fbuild binary. let mut cmd = Command::new(bin); cmd.args([ @@ -233,7 +265,9 @@ fn test_emu_exits_non_zero_when_daemon_returns_failure() { "1", ]) .env("FBUILD_DAEMON_PORT", port.to_string()) - .env_remove("FBUILD_DEV_MODE") + .env("FBUILD_DEV_MODE", "0") + .env("FBUILD_CACHE_DIR", cache.path()) + .env("RUNNING_PROCESS_DISABLE", "1") .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()); From 44c047991edcc383a214a7dd781fad763738b305 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:16:00 -0700 Subject: [PATCH 05/11] ci: verify fallback JSON warnings with a successful package preview --- .github/workflows/check-windows.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 4080594db..7f8fe8279 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -202,9 +202,9 @@ jobs: $before = Get-Content (Join-Path $env:USERPROFILE '.fbuild\prod\daemon\root-owner.json') -Raw | ConvertFrom-Json New-ItemProperty -Path $registry -Name LongPathsEnabled -PropertyType DWord -Value 0 -Force | Out-Null $env:CI = 'true' - $output = & $cli install tests/platform/esp32dev --check --json --no-update-check 2> long-path-warning.txt + $output = & $cli install tests/platform/esp32dev --dry-run --json --no-update-check 2> long-path-warning.txt $code = $LASTEXITCODE - if ($code -notin @(0, 2)) { throw "fallback package check failed: $code" } + if ($code -ne 0) { throw "fallback package preview failed: $code" } $output | ConvertFrom-Json | Out-Null $warning = Get-Content long-path-warning.txt -Raw if ($warning -notmatch 'WARNING:' -or $warning -notmatch 'fix-win' -or $warning.Contains([char]27)) { From 25e271f992e1548faa855a3af1d2249411b5edf8 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:19:54 -0700 Subject: [PATCH 06/11] test: match the daemon cache schema and path casing on Windows --- crates/fbuild-cli/tests/it/test_emu_exit_code.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/fbuild-cli/tests/it/test_emu_exit_code.rs b/crates/fbuild-cli/tests/it/test_emu_exit_code.rs index c91ec47be..15c387057 100644 --- a/crates/fbuild-cli/tests/it/test_emu_exit_code.rs +++ b/crates/fbuild-cli/tests/it/test_emu_exit_code.rs @@ -111,9 +111,7 @@ fn spawn_mock_daemon(stop: Arc, cache_root: &std::path::Path) -> u16 let identity = fbuild_paths::running_process::DaemonCacheIdentity { mode: "prod", cache_root: cache_root.to_path_buf(), - cache_root_key: fbuild_core::path::NormalizedPath::new(cache_root) - .key() - .to_string(), + cache_root_key: fbuild_core::path::NormalizedPath::new(cache_root).display_slash(), cache_dir_source: "FBUILD_CACHE_DIR", trust_domain: fbuild_paths::running_process::LOCAL_TRUST_DOMAIN, }; @@ -123,6 +121,7 @@ fn spawn_mock_daemon(stop: Arc, cache_root: &std::path::Path) -> u16 "daemon_state": "idle", "current_operation": null, "client_count": 0, "spawner_cwd": null, "source_mtime": 0.0, "cache_identity": identity.label_value(), + "cache_schema_version": fbuild_paths::running_process::CACHE_SCHEMA_VERSION, "long_path_support": fbuild_paths::long_paths::probe(), }) .to_string(); From d317b60924fb1010cd1f55f8a151bcf543a75bc2 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:36:52 -0700 Subject: [PATCH 07/11] fix: install immutable path policy through normalized platform APIs --- ci/platform_boundary_research.tsv | 10 +++++----- crates/fbuild-core/src/platform/fs.rs | 2 +- crates/fbuild-core/src/platform/linux/fs.rs | 4 ++-- crates/fbuild-core/src/platform/macos/fs.rs | 4 ++-- crates/fbuild-core/src/platform/windows/fs.rs | 12 ++++++------ .../src/platform/windows/private_directory.rs | 3 ++- crates/fbuild-paths/src/long_paths.rs | 5 +---- 7 files changed, 19 insertions(+), 21 deletions(-) diff --git a/ci/platform_boundary_research.tsv b/ci/platform_boundary_research.tsv index b1d259e8d..cdf62bd89 100644 --- a/ci/platform_boundary_research.tsv +++ b/ci/platform_boundary_research.tsv @@ -106,14 +106,14 @@ crates/fbuild-core/src/platform/windows/private_directory.rs 377 native_path win crates/fbuild-core/src/platform/windows/private_directory.rs 385 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 386 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 387 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 492 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 493 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 536 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 494 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 537 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 567 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 598 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 538 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 568 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/private_directory.rs 599 native_path windows_sys:: process host_mechanic -crates/fbuild-core/src/platform/windows/private_directory.rs 603 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 600 native_path windows_sys:: process host_mechanic +crates/fbuild-core/src/platform/windows/private_directory.rs 604 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 87 native_path std::os::windows::ffi::OsStrExt process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 89 native_path windows_sys:: process host_mechanic crates/fbuild-core/src/platform/windows/process.rs 92 native_path windows_sys:: process host_mechanic diff --git a/crates/fbuild-core/src/platform/fs.rs b/crates/fbuild-core/src/platform/fs.rs index 2574082b3..dcdc3f827 100644 --- a/crates/fbuild-core/src/platform/fs.rs +++ b/crates/fbuild-core/src/platform/fs.rs @@ -74,7 +74,7 @@ pub fn legacy_path_units(path: &Path) -> usize { /// User isolation, directory creation, permissions, and cache policy belong /// to callers. Other hosts return `Unsupported`. pub fn short_cache_base() -> std::io::Result { - super::selected::fs::short_cache_base().map(NormalizedPath::new) + super::selected::fs::short_cache_base() } /// Establish an automatic cache directory owned and writable only by this user. diff --git a/crates/fbuild-core/src/platform/linux/fs.rs b/crates/fbuild-core/src/platform/linux/fs.rs index a26b5ee96..68b4269c1 100644 --- a/crates/fbuild-core/src/platform/linux/fs.rs +++ b/crates/fbuild-core/src/platform/linux/fs.rs @@ -1,6 +1,6 @@ use std::fs::OpenOptions; use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::path::NormalizedPath; use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts}; @@ -20,7 +20,7 @@ pub(crate) fn legacy_path_units(path: &Path) -> usize { path.as_os_str().as_encoded_bytes().len() } -pub(crate) fn short_cache_base() -> std::io::Result { +pub(crate) fn short_cache_base() -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "Windows short-cache drive selection is unavailable on this host", diff --git a/crates/fbuild-core/src/platform/macos/fs.rs b/crates/fbuild-core/src/platform/macos/fs.rs index b7a9209af..f1687bd0b 100644 --- a/crates/fbuild-core/src/platform/macos/fs.rs +++ b/crates/fbuild-core/src/platform/macos/fs.rs @@ -1,6 +1,6 @@ use std::fs::OpenOptions; use std::os::unix::fs::PermissionsExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::path::NormalizedPath; use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts}; @@ -20,7 +20,7 @@ pub(crate) fn legacy_path_units(path: &Path) -> usize { path.as_os_str().as_encoded_bytes().len() } -pub(crate) fn short_cache_base() -> std::io::Result { +pub(crate) fn short_cache_base() -> std::io::Result { Err(std::io::Error::new( std::io::ErrorKind::Unsupported, "Windows short-cache drive selection is unavailable on this host", diff --git a/crates/fbuild-core/src/platform/windows/fs.rs b/crates/fbuild-core/src/platform/windows/fs.rs index 5ca3dcc1b..1f120a927 100644 --- a/crates/fbuild-core/src/platform/windows/fs.rs +++ b/crates/fbuild-core/src/platform/windows/fs.rs @@ -2,7 +2,7 @@ use std::fs::OpenOptions; use std::os::windows::ffi::OsStrExt; use std::os::windows::fs::{MetadataExt, OpenOptionsExt}; use std::os::windows::io::AsRawHandle; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::path::NormalizedPath; use crate::platform::fs::{ErrorClass, LongPathSupport, VolumeFacts, VolumeKind}; @@ -152,7 +152,7 @@ pub(crate) fn legacy_path_units(path: &Path) -> usize { path.as_os_str().encode_wide().count() } -pub(crate) fn short_cache_base() -> std::io::Result { +pub(crate) fn short_cache_base() -> std::io::Result { use windows_sys::Win32::Storage::FileSystem::GetDriveTypeW; let drive = std::env::var_os("SystemDrive").ok_or_else(|| { @@ -179,7 +179,7 @@ pub(crate) fn short_cache_base() -> std::io::Result { Ok(base) } -fn short_cache_base_from_drive(drive: &std::ffi::OsStr) -> std::io::Result { +fn short_cache_base_from_drive(drive: &std::ffi::OsStr) -> std::io::Result { let value = drive.to_str().ok_or_else(|| { std::io::Error::new( std::io::ErrorKind::InvalidInput, @@ -196,7 +196,7 @@ fn short_cache_base_from_drive(drive: &std::ffi::OsStr) -> std::io::Result(); - let path = NormalizedPath::new(short_cache_base().unwrap()) + let path = short_cache_base() + .unwrap() .join(format!("fb-private-{suffix}")); ensure_private_directory(&path).unwrap(); Self { path } diff --git a/crates/fbuild-paths/src/long_paths.rs b/crates/fbuild-paths/src/long_paths.rs index 6f3db2490..373392d13 100644 --- a/crates/fbuild-paths/src/long_paths.rs +++ b/crates/fbuild-paths/src/long_paths.rs @@ -218,10 +218,7 @@ pub fn initialize() -> Result<&'static Resolution> { observation, }; let resolution = resolve_with_short_base(inputs, fs::short_cache_base)?; - let _ = RESOLUTION.set(resolution); - RESOLUTION - .get() - .ok_or_else(|| FbuildError::Other("long-path resolution was not initialized".into())) + Ok(RESOLUTION.get_or_init(|| resolution)) } /// Inspect the startup snapshot without triggering policy reads or filesystem work. From 2eaa70dbded560ef40ae85e0eed1bf91b2a6c362 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:42:52 -0700 Subject: [PATCH 08/11] test: compare fallback directories through normalized paths --- crates/fbuild-paths/src/long_paths/tests.rs | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/crates/fbuild-paths/src/long_paths/tests.rs b/crates/fbuild-paths/src/long_paths/tests.rs index 646009bdf..21346bee1 100644 --- a/crates/fbuild-paths/src/long_paths/tests.rs +++ b/crates/fbuild-paths/src/long_paths/tests.rs @@ -81,7 +81,7 @@ fn path_override_parser_rejects_actual_empty_value_and_preserves_absence() { fn disabled_policy_creates_short_writable_cache_and_scratch() { let temp = tempdir(); let resolution = resolve(inputs(&temp, Observation::Disabled)).unwrap(); - assert!(resolution.cache_root.starts_with(temp.path())); + assert!(resolution.cache_root.relative_to(temp.path()).is_some()); assert!(resolution.cache_root.is_dir()); assert!(resolution.scratch_root.is_dir()); assert_ne!(resolution.cache_root, resolution.requested_cache_root); @@ -115,7 +115,7 @@ fn short_explicit_cache_is_preserved_and_still_warns() { assert_eq!(resolution.cache_root, selected); assert_eq!(resolution.cache_dir_source, "FBUILD_CACHE_DIR"); assert!(resolution.advisory().is_some()); - assert!(resolution.scratch_root.starts_with(&selected)); + assert!(resolution.scratch_root.relative_to(&selected).is_some()); } #[test] @@ -170,8 +170,14 @@ fn short_explicit_cache_works_when_automatic_base_is_unavailable() { input.cache_override = Some(selected.clone()); let resolution = resolve(input).unwrap(); assert_eq!(resolution.cache_root, selected); - assert!(resolution.scratch_root.starts_with(&selected)); - assert!(resolution.short_build_root.unwrap().starts_with(&selected)); + assert!(resolution.scratch_root.relative_to(&selected).is_some()); + assert!( + resolution + .short_build_root + .unwrap() + .relative_to(&selected) + .is_some() + ); } #[test] @@ -281,7 +287,11 @@ fn deep_build_outputs_are_keyed_by_normalized_project() { let deep = first.join("deep".repeat(35)); let first_root = resolution.resolve_build_root(&first, &deep); let second_root = resolution.resolve_build_root(&second, &deep); - assert!(first_root.starts_with(resolution.short_build_root.as_ref().unwrap())); + assert!( + first_root + .relative_to(resolution.short_build_root.as_ref().unwrap()) + .is_some() + ); assert_ne!(first_root, second_root); assert_eq!( first_root, @@ -348,7 +358,7 @@ fn private_build_preparation_rejects_linked_project_ancestor() { let base = resolution.short_build_root.as_ref().unwrap(); let project_root = base.join( build - .strip_prefix(base) + .relative_to(base) .unwrap() .components() .next() From 9f912c3353eafbca0c57633b47e000f593364b1f Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Mon, 12 Oct 2026 23:59:22 -0700 Subject: [PATCH 09/11] fix: make concurrent Windows fallback initialization idempotent Local-Gate: v1 tree=d1ba30a19b78912258bb2de3e4b3caf66618f12e secs=804 lanes=linux-minimal:run,dylint:run Ci-Attestation: {"at":1791875567,"gate":"general/all/ubuntu-ci-guards","host":"linux-x86_64","key":"3df000198e3415820f818a1d5d6285c6a369fc933baa749b86c46bec1b9115aa","lane":"linux-minimal","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":493,"stamp":"8bd45498928146e923c24d6fb8178466","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"rust/x86_64-unknown-linux-gnu/workspace-clippy","host":"linux-x86_64","key":"3df000198e3415820f818a1d5d6285c6a369fc933baa749b86c46bec1b9115aa","lane":"linux-minimal","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":493,"stamp":"19d5a0bd8a5da1c65c9a5fac07b01ebd","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"rust/x86_64-unknown-linux-gnu/workspace-test","host":"linux-x86_64","key":"3df000198e3415820f818a1d5d6285c6a369fc933baa749b86c46bec1b9115aa","lane":"linux-minimal","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":493,"stamp":"a994a66edd68b9fe702537144de23ec5","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"rust/x86_64-unknown-linux-gnu/python-facade-test","host":"linux-x86_64","key":"3df000198e3415820f818a1d5d6285c6a369fc933baa749b86c46bec1b9115aa","lane":"linux-minimal","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":493,"stamp":"5cd540b662631d36fb4c1864a38418a0","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"general/all/dylint-policy","host":"linux-x86_64","key":"6ad8019386de91dd880a73495e050f84b021db2f7f319c64b77a217cefe79f9d","lane":"dylint","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":310,"stamp":"2da4fb47dc89ac3e60c3d7c8890dff3e","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"rust/x86_64-unknown-linux-gnu/dylint-library-check","host":"linux-x86_64","key":"6ad8019386de91dd880a73495e050f84b021db2f7f319c64b77a217cefe79f9d","lane":"dylint","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":310,"stamp":"1d9d78021f8c360300fdb4abe2e4a7da","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} Ci-Attestation: {"at":1791875567,"gate":"rust/x86_64-unknown-linux-gnu/workspace-dylint","host":"linux-x86_64","key":"6ad8019386de91dd880a73495e050f84b021db2f7f319c64b77a217cefe79f9d","lane":"dylint","parents":["6aea3ab81915acdd9373d3d570ef152bbfa5e37e"],"secs":310,"stamp":"a4d6036b19d696a7e9567688439fe491","tree":"d1ba30a19b78912258bb2de3e4b3caf66618f12e","v":1,"via":"run"} --- .../fbuild-cli/tests/it/windows_long_paths.rs | 2 +- crates/fbuild-paths/src/long_paths.rs | 45 +++++++++++--- crates/fbuild-paths/src/long_paths/tests.rs | 61 ++++++++++++++++++- 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/crates/fbuild-cli/tests/it/windows_long_paths.rs b/crates/fbuild-cli/tests/it/windows_long_paths.rs index ba6aef260..3e4ef98a8 100644 --- a/crates/fbuild-cli/tests/it/windows_long_paths.rs +++ b/crates/fbuild-cli/tests/it/windows_long_paths.rs @@ -36,6 +36,6 @@ fn fix_win_on_non_windows_exits_without_building_or_prompting() { assert!(!output.status.success()); let diagnostic = String::from_utf8_lossy(&output.stderr); assert!(diagnostic.contains("Windows"), "wrong error: {diagnostic}"); - assert!(!root.path().join(".fbuild").exists()); + assert!(!root.path().join(fbuild_paths::FBUILD_DIR_NAME).exists()); assert!(output.stdout.is_empty(), "repair emitted build output"); } diff --git a/crates/fbuild-paths/src/long_paths.rs b/crates/fbuild-paths/src/long_paths.rs index 373392d13..3767e12aa 100644 --- a/crates/fbuild-paths/src/long_paths.rs +++ b/crates/fbuild-paths/src/long_paths.rs @@ -417,9 +417,23 @@ fn ensure_sidecar(path: &Path) -> Result<()> { } } Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(path).map_err(|error| { - fallback_error(&format!("create sidecar {}", path.display()), &error) - })?; + if let Err(error) = std::fs::create_dir(path) { + if error.kind() != std::io::ErrorKind::AlreadyExists { + return Err(fallback_error( + &format!("create sidecar {}", path.display()), + &error, + )); + } + } + // A concurrent initializer may have created it after our lookup. + // Recheck the winner before allowing the writability probe. + let metadata = std::fs::symlink_metadata(path)?; + if fs::is_link_or_reparse(path)? || !metadata.is_dir() { + return Err(FbuildError::ConfigError(format!( + "Windows short-path fallback sidecar {} must be a real directory, not a link or file. Choose another short FBUILD_CACHE_DIR or run fbuild --fix-win.", + path.display() + ))); + } } Err(error) => { return Err(fallback_error( @@ -545,6 +559,9 @@ fn validate_marker( } fn write_marker(path: &Path, marker: &SelectionMarker) -> Result<()> { + if let Some(existing) = read_marker(path)? { + return validate_marker(path, &existing, &marker.cache_root, &marker.scratch_root); + } let parent = path .parent() .ok_or_else(|| FbuildError::ConfigError("cache selection marker has no parent".into()))?; @@ -568,13 +585,21 @@ fn write_marker(path: &Path, marker: &SelectionMarker) -> Result<()> { .map_err(|error| { fallback_error(&format!("write cache selection {}", path.display()), &error) })?; - fs::replace_file(pending.path(), path).map_err(|error| { - fallback_error( - &format!("replace cache selection {}", path.display()), - &error, - ) - })?; - Ok(()) + match pending.persist_noclobber(path) { + Ok(_) => Ok(()), + Err(error) => { + // Another process may have installed the same complete selection + // since our initial read. Validate its winner without replacing it. + if let Some(existing) = read_marker(path)? { + validate_marker(path, &existing, &marker.cache_root, &marker.scratch_root) + } else { + Err(fallback_error( + &format!("install cache selection {}", path.display()), + &error.error, + )) + } + } + } } #[cfg(test)] diff --git a/crates/fbuild-paths/src/long_paths/tests.rs b/crates/fbuild-paths/src/long_paths/tests.rs index 21346bee1..516a2b566 100644 --- a/crates/fbuild-paths/src/long_paths/tests.rs +++ b/crates/fbuild-paths/src/long_paths/tests.rs @@ -244,15 +244,74 @@ fn explicit_cache_is_normalized_before_shortness_is_checked() { } #[test] -fn repeated_fallback_initialization_atomically_replaces_marker() { +fn repeated_fallback_initialization_preserves_valid_marker() { let temp = tempdir(); let first = resolve(inputs(&temp, Observation::Disabled)).unwrap(); + let marker_path = first.fbuild_root.join(MARKER_FILE); + let mut saved: serde_json::Value = + serde_json::from_slice(&std::fs::read(&marker_path).unwrap()).unwrap(); + saved["preserve_extra_field"] = serde_json::json!(true); + let bytes = serde_json::to_vec(&saved).unwrap(); + std::fs::write(&marker_path, &bytes).unwrap(); let second = resolve(inputs(&temp, Observation::Disabled)).unwrap(); assert_eq!(first.cache_root, second.cache_root); + assert_eq!(std::fs::read(&marker_path).unwrap(), bytes); let enabled = resolve(inputs(&temp, Observation::Enabled)).unwrap(); assert_eq!(first.cache_root, enabled.cache_root); } +#[test] +fn concurrent_first_fallback_initialization_retains_one_valid_selection() { + let temp = tempdir(); + let barrier = std::sync::Barrier::new(4); + let roots = std::thread::scope(|scope| { + let contenders: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + barrier.wait(); + resolve(inputs(&temp, Observation::Disabled)) + .unwrap() + .cache_root + }) + }) + .collect(); + contenders + .into_iter() + .map(|contender| contender.join().unwrap()) + .collect::>() + }); + assert!(roots.iter().all(|root| root == &roots[0])); + let retained = resolve(inputs(&temp, Observation::Enabled)).unwrap(); + assert_eq!(retained.cache_root, roots[0]); +} + +#[test] +fn concurrent_short_explicit_cache_initialization_shares_real_sidecars() { + let temp = tempdir(); + for index in 0..8 { + let selected = temp.path().join(format!("e{index}")); + let barrier = std::sync::Barrier::new(4); + std::thread::scope(|scope| { + let contenders: Vec<_> = (0..4) + .map(|_| { + scope.spawn(|| { + let mut input = inputs(&temp, Observation::Disabled); + input.cache_override = Some(selected.clone()); + barrier.wait(); + resolve(input).unwrap() + }) + }) + .collect(); + for contender in contenders { + let resolution = contender.join().unwrap(); + assert_eq!(resolution.cache_root, selected); + assert!(resolution.scratch_root.is_dir()); + assert!(resolution.short_build_root.unwrap().is_dir()); + } + }); + } +} + #[test] fn unknown_policy_uses_fallback_with_query_error_in_warning() { let temp = tempdir(); From 2d00ec11981669bfe13144b1c81867df5f75e3e6 Mon Sep 17 00:00:00 2001 From: Zach Vorhies Date: Tue, 13 Oct 2026 00:36:43 -0700 Subject: [PATCH 10/11] ci: discover Windows smoke executables from Cargo artifacts Local-Gate: v1 tree=72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4 secs=834 lanes=linux-minimal:run,dylint:run Ci-Attestation: {"at":1791877838,"gate":"general/all/ubuntu-ci-guards","host":"linux-x86_64","key":"08128df5eb00d41b35740f9051fd7d95b74e8bb99036631478c1ec0bfaa3caf6","lane":"linux-minimal","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":443,"stamp":"9de2724d59ea77e6c156e0ea493cc6b4","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"rust/x86_64-unknown-linux-gnu/workspace-clippy","host":"linux-x86_64","key":"08128df5eb00d41b35740f9051fd7d95b74e8bb99036631478c1ec0bfaa3caf6","lane":"linux-minimal","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":443,"stamp":"9f820e1da70c79344046ff7f6ae04ce5","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"rust/x86_64-unknown-linux-gnu/workspace-test","host":"linux-x86_64","key":"08128df5eb00d41b35740f9051fd7d95b74e8bb99036631478c1ec0bfaa3caf6","lane":"linux-minimal","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":443,"stamp":"bb16e070a14214a415a2f133723a96a5","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"rust/x86_64-unknown-linux-gnu/python-facade-test","host":"linux-x86_64","key":"08128df5eb00d41b35740f9051fd7d95b74e8bb99036631478c1ec0bfaa3caf6","lane":"linux-minimal","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":443,"stamp":"5d0698a17a20b74387496d8a1688ab30","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"general/all/dylint-policy","host":"linux-x86_64","key":"b4492620e628ac6b7845c8873a3a6cbb8d6593c761efde01ea59bae98b7653fe","lane":"dylint","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":391,"stamp":"6bf99ce855c78b4355a259d61f0fb0a8","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"rust/x86_64-unknown-linux-gnu/dylint-library-check","host":"linux-x86_64","key":"b4492620e628ac6b7845c8873a3a6cbb8d6593c761efde01ea59bae98b7653fe","lane":"dylint","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":391,"stamp":"906cf02c8b8940b6efeb70dbe5e31902","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} Ci-Attestation: {"at":1791877838,"gate":"rust/x86_64-unknown-linux-gnu/workspace-dylint","host":"linux-x86_64","key":"b4492620e628ac6b7845c8873a3a6cbb8d6593c761efde01ea59bae98b7653fe","lane":"dylint","parents":["fcf29c5310c8d9b4a7cbda7bcddffc5ca7aeaa11"],"secs":391,"stamp":"0b9b192157d7f3355b7b7db9fb6c0d0c","tree":"72148e3f4678f7a835749b8b8cdaa1cc22d4b8d4","v":1,"via":"run"} --- .github/workflows/check-windows.yml | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index 7f8fe8279..d7bd1e6f7 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -174,10 +174,26 @@ jobs: $ErrorActionPreference = 'Stop' $registry = 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' $original = Get-ItemPropertyValue -Path $registry -Name LongPathsEnabled -ErrorAction SilentlyContinue - $metadata = soldr cargo metadata --format-version 1 --no-deps | ConvertFrom-Json - if ($LASTEXITCODE -ne 0) { throw 'cargo metadata failed' } - $cli = Join-Path $metadata.target_directory 'debug\fbuild.exe' - $daemon = Join-Path $metadata.target_directory 'debug\fbuild-daemon.exe' + $buildOutput = @(& soldr cargo build -p fbuild-cli -p fbuild-daemon --bins --message-format=json) + if ($LASTEXITCODE -ne 0) { + $buildOutput | Write-Output + throw 'could not build the Windows smoke binaries' + } + $binPaths = @{} + foreach ($line in $buildOutput) { + if (-not $line.StartsWith('{')) { continue } + $artifact = $line | ConvertFrom-Json + if ($artifact.reason -eq 'compiler-artifact' -and ($artifact.target.kind -contains 'bin') -and $artifact.executable) { + $binPaths[$artifact.target.name] = $artifact.executable + } + } + foreach ($name in @('fbuild', 'fbuild-daemon')) { + if (-not $binPaths.ContainsKey($name) -or -not (Test-Path -LiteralPath $binPaths[$name] -PathType Leaf)) { + throw "missing Windows smoke executable: $name" + } + } + $cli = $binPaths['fbuild'] + $daemon = $binPaths['fbuild-daemon'] foreach ($binary in @($cli, $daemon)) { $image = [Text.Encoding]::UTF8.GetString([IO.File]::ReadAllBytes($binary)) if ($image -notmatch 'longPathAware[^>]*>true Date: Tue, 13 Oct 2026 01:23:29 -0700 Subject: [PATCH 11/11] ci: pass update-check flags before Windows smoke subcommands Local-Gate: v1 tree=9bdb424cb884551f7307b190e64086a624130a20 secs=930 lanes=linux-minimal:run,dylint:run Ci-Attestation: {"at":1791880749,"gate":"general/all/ubuntu-ci-guards","host":"linux-x86_64","key":"58a16de3e48effa8cc68029a1140c1ebd63598519bc9d363b7707b2e66c1cb56","lane":"linux-minimal","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":545,"stamp":"9d65a1f6a0627a429b577ac3dad9d5e8","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"rust/x86_64-unknown-linux-gnu/workspace-clippy","host":"linux-x86_64","key":"58a16de3e48effa8cc68029a1140c1ebd63598519bc9d363b7707b2e66c1cb56","lane":"linux-minimal","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":545,"stamp":"bcc980f3c7832c966110ab421ecbdf98","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"rust/x86_64-unknown-linux-gnu/workspace-test","host":"linux-x86_64","key":"58a16de3e48effa8cc68029a1140c1ebd63598519bc9d363b7707b2e66c1cb56","lane":"linux-minimal","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":545,"stamp":"a291dfb7f2c7bfc0f662f37b54cf869c","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"rust/x86_64-unknown-linux-gnu/python-facade-test","host":"linux-x86_64","key":"58a16de3e48effa8cc68029a1140c1ebd63598519bc9d363b7707b2e66c1cb56","lane":"linux-minimal","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":545,"stamp":"90aad588ff604ec268d735fc49061bb7","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"general/all/dylint-policy","host":"linux-x86_64","key":"ee3f4156272d4ef813b0178070c9f84f2aa953197df05fa7b000db6848a04d49","lane":"dylint","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":385,"stamp":"97590a4c430884bbb1b8717ce9c20700","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"rust/x86_64-unknown-linux-gnu/dylint-library-check","host":"linux-x86_64","key":"ee3f4156272d4ef813b0178070c9f84f2aa953197df05fa7b000db6848a04d49","lane":"dylint","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":385,"stamp":"92d0f100e1cd7bdd45e50de197c5c422","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} Ci-Attestation: {"at":1791880749,"gate":"rust/x86_64-unknown-linux-gnu/workspace-dylint","host":"linux-x86_64","key":"ee3f4156272d4ef813b0178070c9f84f2aa953197df05fa7b000db6848a04d49","lane":"dylint","parents":["2d8f374d48cf331f8d70d3ad1ddb863252ffef3b"],"secs":385,"stamp":"fae8998620d1d4056bfab252986edf92","tree":"9bdb424cb884551f7307b190e64086a624130a20","v":1,"via":"run"} --- .github/workflows/check-windows.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-windows.yml b/.github/workflows/check-windows.yml index d7bd1e6f7..60e2fd133 100644 --- a/.github/workflows/check-windows.yml +++ b/.github/workflows/check-windows.yml @@ -213,12 +213,12 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'already-enabled repair failed' } $env:FBUILD_DEV_MODE = '0' $env:RUNNING_PROCESS_DISABLE = '1' - & $cli build tests/platform/uno --dry-run --no-update-check + & $cli --no-update-check build tests/platform/uno --dry-run if ($LASTEXITCODE -ne 0) { throw 'enabled daemon startup failed' } $before = Get-Content (Join-Path $env:USERPROFILE '.fbuild\prod\daemon\root-owner.json') -Raw | ConvertFrom-Json New-ItemProperty -Path $registry -Name LongPathsEnabled -PropertyType DWord -Value 0 -Force | Out-Null $env:CI = 'true' - $output = & $cli install tests/platform/esp32dev --dry-run --json --no-update-check 2> long-path-warning.txt + $output = & $cli --no-update-check install tests/platform/esp32dev --dry-run --json 2> long-path-warning.txt $code = $LASTEXITCODE if ($code -ne 0) { throw "fallback package preview failed: $code" } $output | ConvertFrom-Json | Out-Null @@ -226,11 +226,11 @@ jobs: if ($warning -notmatch 'WARNING:' -or $warning -notmatch 'fix-win' -or $warning.Contains([char]27)) { throw 'fallback warning is missing or contains ANSI in redirected stderr' } - & $cli build tests/platform/uno --dry-run --no-update-check + & $cli --no-update-check build tests/platform/uno --dry-run if ($LASTEXITCODE -ne 0) { throw 'disabled-policy daemon handoff failed' } $after = Get-Content (Join-Path $env:USERPROFILE '.fbuild\prod\daemon\root-owner.json') -Raw | ConvertFrom-Json if ($before.pid -eq $after.pid) { throw 'daemon was reused after the registry changed' } - & $cli daemon stop --no-update-check + & $cli --no-update-check daemon stop if ($LASTEXITCODE -ne 0) { throw 'smoke daemon did not stop' } } finally { if ($null -eq $original) {