Compare commits

...
Author SHA1 Message Date
skylartaylor fa6bb0351b docs: clarify security regression guarantees 2026-10-23 02:14:11 +02:00
skylartaylor 48b4165591 docs: document security limits and regression guarantees 2026-10-23 01:59:55 +02:00
Dylan M. Taylor 01bbdb24bc security: confine craft-fonts manifest paths and reject newline directives (CWE-22, CWE-74) (#1841)
The optional `CRAFT_FONTS_DIR` build input lets a font manifest select the file
each face is embedded from. `build.rs` canonicalised that file with no
containment check, so an absolute path, a `..` traversal, or a symlink inside
the checkout could make the generated `include_bytes!` read an arbitrary file
on the build host and expose its bytes through `CRAFT_FONTS` (CWE-22).

It also printed the resolved path straight into Cargo's newline-delimited
build-script protocol (`cargo::rerun-if-changed=…`). A path containing an
embedded newline — reachable through a crafted symlink target — could inject a
second directive such as `cargo::rustc-env=OUT_DIR=…`, redirecting the
generated-source include to attacker-supplied Rust (CWE-74).

- `resolve_font` rejects absolute and `..` manifest paths and requires the
  canonicalised result to stay inside the canonicalised checkout root.
- `rerun_if_changed` refuses any path containing CR/LF before emitting the
  directive; `one_line` collapses CR/LF in checkout-derived diagnostics.

Addresses findings rows 5 (CWE-22) and 14 (CWE-74).
2026-10-22 18:42:51 -04:00
Dylan M. Taylor 15ba3dc5cc security: submit XATTACH picked path as literal text, not a command line (CWE-74) (#1843)
The External References palette's XATTACH handler passed the file picker's
result to `app.cmdline(&path)`. `cmdline` interprets command-line syntax, so a
selected filename beginning with an apostrophe (for example `'closeall`) was run
as an engine command rather than naming the file — closing every drawing without
the UI's unsaved-change confirmation.

The picked path is attacker-controlled (the picker returns whatever the user
selected, and `relative_to_host` can reduce it to a bare basename). After
starting XATTACH, submit it through `Session::input(Input::Text(path))` so it is
delivered to the command's prompt as literal text and never interpreted as a
command, matching how other picker/dialog bridges already behave.

Addresses finding row 26.
2026-10-22 18:19:13 -04:00
Dylan M. Taylor 07bb604214 security: guard the DXF->DWG conversion input and document decoder limits (CWE-789) (#1847)
The native DWG bridge wraps the third-party `acadrust` decoder, which allocates
tables and section buffers from sizes a file declares and cannot be interrupted
once it starts. The DWG->DXF read path already rejects oversized input before
decoding, caps the DXF output while writing, and runs inside `catch_unwind`. The
DXF->DWG `convert` path had no input-size guard, so a very large DXF went
straight into the dependency's reader.

- `convert` now rejects DXF input larger than `Limits::max_dxf_bytes` before
  calling `DxfReader::read`, symmetric with the read path, and keeps the existing
  `catch_unwind` around the decoder and writer (rows 23, 3, 27, large-input
  variant).
- Documented the residual risk: a small file that declares a huge internal table
  or section can still make the dependency allocate before any size is known.
  Fully fixing that needs an upstream fix in `acadrust` (validate declared sizes
  against a decoded-memory budget) or a separate worker with an OS memory limit;
  the in-crate limits keep the blast radius bounded until then.

Addresses findings rows 3, 23, 27 (bounds the reachable input/output size;
the deeper allocation fix belongs in the unmodified dependency).
2026-10-22 18:18:43 -04:00
30 changed files with 206 additions and 6 deletions
+10
View File
@@ -48,6 +48,8 @@ impl Default for Limits {
}
}
/// Start the loopback server with a fresh session token and a bounded request queue.
/// Binding or token-issuance failure returns a disconnected receiver.
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
match TcpListener::bind(("127.0.0.1", port)) {
Ok(l) => {
@@ -101,6 +103,7 @@ impl Drop for Slot {
}
}
/// Accept clients within the connection limit and give each worker the same session token.
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits, token: Arc<String>) {
let open = Arc::new(AtomicUsize::new(0));
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
@@ -162,6 +165,7 @@ enum Line {
Reject(String),
}
/// Parse a UTF-8 JSON request and its optional token, distinguishing blank lines from rejected input.
fn parse_line(bytes: &[u8]) -> Line {
let Ok(text) = std::str::from_utf8(bytes) else {
return Line::Reject("not a control request (invalid UTF-8); closing the connection".into());
@@ -198,6 +202,8 @@ fn is_http_request_line(text: &str) -> bool {
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
}
/// Serve bounded JSON lines, authenticating each request before enqueueing it for the UI.
/// Invalid credentials close the connection; queue and reply limits report whether a request ran.
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits, token: &str) {
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
return;
@@ -385,6 +391,7 @@ mod tests {
assert!(t.elapsed() < Duration::from_secs(4));
}
/// Authenticated requests rejected by a full queue retain their IDs and report that they did not run.
#[test]
fn a_full_queue_answers_busy() {
let (addr, rx) = serve_with(short());
@@ -407,6 +414,7 @@ mod tests {
drop(rx);
}
/// Replies on one authenticated connection preserve the ID of each request.
#[test]
fn requests_are_answered_with_their_ids() {
let addr = serve_echo();
@@ -423,6 +431,7 @@ mod tests {
}
}
/// Missing and incorrect session tokens are rejected before dispatch.
#[test]
fn a_request_without_the_token_is_unauthorized() {
// A local peer that does not hold the session token cannot drive the app (CWE-306).
@@ -437,6 +446,7 @@ mod tests {
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
}
/// A request carrying the session token reaches the control dispatcher.
#[test]
fn a_request_with_the_token_is_dispatched() {
let addr = serve_echo();
+3
View File
@@ -89,6 +89,8 @@ struct CompDof {
objects: Vec<Option<usize>>,
}
/// Compute component and object freedom only when rank and residual-evaluation work fit their budgets.
/// Return unknown freedom before building the Jacobian when either work limit is exceeded.
fn analyse(comp: &Comp) -> CompDof {
let free = comp.free(None);
let rows = comp.rows(None);
@@ -213,6 +215,7 @@ mod budget_tests {
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
use cadcraft_geom::{Spline, Vec2};
/// An expensive fit-spline endpoint residual produces unknown freedom before numerical differentiation.
#[test]
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
let mut d = Drawing::default();
+2
View File
@@ -328,6 +328,8 @@ pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> R
build_budgeted(d, cons, opts, &mut members_left)
}
/// Build constraint components with one caller-owned rigid-member sampling budget shared by every model.
/// Return constraints that cannot be interpreted as skipped entries.
pub(crate) fn build_budgeted(
d: &Drawing,
cons: &[&Constraint],
+5
View File
@@ -117,6 +117,8 @@ pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
rigid_points_budgeted(d, ins, &mut members_left)
}
/// Sample finite block points within per-insert and caller-shared member limits.
/// Members that contribute no points still consume traversal work; nested blocks are not entered.
fn rigid_points_budgeted(d: &Drawing, ins: &Insert, members_left: &mut usize) -> Vec<Vec2> {
let Some(block) = d.block(&ins.block) else { return Vec::new() };
let m = ins.transform(block.base.xy());
@@ -187,6 +189,7 @@ impl Model {
Self::build_budgeted(d, handles, &mut members_left)
}
/// Build solver parameters for supported entities while sharing rigid-member sampling work with other models.
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
let mut m = Model::default();
for &h in handles {
@@ -620,6 +623,7 @@ mod budget_tests {
use cadcraft_geom::Vec3;
use std::sync::Arc;
/// Writing an unchanged model back leaves partial rational weights and the original spline geometry intact.
#[test]
fn write_back_keeps_partial_rational_weights() {
let mut drawing = Drawing::default();
@@ -635,6 +639,7 @@ mod budget_tests {
}
}
/// A second model receives no sampled block points after the first model spends the shared member budget.
#[test]
fn rigid_sampling_budget_is_shared_between_models() {
let mut d = Drawing::default();
+1
View File
@@ -116,6 +116,7 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
}
impl Built {
/// Estimate spline reconstruction work for endpoint and join residuals; other residual forms add no such cost.
pub(crate) fn spline_reconstruct_cost(&self, m: &Model) -> f64 {
let cost = |i: usize| self.refs.get(i).map_or(0.0, |r| m.spline_reconstruct_cost(r.handle));
match self.form {
+1
View File
@@ -364,6 +364,7 @@ fn hostile_new_geometry_never_panics() {
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
}
/// Fit-point vertex constraints retain a known freedom count because they do not reconstruct the spline.
#[test]
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
let mut d = Drawing::default();
+39
View File
@@ -4,6 +4,25 @@
//! converted to DXF bytes, which CADCraft's own DXF reader maps to its document model; saving
//! goes the other way. Keeping the dependency behind this byte-level API isolates it: nothing
//! else in CADCraft depends on its types. Native targets only (it memory-maps files).
//!
//! # Guardrails around the third-party decoder
//!
//! The decoder allocates tables and section buffers from sizes a (possibly damaged or hostile)
//! file declares, and once it starts it cannot be interrupted (an allocation failure aborts the
//! process, which `catch_unwind` cannot recover). Because the dependency is used unmodified, this
//! crate guards it from the outside:
//!
//! - Both directions reject oversized input *before* the decoder runs ([`Limits::max_dwg_bytes`]
//! for DWG→DXF, [`Limits::max_dxf_bytes`] for DXF→DWG), and the DWG→DXF output is capped while
//! it is written (`CappedWriter`).
//! - The reader and writer run inside `catch_unwind`, so a decode failure is reported instead of
//! crashing.
//!
//! These bound the input- and output-size variants. A small file that declares a huge internal
//! table or section can still make the dependency allocate before any size is known; fully fixing
//! that requires an upstream fix in `acadrust` (validate declared sizes against a decoded-memory
//! budget) or running the decode in a separate worker with an OS memory limit. The limits here
//! keep the blast radius bounded in the meantime.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
@@ -505,6 +524,17 @@ mod native {
}
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
// Reject a pathologically large DXF before handing it to the third-party decoder, which
// allocates tables and section buffers from the input's declared sizes and cannot be
// interrupted once it starts (CWE-789). This bounds the large-input variant; the decoder
// runs inside `catch_unwind` so a decode failure is reported rather than crashing.
if dxf.len() > Limits::DEFAULT.max_dxf_bytes {
return Err(format!(
"DXF: the input is {}, larger than the {} CADCraft converts to DWG",
mb(dxf.len() as u64),
mb(Limits::DEFAULT.max_dxf_bytes as u64)
));
}
let data = dxf.to_vec();
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
.map_err(|_| "the DXF→DWG conversion failed".to_string())?
@@ -618,6 +648,15 @@ mod tests {
assert!(dwg_to_dxf(b"").is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn dxf_to_dwg_rejects_garbage_without_crashing() {
// The DXF→DWG path runs the third-party reader inside catch_unwind and reports failures
// rather than crashing (and rejects oversized input before decoding).
assert!(dxf_to_dwg(b"not a dxf file").is_err());
assert!(dxf_to_dwg(b"").is_err());
}
/// A DXF with one DIMENSION per (text, group 70, subclass, points) row.
#[cfg(not(target_arch = "wasm32"))]
fn dims_dxf(dims: &[(&str, i64, &str, &[(i32, [f64; 2])])]) -> String {
+3
View File
@@ -44,6 +44,7 @@ pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
mod tests {
use super::*;
/// Regular-file reads accept the byte boundary and reject any larger payload.
#[test]
fn reads_within_limit_and_rejects_oversize() {
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
@@ -57,6 +58,7 @@ mod tests {
std::fs::remove_dir_all(&dir).ok();
}
/// An endless device is rejected before its contents can be buffered.
#[cfg(unix)]
#[test]
fn rejects_non_regular_file() {
@@ -66,6 +68,7 @@ mod tests {
assert!(read_capped(p, 8 << 20).is_err());
}
}
/// Opening a FIFO without a writer returns an error without waiting for a peer.
#[cfg(unix)]
#[test]
fn rejects_fifo_without_a_writer() {
+1
View File
@@ -337,6 +337,7 @@ fn opened(s: &Session, i: usize, entities: bool) -> Value {
}
}
/// Open a drawing from bounded regular-file bytes or base64 data and resolve its external references.
fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(_path) = str_param(p, "path") {
#[cfg(not(target_arch = "wasm32"))]
+9
View File
@@ -698,11 +698,14 @@ fn wcmatch(a: &[Value]) -> R {
/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging.
const WCMATCH_BUDGET: u64 = 200_000_000;
/// Spend one wildcard work unit, returning an error when the shared budget is exhausted.
fn charge_match(budget: &mut u64) -> Result<(), ()> {
*budget = budget.checked_sub(1).ok_or(())?;
Ok(())
}
/// Split comma-separated wildcard alternatives while preserving escapes and bracket sets.
/// Every consumed character spends from the same budget used for matching.
fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
let mut out = vec![Vec::new()];
let mut chars = pat.chars().peekable();
@@ -742,6 +745,7 @@ fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
Ok(out)
}
/// Match one wildcard alternative, including leading `~` negation, within the shared budget.
fn match_pattern(text: &[char], pat: &[char], budget: &mut u64) -> Result<bool, ()> {
if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r))
&& !rest.is_empty()
@@ -757,6 +761,7 @@ fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result<b
glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new())
}
/// Match suffixes while memoizing failures; both matching work and retained failure entries are bounded.
fn glob_cached(
text: &[char],
pat: &[char],
@@ -778,6 +783,7 @@ fn glob_cached(
Ok(matched)
}
/// Evaluate wildcard tokens and backtracking branches under shared work and recursion limits.
fn glob_inner(
text: &[char],
pat: &[char],
@@ -1060,6 +1066,7 @@ fn findfile(a: &[Value]) -> R {
Ok(if file_exists(&p) { Value::Str(p) } else { Value::Nil })
}
/// Read a regular text file up to 64 MiB, replacing invalid UTF-8 and returning `None` on read failure.
#[cfg(not(target_arch = "wasm32"))]
pub fn read_text_file(path: &str) -> Option<String> {
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
@@ -1068,6 +1075,7 @@ pub fn read_text_file(path: &str) -> Option<String> {
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
Some(String::from_utf8_lossy(&bytes).into_owned())
}
/// Return `None` on the web, where native filesystem paths are unavailable.
#[cfg(target_arch = "wasm32")]
pub fn read_text_file(_path: &str) -> Option<String> {
None
@@ -1588,6 +1596,7 @@ pub fn wild_match(text: &str, pattern: &str) -> bool {
mod wildcard_budget_tests {
use super::*;
/// Pattern splitting, literal matching, and bracket scans all stop when their shared fuel runs out.
#[test]
fn all_native_scans_consume_fuel() {
assert!(split_patterns("abcdefgh", &mut 4).is_err());
+3
View File
@@ -158,6 +158,8 @@ pub fn from_json(v: &serde_json::Value) -> Value {
const TO_JSON_BUDGET: u64 = 2_000_000;
const TO_JSON_MAX_DEPTH: usize = 256;
/// Convert a LISP value to owned JSON within element and nesting limits.
/// Return `None` before a shared cons structure can expand beyond the remaining budget.
fn to_json_bounded(v: &Value, depth: usize, budget: &mut u64) -> Option<serde_json::Value> {
if depth > TO_JSON_MAX_DEPTH || *budget == 0 {
return None;
@@ -185,6 +187,7 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
Ok(Flow::Value(crate::sysvars::get(ctx.s, &name).map(|v| from_json(&v)).unwrap_or_default()))
}
/// Set a system variable after bounded JSON conversion, reporting invalid or oversized values as LISP errors.
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
arity(&a, 2, 2)?;
let name = string(&a[0])?;
+1
View File
@@ -131,6 +131,7 @@ pub(crate) struct Limits {
}
impl Default for Limits {
/// Provide finite cons-cell, string-byte, and synchronous-callback limits for a LISP run.
fn default() -> Self {
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET, callback_steps: 500_000_000 }
}
+5
View File
@@ -221,6 +221,7 @@ fn runaway_routines_are_errors_not_crashes() {
assert_eq!(r, Ok("3000000".into()));
}
/// An infinite comparator stops at its callback cap, and a later finite comparator still succeeds.
#[test]
fn synchronous_callback_loop_is_an_error_not_a_hang() {
// A comparator with an infinite loop used to run with u64::MAX fuel and hang the thread; it
@@ -232,6 +233,7 @@ fn synchronous_callback_loop_is_an_error_not_a_hang() {
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)");
}
/// A zero-step callback limit prevents even the first callback side effect.
#[test]
fn a_zero_callback_budget_does_not_run_the_callback() {
let mut s = session();
@@ -242,6 +244,7 @@ fn a_zero_callback_budget_does_not_run_the_callback() {
assert_eq!(ok(&mut s, "callback-ran"), "nil");
}
/// A callback stops at a cap smaller than one scheduling slice without completing all its assignments.
#[test]
fn a_callback_must_fit_within_a_partial_slice() {
let mut s = session();
@@ -252,6 +255,7 @@ fn a_callback_must_fit_within_a_partial_slice() {
assert_eq!(ok(&mut s, "(and (> callback-count 0) (< callback-count 1000))"), "T");
}
/// Crossing a scheduling boundary grants only the additional configured step, rather than another slice.
#[test]
fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
let counts: Vec<u64> = [399_999, 400_000]
@@ -270,6 +274,7 @@ fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
}
/// System-variable conversion rejects a shared list whose owned JSON expansion would exceed its budget.
#[test]
fn setvar_rejects_an_oversized_shared_structure() {
// A list repeatedly sharing its own contents expands exponentially; setvar's bounded JSON
+1
View File
@@ -353,6 +353,7 @@ pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
/// Largest number of node comparisons one `equal` call may perform.
const EQUAL_BUDGET: u64 = 50_000_000;
/// Compare nested values within depth and node-work limits, returning false when either limit is reached.
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64) -> bool {
if depth > MAX_DEPTH || *budget == 0 {
return false;
+44 -5
View File
@@ -18,9 +18,11 @@ fn main() {
match craft_fonts(&dir) {
Ok(entries) => src.push_str(&entries),
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
println!("cargo::error=CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e));
}
Err(e) => {
println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {}", one_line(&dir.display().to_string()), one_line(&e))
}
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
}
}
src.push_str("];\n");
@@ -37,7 +39,9 @@ const BARRED_FAMILIES: [&str; 5] = ["Source Han", "Source Serif", "Source Sans",
/// One `CraftFont { .. }` initialiser per manifest line, in manifest order.
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
let manifest = dir.join("fonts/manifest.txt");
println!("cargo::rerun-if-changed={}", manifest.display());
rerun_if_changed(&manifest)?;
// Resolve the checkout root once so every manifest file can be confined inside it.
let root = dir.canonicalize().map_err(|e| format!("{}: {e}", dir.display()))?;
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").is_ok_and(|a| a == "wasm32");
let mut out = String::new();
@@ -55,8 +59,8 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
if wasm && !web_face {
continue;
}
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
println!("cargo::rerun-if-changed={}", path.display());
let path = resolve_font(&root, file)?;
rerun_if_changed(&path)?;
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
let _ = writeln!(
out,
@@ -67,3 +71,38 @@ fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
}
Ok(out)
}
/// Resolve a manifest `file` field to a real path *confined to the checkout* `root` (CWE-22).
///
/// The field is rejected if it is absolute or contains a parent (`..`) component, and the
/// canonicalised result must still live inside `root`, so a crafted manifest or a symlink in the
/// checkout cannot make the generated `include_bytes!` read an arbitrary file on the build host.
fn resolve_font(root: &std::path::Path, file: &str) -> Result<PathBuf, String> {
let rel = std::path::Path::new(file);
if rel.is_absolute() || rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
return Err(format!("{file}: manifest paths must be relative and stay inside the checkout"));
}
let path = root.join(rel).canonicalize().map_err(|e| format!("{file}: {e}"))?;
if !path.starts_with(root) {
return Err(format!("{file}: resolves outside the craft-fonts checkout"));
}
Ok(path)
}
/// Collapse CR/LF in a diagnostic derived from checkout contents to a single space, so it cannot
/// introduce an additional Cargo directive on the newline-delimited build-script stream (CWE-74).
fn one_line(s: &str) -> String {
s.replace(['\n', '\r'], " ")
}
/// Emit a `cargo::rerun-if-changed` directive for `path`, refusing paths that contain a line
/// separator (CWE-74): Cargo's build-script protocol is newline-delimited, so a path with an
/// embedded CR/LF could smuggle an extra directive such as `cargo::rustc-env=OUT_DIR=…`.
fn rerun_if_changed(path: &std::path::Path) -> Result<(), String> {
let shown = path.display().to_string();
if shown.contains(['\n', '\r']) {
return Err(format!("{shown:?}: path contains a line separator"));
}
println!("cargo::rerun-if-changed={shown}");
Ok(())
}
+1
View File
@@ -60,6 +60,7 @@ pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor:
catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok()
}
/// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs.
fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped {
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
+1
View File
@@ -685,6 +685,7 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
mod tests {
use super::*;
/// Incremental character accounting preserves the text of a multi-word line without introducing wrapping.
#[test]
fn line_char_offsets_track_incrementally() {
// A multi-word line lays out correctly even though the per-line character count is now
+16
View File
@@ -624,6 +624,7 @@ struct GlyphCache {
points: usize,
}
/// Return the process-wide outline cache whose retained glyph and point counts are bounded during insertion.
fn glyph_cache() -> &'static RwLock<GlyphCache> {
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
C.get_or_init(|| RwLock::new(GlyphCache::default()))
@@ -643,6 +644,7 @@ struct Pen {
const MAX_OUTLINE_POINTS: usize = 65_536;
impl Pen {
/// Append one flattened outline point, discarding all contours if the per-glyph point limit is exceeded.
fn push(&mut self, point: Vec2) {
if self.truncated {
return;
@@ -661,6 +663,7 @@ impl Pen {
fn pt(x: f32, y: f32) -> Vec2 {
Vec2::new(f64::from(x), f64::from(y))
}
/// Close and retain a nondegenerate contour only if its closing point also fits the outline budget.
fn flush(&mut self) {
if self.cur.len() > 2 {
if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied())
@@ -678,13 +681,16 @@ impl Pen {
}
impl OutlinePen for Pen {
/// Finish the preceding contour and begin a new one through the budgeted point writer.
fn move_to(&mut self, x: f32, y: f32) {
self.flush();
self.push(Self::pt(x, y));
}
/// Append a line endpoint through the budgeted point writer.
fn line_to(&mut self, x: f32, y: f32) {
self.push(Self::pt(x, y));
}
/// Flatten a quadratic segment into six samples, charging each retained point to the glyph budget.
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
let a = self.cur.last().copied().unwrap_or_default();
let c = Self::pt(cx0, cy0);
@@ -695,6 +701,7 @@ impl OutlinePen for Pen {
self.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
}
}
/// Flatten a cubic segment into eight samples, charging each retained point to the glyph budget.
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
let a = self.cur.last().copied().unwrap_or_default();
let c0 = Self::pt(cx0, cy0);
@@ -742,6 +749,8 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
glyph_by_id(face, u32::from(gid)).contours.clone()
}
/// Load or build a glyph outline with bounded flattening, retaining its advance even when contours are discarded.
/// Cache insertion clears existing entries when the glyph-count or aggregate point allowance would be exceeded.
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&key) {
@@ -812,6 +821,8 @@ pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64,
})
}
/// Charge source outline points before copying and transforming a glyph.
/// Return `None` without spending fuel when it cannot fit; empty outlines remain valid at zero budget.
pub(crate) fn place_glyph_budgeted(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64, points_left: &mut usize) -> Option<Vec<Vec<Vec2>>> {
*points_left = points_left.checked_sub(g.points)?;
Some(place_glyph(g, x, h, wf, shear))
@@ -926,6 +937,7 @@ mod family_tests {
#[cfg(test)]
mod geometry_budget_tests {
use super::*;
/// Placement checks the source point cost before copying and accepts empty outlines without spending points.
#[test]
fn placement_checks_source_size_and_accepts_empty_glyphs() {
let glyph = GlyphOutline { contours: vec![vec![Vec2::ZERO; 4]].into(), points: 4, cap: 1.0, ..Default::default() };
@@ -939,6 +951,7 @@ mod geometry_budget_tests {
assert_eq!(budget, 0);
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none());
}
/// Exceeding the per-glyph flattening limit discards all contour geometry before it reaches the cache.
#[test]
fn flattened_outlines_are_bounded_before_caching() {
let mut pen = Pen::default();
@@ -951,6 +964,7 @@ mod geometry_budget_tests {
assert!(pen.contours.is_empty() && pen.cur.is_empty());
assert_eq!(pen.points, MAX_OUTLINE_POINTS);
}
/// A mapped empty glyph retains text metadata referring to an existing empty outline slot.
#[test]
fn mapped_empty_glyph_keeps_text_and_a_valid_outline_slot() {
// Metadata placement does not parse the face; an empty face suffices to check the index contract.
@@ -963,6 +977,7 @@ mod geometry_budget_tests {
assert!(shaped.glyphs[shaped.text[0].outline].is_empty());
}
/// An empty glyph with no text mapping leaves the shaped result empty.
#[test]
fn unmapped_empty_glyph_does_not_make_the_shape_nonempty() {
let glyph = GlyphOutline { cap: 1.0, ..Default::default() };
@@ -973,6 +988,7 @@ mod geometry_budget_tests {
assert!(shaped.text.is_empty());
}
/// Missing text metadata does not discard a glyph that has drawable contours.
#[test]
fn an_unmapped_glyph_with_contours_still_draws() {
let mut shaped = Shaped::default();
+2
View File
@@ -156,6 +156,8 @@ pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) {
}
}
/// Resolve image definitions under a shared copied-string budget.
/// When strings no longer fit, keep applying pixel-size and resolution metadata.
fn fill(store: &mut EntityStore, by_image: &HashMap<Handle, &Def>, budget: &mut usize) {
let hs: Vec<Handle> =
store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect();
+6
View File
@@ -248,6 +248,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
entity_with_table_budget(kind, tags, &mut 4_000_000)
}
/// Parse one entity while charging materialized table cells to the caller's shared import budget.
fn entity_with_table_budget(kind: &str, tags: &[Tag], table_cells_left: &mut usize) -> Option<(Common, EntityKind)> {
// The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit.
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
@@ -1398,6 +1399,7 @@ fn style_font(tags: &[Tag], file: String) -> String {
}
}
/// Load symbol tables and resolve handle references, indexing layer names for case-insensitive updates.
fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
let recs = records(tags);
// STYLE handle → name, or the shape file of an unnamed shape style (linetype elements).
@@ -1681,6 +1683,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
read_budgeted(bytes, 4_000_000)
}
/// Import DXF sections while sharing one materialized-table-cell budget across blocks and entity sections.
fn read_budgeted(bytes: &[u8], mut table_cells_left: usize) -> Result<Drawing> {
let tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
let secs = sections(&tags);
@@ -2345,9 +2348,11 @@ pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKin
#[cfg(test)]
mod table_budget_tests {
use super::*;
/// Build synthetic table tags with a requested row and column count for allocation-budget checks.
fn table_tags(rows: i64, cols: i64) -> Vec<Tag> {
vec![Tag::s(100, "AcDbTable"), Tag::i(91, rows), Tag::i(92, cols), Tag::i(171, 1), Tag::s(1, "cell")]
}
/// A table that cannot fit allocates no cell grid, while a smaller table spends only its cell count.
#[test]
fn exhausted_budget_rejects_before_materializing_cells() {
let mut left = 3;
@@ -2358,6 +2363,7 @@ mod table_budget_tests {
assert_eq!(table.cells[0].len(), 2);
assert_eq!(left, 1);
}
/// Tables in blocks and repeated entity sections consume one document-wide cell allowance.
#[test]
fn blocks_and_entity_sections_share_one_budget() {
let table = "0\nACAD_TABLE\n100\nAcDbTable\n91\n1\n92\n2\n171\n1\n1\ncell\n";
+2
View File
@@ -172,6 +172,7 @@ impl LeftOut {
}
impl Parser {
/// Initialize a metric SVG drawing with one document-wide generated-vertex allowance.
fn new() -> Self {
Parser {
drawing: Drawing::new_metric(),
@@ -334,6 +335,7 @@ impl Parser {
self.cur().apply_vec(v)
}
/// Charge an entity against element and retained-vertex limits before adding it to model space.
fn add(&mut self, kind: EntityKind) -> Result<()> {
self.elements += 1;
if self.elements > MAX_ELEMENTS {
+2
View File
@@ -17,6 +17,7 @@ fn arc_bomb(arcs: usize) -> Vec<u8> {
svg.into_bytes()
}
/// A small transformed-arc input remains accepted within the generated-geometry budget.
#[test]
fn a_modest_arc_svg_still_reads() {
// A handful of arcs is well within budget and parses fine.
@@ -24,6 +25,7 @@ fn a_modest_arc_svg_still_reads() {
assert!(d.model.iter().count() >= 1, "expected some geometry");
}
/// Repeated flattened arcs fail with a geometry-budget error before retaining excessive vertices.
#[test]
fn an_arc_heavy_svg_is_rejected_by_the_vertex_budget() {
// ~1000 arcs x up to 10_000 chords each would far exceed the 8M vertex budget.
+7
View File
@@ -10,9 +10,11 @@ use serde_json::{Value, json};
/// Something that answers control-channel methods (`engine.execute`, `cmdline.input`,
/// `drawing.inspect`, `ui.render`…). See `cadcraft_ui_egui::control` for the full list.
pub trait Backend {
/// Invoke a control method and return its result or a descriptive error.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String>;
/// True when a real UI is attached (`ui.screenshot` works).
fn has_ui(&self) -> bool;
/// Describe the backend connection for client-facing status.
fn describe(&self) -> String;
}
@@ -37,6 +39,7 @@ fn port_of(addr: &str) -> Option<u16> {
const LAUNCH_WAIT: Duration = Duration::from_secs(30);
impl Remote {
/// Connect to a running control server; its session token is read lazily when the first request is sent.
pub fn connect(addr: &str) -> std::io::Result<Self> {
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None };
r.reconnect()?;
@@ -182,6 +185,8 @@ fn is_read_only(method: &str) -> bool {
}
impl Backend for Remote {
/// Send a control request with the token when available, refreshing it before a safe retry.
/// A mutation that may have reached the server is never resent automatically.
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
let id = self.next_id;
self.next_id += 1;
@@ -447,6 +452,7 @@ mod tests {
server.join().unwrap();
}
}
/// Retrying a read after a lost reply reloads the replacement server session token.
#[test]
fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
@@ -473,6 +479,7 @@ mod tests {
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
server.join().unwrap();
}
/// A failed mutation write reports uncertain delivery and does not open a retry connection.
#[test]
fn failed_mutation_write_is_not_resent() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+13
View File
@@ -23,6 +23,7 @@ pub fn token_path(port: u16) -> io::Result<PathBuf> {
Ok(private_dir()?.join(format!("cadcraft-control-{port}.token")))
}
/// Choose the Unix runtime directory, home cache, or temporary directory for an owner-only token file.
#[cfg(unix)]
fn private_dir() -> io::Result<PathBuf> {
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
@@ -34,11 +35,13 @@ fn private_dir() -> io::Result<PathBuf> {
Ok(std::env::temp_dir())
}
/// Require the Windows per-user local app-data directory for session token storage.
#[cfg(windows)]
fn private_dir() -> io::Result<PathBuf> {
windows_private_dir(std::env::var_os("LOCALAPPDATA"))
}
/// Validate that Windows supplied a nonempty local app-data path instead of using a shared fallback.
#[cfg(any(windows, test))]
fn windows_private_dir(local: Option<std::ffi::OsString>) -> io::Result<PathBuf> {
local
@@ -47,6 +50,7 @@ fn windows_private_dir(local: Option<std::ffi::OsString>) -> io::Result<PathBuf>
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token"))
}
/// Reject token storage on platforms without a supported private-directory convention.
#[cfg(not(any(unix, windows)))]
fn private_dir() -> io::Result<PathBuf> {
Err(io::Error::new(io::ErrorKind::Unsupported, "no private control token directory on this platform"))
@@ -61,6 +65,7 @@ pub fn issue(port: u16) -> io::Result<String> {
Ok(token)
}
/// Create missing parent directories, then write the session token through the platform privacy helper.
fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
@@ -98,6 +103,7 @@ pub fn matches(expected: &str, given: &str) -> bool {
diff == 0
}
/// Encode 256 bits of operating-system randomness as lowercase hexadecimal, propagating entropy failures.
fn random_hex() -> io::Result<String> {
let mut bytes = [0u8; TOKEN_BYTES];
getrandom::fill(&mut bytes).map_err(|e| io::Error::other(format!("no secure randomness: {e}")))?;
@@ -109,6 +115,7 @@ fn random_hex() -> io::Result<String> {
Ok(s)
}
/// Replace a stale token with an exclusively created file using Unix mode `0600`, then flush its bytes.
#[cfg(unix)]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
@@ -120,6 +127,7 @@ fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
f.flush()
}
/// Exclusively create and flush a replacement token within the per-user directory selected by the caller.
#[cfg(not(unix))]
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
use std::io::Write;
@@ -134,6 +142,7 @@ fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
mod tests {
use super::*;
/// An issued token has the expected size, can be read back, and disappears after revocation.
#[test]
fn issue_then_read_round_trips() {
let port = 50000 + (std::process::id() % 10000) as u16;
@@ -144,6 +153,7 @@ mod tests {
assert!(read(port).is_err());
}
/// Token comparison rejects different bytes and lengths while accepting identical tokens.
#[test]
fn matches_is_exact() {
assert!(matches("abc", "abc"));
@@ -152,6 +162,7 @@ mod tests {
assert!(!matches("", "x"));
}
/// A token issued on Unix grants no file permission bits to group or other users.
#[cfg(unix)]
#[test]
fn token_file_is_owner_only() {
@@ -162,6 +173,7 @@ mod tests {
assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode);
revoke(port);
}
/// Windows token storage rejects absent or empty app-data paths and preserves a supplied path.
#[test]
fn windows_requires_local_app_data() {
assert!(windows_private_dir(None).is_err());
@@ -169,6 +181,7 @@ mod tests {
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
}
/// Token issuance creates its missing directory hierarchy before writing the credential.
#[test]
fn issuance_creates_missing_parent() {
let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id()));
+6
View File
@@ -413,6 +413,7 @@ fn tol_height_code(st: &DimStyle) -> String {
format!("{}x", format_decimal(f / 0.7, 4, 8, "."))
}
/// Apply measurement placeholders or append a dimension suffix, bounding the resulting UTF-8 text.
fn post(template: &str, meas: &str) -> String {
if template.contains("<>") {
bounded_replace(template, meas)
@@ -424,6 +425,7 @@ fn post(template: &str, meas: &str) -> String {
}
}
/// Apply dimension text overrides: empty uses the base, whitespace suppresses text, and other text substitutes it.
fn apply_override(base: &str, d: &Dimension) -> String {
if d.text.is_empty() {
base.to_string()
@@ -455,6 +457,8 @@ fn bounded_replace(template: &str, value: &str) -> String {
}
// Returns false when truncation consumed the remaining UTF-8-safe space.
/// Append within the remaining dimension-text byte allowance without splitting a UTF-8 character.
/// Return false if any input text had to be truncated.
fn append_dim_text(out: &mut String, text: &str) -> bool {
let mut end = text.len().min(MAX_DIM_TEXT.saturating_sub(out.len()));
while !text.is_char_boundary(end) {
@@ -1363,12 +1367,14 @@ mod tests {
#[cfg(test)]
mod text_budget_tests {
use super::*;
/// A dimension suffix without placeholders still obeys the total text limit.
#[test]
fn bounds_post_without_markers() {
let text = post(&"x".repeat(MAX_DIM_TEXT * 2), "12");
assert_eq!(text.len(), MAX_DIM_TEXT);
assert!(text.starts_with("12x"));
}
/// Oversized substituted text retains its prefix and ends at a valid UTF-8 boundary.
#[test]
fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() {
let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT));
+4
View File
@@ -79,6 +79,8 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
(0..n).any(|k| (0..n).all(|i| near(a[i], b[(i + k) % n]))) || (0..n).any(|k| (0..n).all(|i| near(a[i], b[(k + n - (i % n)) % n])))
}
/// Remove degenerate and duplicate boundary loops within a shared comparison budget.
/// Retain remaining loops without further comparisons once the budget is exhausted.
fn dedup_polygons(loops: &mut Vec<Vec<Vec2>>) {
// `same_polygon` is O(n²) in a polygon's vertex count (every cyclic alignment, both windings),
// and each loop is compared against every unique one kept so far, so adversarial boundary
@@ -463,6 +465,7 @@ impl Family<'_> {
self.origin + p.rotate(self.ang)
}
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
@@ -606,6 +609,7 @@ mod tests {
#[cfg(test)]
mod dedup_budget_tests {
use super::*;
/// Loops with different vertex counts leave enough comparison fuel to remove a later duplicate.
#[test]
fn unequal_loops_do_not_spend_the_comparison_budget() {
// The old estimate exhausted the budget on this unequal pair before seeing the triangle.
+1
View File
@@ -118,6 +118,7 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
mod tests {
use super::*;
/// This gap-only pattern draws nothing, and `ranges` returns before cycling through its repeats.
#[test]
fn an_all_gap_pattern_draws_nothing_without_spinning() {
let lt = Linetype::simple("GAPS", "", &[-0.001, -0.002]);
+4
View File
@@ -57,6 +57,8 @@ pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
}
// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles.
/// Mark cells covered by merges under grid-size and marking-work limits.
/// Return partial coverage as an error so layout can use its outline fallback instead of assigning owners.
fn coverage(t: &Table) -> Result<Vec<Vec<bool>>, Vec<Vec<bool>>> {
let rows = t.row_heights.len().min(10_000);
let cols = t.col_widths.len().min(10_000);
@@ -395,6 +397,7 @@ mod budget_tests {
use super::*;
use cadcraft_doc::TableCell;
/// Excessive overlapping merges draw only the table outline after coverage work is exhausted.
#[test]
fn overlapping_merges_use_outline_fallback() {
let t = Table {
@@ -409,6 +412,7 @@ mod budget_tests {
assert!(g.fills.is_empty() && g.texts.is_empty());
}
/// A split table within the displayed-cell budget clips a merge to contiguous selected rows.
#[test]
fn part_clips_merges_using_displayed_column_count() {
let t = Table {
+7 -1
View File
@@ -24,7 +24,13 @@ pub fn route(app: &mut CadApp, id: &str, params: &Value) -> Option<Result<Value,
if let Err(e) = app.session.start("xattach") {
return Some(Err(e.to_string()));
}
app.cmdline(&path);
// Submit the picked filename as literal text, not through the command line: a path is
// attacker-controlled (the file picker returns whatever the user selected), and
// `cmdline` interprets command syntax, so a name like `'closeall` would run an engine
// command instead of naming the file (CWE-74).
if let Err(e) = app.session.input(cadcraft_engine::Input::Text(path)) {
return Some(Err(e.to_string()));
}
Some(Ok(Value::Null))
}
_ => None,
+6
View File
@@ -14,11 +14,14 @@ struct Counts {
/// never make the stats command run unbounded work.
const MAX_ENTRIES: usize = 2_000_000;
/// Accumulate Rust source counts under one directory with a fresh traversal-entry budget.
fn walk(dir: &Path, c: &mut Counts) -> Result<(), String> {
let mut budget = MAX_ENTRIES;
walk_inner(dir, c, &mut budget)
}
/// Traverse entries without following symlinks, sharing one budget across descendant directories.
/// Skip unreadable entries and excluded directories; report budget exhaustion to the caller.
fn walk_inner(dir: &Path, c: &mut Counts, budget: &mut usize) -> Result<(), String> {
let Ok(rd) = std::fs::read_dir(dir) else {
return Ok(());
@@ -84,6 +87,7 @@ fn exact_tests(pkg: &str) -> Option<usize> {
Some(String::from_utf8_lossy(&out.stdout).lines().filter(|l| l.ends_with(": test")).count())
}
/// Print per-package source statistics, optionally querying exact test counts, and propagate traversal errors.
pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
let meta = crate::metadata()?;
let mut rows = Vec::new();
@@ -138,6 +142,7 @@ mod tests {
assert_eq!(count_tests(src), (2, 1));
}
/// A directory alias does not cause its Rust source to be counted twice.
#[cfg(unix)]
#[test]
fn walk_does_not_follow_directory_symlinks() {
@@ -154,6 +159,7 @@ mod tests {
assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");
std::fs::remove_dir_all(&base).ok();
}
/// Traversal reports an exhausted entry budget and accepts a tree that exactly fits it.
#[test]
fn walk_reports_budget_exhaustion() {
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));