You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Sub-issue of #802. This audit covers the foundational crates that everything else depends on: fbuild-core, fbuild-paths, fbuild-config, fbuild-header-scan, and fbuild-library-select. The single highest-leverage finding is in fbuild-core/src/subprocess.rs::run_command, which is the central choke point every fbuild subprocess flows through. That helper accepts timeout: Option<Duration> and does the right thing when given a value (process.wait(timeout) then kill on ProcessError::Timeout then return FbuildError::Timeout). The problem is that the default is no timeout, and a non-trivial number of call sites across the workspace pass None (a quick grep finds 34+ such call sites across 20 files in the higher-level crates). That means a single misbehaving compiler, linker, esptool, avrdude, addr2line, etc. invocation can wedge the daemon thread that spawned it for as long as the child decides to live.
Everything else found in this audit is in line with the workspace's design: ini extends resolution has cycle detection (visited set) and max_depth = 10 for variable substitution; the header-scan walker is a BFS over a visited set; symbol-analysis loops are bounded by BTreeMap::range walks. Synchronization usage in these crates is conservative (OnceLock, short RwLock/Mutex critical sections). The notable secondary finding is install_status::publish_install_status, which invokes subscriber callbacks synchronously on the publisher thread — a slow / panicking subscriber back-presses the publisher (low severity in practice today because the only registered subscriber is the daemon's own short broadcast hop, but it's still a design contract worth documenting and ideally guarding).
process.wait(timeout) with timeout: Option<Duration> defaulting to None
Helper itself is correct when given a Some(Duration), but the API makes timeout opt-in. 34+ call sites across fbuild-build, fbuild-deploy, fbuild-serial, fbuild-packages, fbuild-daemon, etc. pass None. A hung compiler / linker / esptool / avrdude blocks the daemon-side worker thread indefinitely.
Pick a sensible default cap (e.g. Duration::from_secs(15 * 60) per the existing #802 discussion) inside run_command* when the caller passes None. Keep Some(Duration) as explicit-override. Tag the few legitimately-unbounded cases (interactive pio passthrough, long QEMU runs) with a named constant or a run_command_no_timeout opt-in so they are auditable.
subscriber(status) called synchronously while still on the publisher's thread
The subscriber is a Arc<dyn Fn(...) + Send + Sync> invoked directly. A subscriber that takes a lock, does I/O, or blocks on a channel send will hold up every code path that calls publish_install_status — including the toolchain installer's hot path. There is no isolation or per-call deadline.
Either (a) document the "subscriber MUST be non-blocking" contract explicitly in the doc comment and add a debug_assert! / panic catcher around the call, or (b) wrap the call in a tokio::task::spawn_blocking / dedicated dispatcher thread so a slow subscriber cannot back-pressure publishers.
ONLINE_MAP.write().unwrap() / .read().unwrap() on a RwLock<Option<HashMap<...>>>
Lock is held for the duration of a *guard = Some(map) write or a .get(&pack(vid, pid)).cloned() read. Both are short and non-recursive, so contention is fine. Only listed for completeness — flag as LOW because no realistic path holds the read guard across an install.
None — acceptable as written. Consider switching to arc_swap::ArcSwap if write-while-reading ever becomes a measured concern.
Reads whole file into memory. Bounded by filesystem (.ini, board JSON, sdkconfig — KB-range in practice) but unbounded in principle.
None for the small-config files. If a malicious / corrupt input file is a worry, consider a read_to_string_with_cap(path, MAX_BYTES) wrapper used uniformly.
LOW
fbuild-header-scan
src/walker.rs:139 (fs::read_to_string inside the rayon par_iter)
Reads each header without size cap
Walker BFS itself is correctly bounded by the visited set (cycles, diamonds, depth-N chains tested in tests::w10_cycle_terminates / w11_diamond_dedupes). Per-file read_to_string is unbounded in principle but headers are KB-range in practice.
None. Same caveat as the read_to_string row above.
crates/fbuild-core/src/subprocess.rs (the central concern)
crates/fbuild-core/src/containment.rs
crates/fbuild-core/src/install_status.rs
crates/fbuild-core/src/response_file.rs
crates/fbuild-core/src/build_log.rs
crates/fbuild-core/src/usb/data.rs (excerpt)
crates/fbuild-config/src/ini_parser/parser.rs
crates/fbuild-config/src/ini_parser/variables.rs
crates/fbuild-header-scan/src/walker.rs
crates/fbuild-library-select/src/cache.rs
Out-of-scope notes
Test code excluded.containment.rs tests use child.wait() directly on a hand-built Command, and running_process.rs tests use ENV_LOCK.lock().unwrap() to serialize env-var fiddling — both are #[cfg(test)] only and do not ship.
Drop impls excluded. None found in the audited surface that block on subprocess termination.
Higher-level orchestrator timeouts excluded. Whether fbuild-build, fbuild-deploy, fbuild-daemon etc. layer their own timeouts on top is a separate parallel audit (per audit: blocking operations with no timeout (meta) #802); this issue only covers what the foundational crates expose and enforce themselves.
std::fs::read{,_to_string} everywhere. Treated as LOW because in practice these files are KB-range config / source headers. A workspace-wide "cap reads at N MB" wrapper would close the principled hole but is not load-bearing for any realistic build.
The 34+ run_command(..., None) call sites themselves. Listed quantitatively but enumerating each is the job of the higher-level crate audits, not this one — the fix lands in subprocess.rs as a default cap so every existing call site inherits the safety net for free.
Intro
Sub-issue of #802. This audit covers the foundational crates that everything else depends on:
fbuild-core,fbuild-paths,fbuild-config,fbuild-header-scan, andfbuild-library-select. The single highest-leverage finding is infbuild-core/src/subprocess.rs::run_command, which is the central choke point every fbuild subprocess flows through. That helper acceptstimeout: Option<Duration>and does the right thing when given a value (process.wait(timeout)then kill onProcessError::Timeoutthen returnFbuildError::Timeout). The problem is that the default is no timeout, and a non-trivial number of call sites across the workspace passNone(a quick grep finds 34+ such call sites across 20 files in the higher-level crates). That means a single misbehaving compiler, linker, esptool, avrdude, addr2line, etc. invocation can wedge the daemon thread that spawned it for as long as the child decides to live.Everything else found in this audit is in line with the workspace's design: ini
extendsresolution has cycle detection (visited set) andmax_depth = 10for variable substitution; the header-scan walker is a BFS over a visited set; symbol-analysis loops are bounded byBTreeMap::rangewalks. Synchronization usage in these crates is conservative (OnceLock, shortRwLock/Mutexcritical sections). The notable secondary finding isinstall_status::publish_install_status, which invokes subscriber callbacks synchronously on the publisher thread — a slow / panicking subscriber back-presses the publisher (low severity in practice today because the only registered subscriber is the daemon's own short broadcast hop, but it's still a design contract worth documenting and ideally guarding).Findings
fbuild-coresrc/subprocess.rs:81-89(run_command),:151-174(run_command_passthrough),:100-144(run_command_with_stdin)process.wait(timeout)withtimeout: Option<Duration>defaulting toNoneSome(Duration), but the API makes timeout opt-in. 34+ call sites acrossfbuild-build,fbuild-deploy,fbuild-serial,fbuild-packages,fbuild-daemon, etc. passNone. A hung compiler / linker / esptool / avrdude blocks the daemon-side worker thread indefinitely.Duration::from_secs(15 * 60)per the existing #802 discussion) insiderun_command*when the caller passesNone. KeepSome(Duration)as explicit-override. Tag the few legitimately-unbounded cases (interactivepiopassthrough, long QEMU runs) with a named constant or arun_command_no_timeoutopt-in so they are auditable.fbuild-coresrc/install_status.rs:82-93(publish_install_status)subscriber(status)called synchronously while still on the publisher's threadArc<dyn Fn(...) + Send + Sync>invoked directly. A subscriber that takes a lock, does I/O, or blocks on a channel send will hold up every code path that callspublish_install_status— including the toolchain installer's hot path. There is no isolation or per-call deadline.debug_assert!/ panic catcher around the call, or (b) wrap the call in atokio::task::spawn_blocking/ dedicated dispatcher thread so a slow subscriber cannot back-pressure publishers.fbuild-coresrc/usb/data.rs:225-228(install_online_cache_map),:231-235(lookup)ONLINE_MAP.write().unwrap()/.read().unwrap()on aRwLock<Option<HashMap<...>>>*guard = Some(map)write or a.get(&pack(vid, pid)).cloned()read. Both are short and non-recursive, so contention is fine. Only listed for completeness — flag as LOW because no realistic path holds the read guard across an install.arc_swap::ArcSwapif write-while-reading ever becomes a measured concern.fbuild-coresrc/symbol_analysis/mod.rs:880(nm_range_covers),src/symbol_analysis/graph/mod.rs:441(BFS)while let Some(...)walkingBTreeMap::range/VecDequefbuild-configsrc/ini_parser/parser.rs:111-166(resolve_env),:168-210(resolve_section),:218-237(substitute_vars)extendsresolution +${section.key}variable substitutionvisited: HashSet<String>; variable substitution capped atmax_depth = 10. Already correctly hardened.fbuild-configsrc/board/db.rs:139,src/board/loaders.rs:122,src/ini_parser/mod.rs:60,src/sdkconfig.rs:103,109std::fs::read_to_string(path).ini, board JSON,sdkconfig— KB-range in practice) but unbounded in principle.read_to_string_with_cap(path, MAX_BYTES)wrapper used uniformly.fbuild-header-scansrc/walker.rs:139(fs::read_to_stringinside the rayon par_iter)visitedset (cycles, diamonds, depth-N chains tested intests::w10_cycle_terminates/w11_diamond_dedupes). Per-fileread_to_stringis unbounded in principle but headers are KB-range in practice.read_to_stringrow above.fbuild-library-selectsrc/cache.rs:235-239(cache_keyseed read),:289(canonical_header_hash)std::fs::read(&canon)of seed sources / canonical headers for blake3 hashingfbuild-pathssrc/running_process.rs:339,358,382(ENV_LOCK.lock().unwrap())#[cfg(test)]paths.What was searched
Patterns grepped across
crates/{fbuild-core,fbuild-paths,fbuild-config,fbuild-header-scan,fbuild-library-select}/src/**/*.rs:Command::new/.output()/.wait()/.wait_with_output()(subprocess spawn + blocking wait).lock()/.read()/.write()(Mutex/RwLock guards).recv()/recv_timeout(mpsc receivers)loop {/while(unbounded iteration / recursion)thread::sleepfs::read_to_string/fs::read(file reads on potentially-large artifacts)RwLock/Mutex/OnceLock/sync::(synchronization primitives)subscriber/callback/on_status(event-bus patterns)extends/recursive/depth(config parser cycle / recursion controls)Files read in full for context:
crates/fbuild-core/src/subprocess.rs(the central concern)crates/fbuild-core/src/containment.rscrates/fbuild-core/src/install_status.rscrates/fbuild-core/src/response_file.rscrates/fbuild-core/src/build_log.rscrates/fbuild-core/src/usb/data.rs(excerpt)crates/fbuild-config/src/ini_parser/parser.rscrates/fbuild-config/src/ini_parser/variables.rscrates/fbuild-header-scan/src/walker.rscrates/fbuild-library-select/src/cache.rsOut-of-scope notes
containment.rstests usechild.wait()directly on a hand-builtCommand, andrunning_process.rstests useENV_LOCK.lock().unwrap()to serialize env-var fiddling — both are#[cfg(test)]only and do not ship.Dropimpls excluded. None found in the audited surface that block on subprocess termination.fbuild-build,fbuild-deploy,fbuild-daemonetc. layer their own timeouts on top is a separate parallel audit (per audit: blocking operations with no timeout (meta) #802); this issue only covers what the foundational crates expose and enforce themselves.std::fs::read{,_to_string}everywhere. Treated as LOW because in practice these files are KB-range config / source headers. A workspace-wide "cap reads at N MB" wrapper would close the principled hole but is not load-bearing for any realistic build.run_command(..., None)call sites themselves. Listed quantitatively but enumerating each is the job of the higher-level crate audits, not this one — the fix lands insubprocess.rsas a default cap so every existing call site inherits the safety net for free.Generated with Claude Code