mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-24 10:56:51 -06:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa6bb0351b | ||
|
|
48b4165591 | ||
|
|
01bbdb24bc | ||
|
|
15ba3dc5cc | ||
|
|
07bb604214 |
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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])?;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user