mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-22 18:24:01 -04:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15ba3dc5cc | ||
|
|
07bb604214 | ||
|
|
dc4bbe86f6 | ||
|
|
953a6b3b01 | ||
|
|
8085745a22 | ||
|
|
ca578a8fb7 | ||
|
|
916be26ed3 | ||
|
|
2dbc7699a0 | ||
|
|
7c72ec45a9 | ||
|
|
0158712bfd | ||
|
|
683eb5e6e7 | ||
|
|
345fb27a69 | ||
|
|
8f988f630d | ||
|
|
acd109d97c | ||
|
|
f4e6873130 | ||
|
|
e4214f7180 | ||
|
|
35934718ea | ||
|
|
916d440bed | ||
|
|
632967aa78 | ||
|
|
f15be2305c | ||
|
|
f2dadec23a | ||
|
|
00acec952e | ||
|
|
78254b4d3f | ||
|
|
8094d7746f | ||
|
|
4ad4f4f598 | ||
|
|
477eec5026 | ||
|
|
38750c6eb8 | ||
|
|
75d9d72703 | ||
|
|
82424e47c9 | ||
|
|
4d826ed91f | ||
|
|
a7bb28d722 | ||
|
|
27bff9cc30 | ||
|
|
d600a80354 | ||
|
|
81f0aa53ff | ||
|
|
27861ad0ed | ||
|
|
b72680cfb7 | ||
|
|
d9adce038c | ||
|
|
4c75b0c458 | ||
|
|
8a2b8f4bdf |
No files matched your search
@@ -30,6 +30,7 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr
|
||||
- **Programmatic calls never open dialogs.** `engine.execute` runs the JSON form with defaults; only menu/toolbar invocation starts the interactive prompt sequence.
|
||||
- **Layering** is enforced by `cargo xtask layers`: L0 `geom`, `dxf` → L1 `color`, `doc` → L2 `fonts`, `render`, `constraints` → L3 `io` → L4 `engine` → L5 `ui-egui`, `mcp` → apps. Nothing below L5 depends on egui/eframe/winit/wgpu/rfd. **The UI crate is swappable.**
|
||||
- **The UI is thin**: panels read engine state and act through `app.run(id, params)` / `app.start(id)` / `app.cmdline(text)`. Colours come from `theme::Tokens`.
|
||||
- **Time goes through `chrono`** (workspace dependency). Never hand-roll UTC/calendar conversion (days-to-date algorithms, leap-year math); a crate owns only its output format string. The one exception is range extension: past the years chrono can represent, shift the date by whole 400-year Gregorian cycles (146,097 days, after which the calendar repeats), let chrono compute the month and day, and add the cycles back to the year (splitting seconds into hours, minutes and seconds is plain arithmetic).
|
||||
- **Rust only** (no handwritten JS/TS). **Never break wasm** (`cargo xtask wasm`).
|
||||
- **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every feature arc that builds, and push to `main`.
|
||||
|
||||
|
||||
Generated
+31
-16
@@ -547,11 +547,13 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-mcp",
|
||||
"cadcraft-ui-egui",
|
||||
"chrono",
|
||||
"eframe",
|
||||
"egui",
|
||||
"hidapi",
|
||||
@@ -569,7 +571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-cli"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -580,14 +582,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-color"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-constraints"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-doc",
|
||||
"cadcraft-geom",
|
||||
@@ -597,7 +599,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-doc"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-geom",
|
||||
@@ -608,14 +610,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dwg"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-dxf"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"log",
|
||||
@@ -624,7 +626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-engine"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-constraints",
|
||||
@@ -635,6 +637,7 @@ dependencies = [
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"craft-docpath",
|
||||
"libc",
|
||||
"log",
|
||||
"rstar",
|
||||
"serde",
|
||||
@@ -644,7 +647,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-fonts"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-geom",
|
||||
"harfrust",
|
||||
@@ -654,14 +657,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-geom"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-io"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"acadrust",
|
||||
"cadcraft-color",
|
||||
@@ -681,18 +684,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-mcp"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
"cadcraft-render",
|
||||
"getrandom 0.3.4",
|
||||
"log",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-render"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -706,7 +710,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-ui-egui"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-color",
|
||||
"cadcraft-doc",
|
||||
@@ -714,6 +718,7 @@ dependencies = [
|
||||
"cadcraft-fonts",
|
||||
"cadcraft-geom",
|
||||
"cadcraft-render",
|
||||
"chrono",
|
||||
"craft-ui",
|
||||
"egui",
|
||||
"egui-wgpu",
|
||||
@@ -728,7 +733,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cadcraft-web"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"cadcraft-engine",
|
||||
"cadcraft-io",
|
||||
@@ -825,6 +830,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -4811,8 +4825,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
|
||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members
|
||||
|
||||
[workspace.package]
|
||||
version = "0.10.0"
|
||||
version = "0.11.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.90"
|
||||
@@ -32,6 +32,8 @@ thiserror = "2"
|
||||
serde = { version = "1", features = ["derive", "rc"] }
|
||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||
log = "0.4"
|
||||
# All date/time conversion goes through chrono (no hand-rolled calendar code beyond the 400-year range extension; AGENTS.md).
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
|
||||
rayon = "1"
|
||||
|
||||
# rendering / formats
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/app-icon/hicolor/256x256/apps/ai.storyteller.cadcraft.png" width="128" height="128" alt="CADCraft icon: a drafting set square and compass arc on a blue grid tile">
|
||||
</p>
|
||||
|
||||
<h1 align="center">CADCraft</h1>
|
||||
|
||||
<p align="center">
|
||||
@@ -285,6 +289,14 @@ See [ROADMAP.md](ROADMAP.md) for the stage, measured parity and the estimate of
|
||||
|
||||
**Download CADCraft** from GitHub: the [latest release](https://github.com/storytold/cadcraft/releases/latest) has every build listed below, and [all releases](https://github.com/storytold/cadcraft/releases) has earlier versions and their notes. `<ver>` in the file names is the version number, and `SHA256SUMS.txt` lists a checksum for every file.
|
||||
|
||||
> [!WARNING]
|
||||
> Download official CADCraft builds from the [storytold/cadcraft releases](https://github.com/storytold/cadcraft/releases). Verify that the repository owner is `storytold`. Before you run a download, check that its checksum matches the line for that file in the release's `SHA256SUMS.txt`:
|
||||
>
|
||||
> ```sh
|
||||
> shasum -a 256 cadcraft-<ver>-macos-universal.dmg # macOS (on Linux: sha256sum FILE)
|
||||
> Get-FileHash .\cadcraft-<ver>-windows-x64.msi # Windows PowerShell
|
||||
> ```
|
||||
|
||||
### Windows
|
||||
|
||||
| Build | Installer | Portable |
|
||||
|
||||
@@ -14,10 +14,13 @@ workspace = true
|
||||
cadcraft-engine = { workspace = true }
|
||||
cadcraft-ui-egui = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
# For the shared per-session control-token helper (control_server.rs authentication).
|
||||
cadcraft-mcp = { workspace = true }
|
||||
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||
egui = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
winresource = "0.1"
|
||||
|
||||
@@ -51,8 +51,21 @@ impl Default for Limits {
|
||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
match TcpListener::bind(("127.0.0.1", port)) {
|
||||
Ok(l) => {
|
||||
log::info!("control server listening on 127.0.0.1:{port}");
|
||||
serve_listener(l, Limits::default(), ctx)
|
||||
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
|
||||
// token in an owner-only file and require it on every request (CWE-306). A client
|
||||
// (the MCP bridge) reads it from the file, which needs the desktop user's filesystem
|
||||
// access; another local account cannot.
|
||||
let token = match cadcraft_mcp::control_token::issue(port) {
|
||||
Ok(t) => Arc::new(t),
|
||||
Err(e) => {
|
||||
log::error!("control server not started: could not write the control token: {e}");
|
||||
return sync_channel::<ControlRequest>(0).1;
|
||||
}
|
||||
};
|
||||
if let Ok(path) = cadcraft_mcp::control_token::token_path(port) {
|
||||
log::info!("control server listening on 127.0.0.1:{port} (token in {})", path.display());
|
||||
}
|
||||
serve_listener(l, Limits::default(), ctx, token)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("control server failed to bind 127.0.0.1:{port}: {e}");
|
||||
@@ -61,10 +74,20 @@ pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
}
|
||||
}
|
||||
|
||||
/// How long the accepting thread waits after a failed accept. Accepting can keep failing, for
|
||||
/// example while the process has no file descriptors left, and retrying at once would keep a
|
||||
/// core busy until it recovers.
|
||||
const ACCEPT_RETRY: Duration = Duration::from_millis(100);
|
||||
|
||||
/// The connections `incoming` accepts, calling `wait` after each failed accept.
|
||||
fn accepted<T>(incoming: impl Iterator<Item = std::io::Result<T>>, mut wait: impl FnMut()) -> impl Iterator<Item = T> {
|
||||
incoming.filter_map(move |stream| stream.map_err(|_| wait()).ok())
|
||||
}
|
||||
|
||||
/// Accept connections on `listener` under `limits`; requests arrive on the returned receiver.
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||
fn serve_listener(listener: TcpListener, limits: Limits, ctx: egui::Context, token: Arc<String>) -> Receiver<ControlRequest> {
|
||||
let (tx, rx) = sync_channel::<ControlRequest>(limits.queue.max(1));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits));
|
||||
std::thread::spawn(move || accept_loop(listener, tx, ctx, limits, token));
|
||||
rx
|
||||
}
|
||||
|
||||
@@ -78,9 +101,9 @@ impl Drop for Slot {
|
||||
}
|
||||
}
|
||||
|
||||
fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui::Context, limits: Limits) {
|
||||
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 listener.incoming().flatten() {
|
||||
for mut stream in accepted(listener.incoming(), || std::thread::sleep(ACCEPT_RETRY)) {
|
||||
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||
open.fetch_sub(1, Ordering::SeqCst);
|
||||
@@ -90,9 +113,10 @@ fn accept_loop(listener: TcpListener, tx: SyncSender<ControlRequest>, ctx: egui:
|
||||
let slot = Slot(Arc::clone(&open));
|
||||
let tx = tx.clone();
|
||||
let ctx = ctx.clone();
|
||||
let token = Arc::clone(&token);
|
||||
let _ = std::thread::Builder::new().spawn(move || {
|
||||
let _slot = slot;
|
||||
serve(stream, &tx, &ctx, limits);
|
||||
serve(stream, &tx, &ctx, limits, &token);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -131,6 +155,7 @@ enum Line {
|
||||
id: Value,
|
||||
method: String,
|
||||
params: Value,
|
||||
token: Option<String>,
|
||||
},
|
||||
Blank,
|
||||
/// Not a request: reply with this error and close the connection.
|
||||
@@ -162,6 +187,7 @@ fn parse_line(bytes: &[u8]) -> Line {
|
||||
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||
method: method.to_string(),
|
||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
||||
token: obj.get("token").and_then(Value::as_str).map(str::to_string),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +198,7 @@ fn is_http_request_line(text: &str) -> bool {
|
||||
|| text.rsplit_once(' ').is_some_and(|(_, version)| version.starts_with("HTTP/"))
|
||||
}
|
||||
|
||||
fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context, limits: Limits) {
|
||||
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;
|
||||
}
|
||||
@@ -190,7 +216,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
Ok(true) => match parse_line(&buf) {
|
||||
Line::Blank => continue,
|
||||
Line::Reject(e) => e,
|
||||
Line::Request { id, method, params } => {
|
||||
Line::Request { id, method, params, token: given } => {
|
||||
// Authenticate the peer before dispatching: loopback TCP does not identify the
|
||||
// OS user, so only a client holding the owner-only session token may drive the
|
||||
// desktop user's commands (CWE-306).
|
||||
if !given.map(|g| cadcraft_mcp::control_token::matches(token, &g)).unwrap_or(false) {
|
||||
let mut r = json!({"ok": false, "error": "unauthorized: missing or invalid control token", "state": "not-run"});
|
||||
if let Some(o) = r.as_object_mut() {
|
||||
o.insert("id".into(), id);
|
||||
}
|
||||
let _ = writeln!(out, "{r}");
|
||||
break;
|
||||
}
|
||||
let (req, pending) = ControlRequest::new(method, params);
|
||||
let mut r = match tx.try_send(req) {
|
||||
Ok(()) => {
|
||||
@@ -222,6 +259,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A failed accept is followed by a wait before the next one, so a listener that keeps
|
||||
/// failing does not spin, and the connections around the failures still come through.
|
||||
#[test]
|
||||
fn a_failed_accept_waits_before_the_next() {
|
||||
let failed = || Err(std::io::Error::other("no file descriptors left"));
|
||||
let incoming = vec![failed(), Ok(1), failed(), failed(), Ok(2)];
|
||||
let mut waits = 0;
|
||||
let connections: Vec<i32> = super::accepted(incoming.into_iter(), || waits += 1).collect();
|
||||
assert_eq!(connections, [1, 2]);
|
||||
assert_eq!(waits, 3, "one wait per failed accept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lines_are_read_up_to_the_limit() {
|
||||
let mut buf = Vec::new();
|
||||
@@ -255,11 +304,14 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A fixed token for tests; real tokens are random (see `cadcraft_mcp::control_token`).
|
||||
const TEST_TOKEN: &str = "test-control-token";
|
||||
|
||||
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
|
||||
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
(addr, serve_listener(listener, limits, egui::Context::default()))
|
||||
(addr, serve_listener(listener, limits, egui::Context::default(), Arc::new(TEST_TOKEN.to_string())))
|
||||
}
|
||||
|
||||
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||
@@ -273,6 +325,11 @@ mod tests {
|
||||
addr
|
||||
}
|
||||
|
||||
/// A request line carrying the test token, so it authenticates.
|
||||
fn req_line(id: i64, method: &str) -> String {
|
||||
json!({"id": id, "method": method, "token": TEST_TOKEN}).to_string() + "\n"
|
||||
}
|
||||
|
||||
/// Send `input` on a new connection and read reply lines until the server closes it.
|
||||
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
@@ -332,12 +389,12 @@ mod tests {
|
||||
fn a_full_queue_answers_busy() {
|
||||
let (addr, rx) = serve_with(short());
|
||||
// No UI drains the one-place queue: the first request fills it, the second finds it full.
|
||||
let first = std::thread::spawn(move || exchange(addr, b"{\"id\":1,\"method\":\"m\"}\n"));
|
||||
let first = std::thread::spawn(move || exchange(addr, req_line(1, "m").as_bytes()));
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(4);
|
||||
let busy = loop {
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":2,\"method\":\"m\"}\n").unwrap();
|
||||
c.write_all(req_line(2, "m").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
@@ -355,7 +412,8 @@ mod tests {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(b"{\"id\":3,\"method\":\"ping\"}\r\n\n{\"id\":4,\"method\":\"pong\"}\n").unwrap();
|
||||
let input = format!("{}\n{}", req_line(3, "ping").trim_end(), req_line(4, "pong"));
|
||||
c.write_all(input.as_bytes()).unwrap();
|
||||
let mut r = BufReader::new(c);
|
||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||
let mut line = String::new();
|
||||
@@ -365,6 +423,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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).
|
||||
let addr = serve_echo();
|
||||
let replies = exchange(addr, b"{\"id\":9,\"method\":\"ping\"}\n");
|
||||
assert_eq!(replies.len(), 1, "{replies:?}");
|
||||
assert_eq!(replies[0]["ok"], false);
|
||||
assert!(replies[0]["error"].as_str().unwrap().contains("unauthorized"), "{replies:?}");
|
||||
// A wrong token is also refused.
|
||||
let wrong = exchange(addr, b"{\"id\":9,\"method\":\"ping\",\"token\":\"nope\"}\n");
|
||||
assert_eq!(wrong[0]["ok"], false);
|
||||
assert!(wrong[0]["error"].as_str().unwrap().contains("unauthorized"), "{wrong:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_with_the_token_is_dispatched() {
|
||||
let addr = serve_echo();
|
||||
let mut c = TcpStream::connect(addr).unwrap();
|
||||
c.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
c.write_all(req_line(10, "ping").as_bytes()).unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(c).read_line(&mut line).unwrap();
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
assert_eq!((v["ok"].as_bool(), v["method"].as_str()), (Some(true), Some("ping")), "{line}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_connections_are_refused() {
|
||||
let addr = serve_echo();
|
||||
|
||||
@@ -93,7 +93,10 @@ fn matches(name: &str, target: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch.
|
||||
/// `2026-10-08T07:59:17.728Z` (UTC, milliseconds). A clock before 1970 reads as the epoch (the
|
||||
/// `duration_since` clamp); past the last date chrono can represent (year 262143) the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch, so the
|
||||
/// output never changes for a value the old formatter accepted and logging never panics.
|
||||
pub fn timestamp(t: SystemTime) -> String {
|
||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||
let secs = since.as_secs();
|
||||
@@ -102,20 +105,25 @@ pub fn timestamp(t: SystemTime) -> String {
|
||||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{:03}Z", rem / 3600, rem % 3600 / 60, rem % 60, since.subsec_millis())
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 to a proleptic Gregorian (year, month, day) (Howard Hinnant's
|
||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
||||
/// clock can't overflow).
|
||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
||||
let z = days.saturating_add(719_468);
|
||||
let era = z / 146_097;
|
||||
let doe = z % 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
|
||||
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||
@@ -385,8 +393,20 @@ mod tests {
|
||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(951_782_400)), "2000-02-29T00:00:00.000Z");
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(4_107_542_400)), "2100-03-01T00:00:00.000Z");
|
||||
// Before the epoch (a clock set wrong) clamps instead of panicking.
|
||||
// Before the epoch (a clock set wrong) clamps to the epoch instead of panicking.
|
||||
assert_eq!(timestamp(UNIX_EPOCH - Duration::from_secs(5)), "1970-01-01T00:00:00.000Z");
|
||||
// Past chrono's range (a clock set absurdly far ahead) keeps the proleptic Gregorian date
|
||||
// the old formatter gave, rather than falling back to the epoch.
|
||||
// Windows' SystemTime holds only up to about year 30828, so this is checked where it fits.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(10_000_000_000_000)) {
|
||||
assert_eq!(timestamp(far), "318857-05-20T17:46:40.000Z");
|
||||
}
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = UNIX_EPOCH.checked_add(Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(timestamp(far), "292277026596-12-04T15:30:07.000Z");
|
||||
}
|
||||
// Years past 9999 keep the plain zero-padded year (no `+`).
|
||||
assert_eq!(timestamp(UNIX_EPOCH + Duration::from_secs(253_402_300_800)), "10000-01-01T00:00:00.000Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
|
||||
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
|
||||
let tol = if o.distance_tolerance.is_finite() { o.distance_tolerance.clamp(0.0, 1e9) } else { 0.05 };
|
||||
let atol = if o.angle_tolerance.is_finite() { o.angle_tolerance.clamp(0.0, 45.0).to_radians() } else { 1f64.to_radians() };
|
||||
let allow = |k: ConstraintKind| o.types.is_empty() || o.types.iter().any(|t| t.eq_ignore_ascii_case(k.name()));
|
||||
// Resolve the allowed type names into a set once, so membership is O(1) per candidate instead
|
||||
// of re-scanning the whole (attacker-controllable) `o.types` list for every geometric match
|
||||
// (CWE-407). Empty `o.types` means "all types".
|
||||
let allowed: std::collections::HashSet<String> = o.types.iter().map(|t| t.to_ascii_lowercase()).collect();
|
||||
let allow = |k: ConstraintKind| allowed.is_empty() || allowed.contains(&k.name().to_ascii_lowercase());
|
||||
let g = collect(d, handles);
|
||||
let mut out: Vec<Constraint> = Vec::new();
|
||||
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
|
||||
|
||||
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::model::Model;
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build, lm};
|
||||
use crate::{Comp, MAX_CONSTRAINTS, SolveError, SolveOptions, build_budgeted, lm};
|
||||
|
||||
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||
const MAX_NULL_COLS: usize = 600;
|
||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||
const MAX_RANK_WORK: f64 = 4e8;
|
||||
/// Largest residual-evaluation work (rows × free × per-residual cost, including spline
|
||||
/// reconstruction) allowed before building the Jacobian during numerical differentiation.
|
||||
const MAX_EVAL_WORK: f64 = 2e9;
|
||||
/// Most objects listed in one report.
|
||||
const MAX_OBJECTS: usize = 10_000;
|
||||
|
||||
@@ -94,6 +97,14 @@ fn analyse(comp: &Comp) -> CompDof {
|
||||
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
|
||||
return unknown;
|
||||
}
|
||||
// Numerical differentiation evaluates every residual about `rows × free` times, and each
|
||||
// spline-end residual reconstructs its spline through a dense solve. Account for that
|
||||
// reconstruction cost before building the Jacobian so a component with few equations but large
|
||||
// splines cannot pass the rank-work check yet still require billions of operations (row 43).
|
||||
let eval_work = (rows as f64) * (nf as f64) * (1.0 + comp.built.iter().map(|b| b.spline_reconstruct_cost(&comp.model)).sum::<f64>());
|
||||
if eval_work > MAX_EVAL_WORK {
|
||||
return unknown;
|
||||
}
|
||||
let f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
|
||||
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
|
||||
if nf > MAX_NULL_COLS {
|
||||
@@ -126,7 +137,8 @@ fn plural(n: usize, one: &str, many: &str) -> String {
|
||||
/// (constrained or not) and the constraint components they belong to.
|
||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
||||
let (comps, _) = build(d, &cons, &SolveOptions::default())?;
|
||||
let mut members_left = crate::model::MAX_RIGID_WORK;
|
||||
let (comps, _) = build_budgeted(d, &cons, &SolveOptions::default(), &mut members_left)?;
|
||||
let want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||
let mut objects = Vec::new();
|
||||
let mut seen = HashSet::new();
|
||||
@@ -165,7 +177,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
if seen.contains(&h) {
|
||||
continue;
|
||||
}
|
||||
let m = Model::build(d, &[h]);
|
||||
let m = Model::build_budgeted(d, &[h], &mut members_left);
|
||||
let Some(s) = m.slots.first() else { continue };
|
||||
seen.insert(h);
|
||||
free_objects = true;
|
||||
@@ -193,3 +205,34 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||
};
|
||||
Ok(DofReport { dof: total, status, redundant, constraints, objects, message })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use crate::residual::{Built, Form};
|
||||
use cadcraft_doc::{Common, ConstraintKind, EntityKind, GeomRef, Space};
|
||||
use cadcraft_geom::{Spline, Vec2};
|
||||
|
||||
#[test]
|
||||
fn endpoint_analysis_rejects_cubic_fit_work_before_differentiation() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut spline = Spline::from_control(pts.clone(), 3);
|
||||
spline.fit = pts;
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Spline(spline)).unwrap();
|
||||
let model = Model::build(&d, &[h]);
|
||||
let endpoint = Built {
|
||||
id: 1,
|
||||
kind: ConstraintKind::Tangent,
|
||||
refs: vec![GeomRef::whole(h)],
|
||||
form: Form::EndOn { spline: 0, at_end: false },
|
||||
target: 0.0,
|
||||
sign: 1.0,
|
||||
rows: 2,
|
||||
aux_init: Vec::new(),
|
||||
aux: 0,
|
||||
};
|
||||
let comp = Comp { model, built: vec![endpoint], weights: vec![1.0; 2], tol: 1e-9 };
|
||||
assert!(analyse(&comp).dof.is_none());
|
||||
}
|
||||
}
|
||||
@@ -324,12 +324,22 @@ fn components<'a>(cons: &[&'a Constraint]) -> Vec<Vec<&'a Constraint>> {
|
||||
|
||||
/// Build the component systems. Constraints that cannot be interpreted are returned as skipped.
|
||||
pub(crate) fn build(d: &Drawing, cons: &[&Constraint], opts: &SolveOptions) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut members_left = model::MAX_RIGID_WORK;
|
||||
build_budgeted(d, cons, opts, &mut members_left)
|
||||
}
|
||||
|
||||
pub(crate) fn build_budgeted(
|
||||
d: &Drawing,
|
||||
cons: &[&Constraint],
|
||||
opts: &SolveOptions,
|
||||
members_left: &mut usize,
|
||||
) -> Result<(Vec<Comp>, Vec<(u32, String)>), SolveError> {
|
||||
let mut e = env(d);
|
||||
let mut skipped = Vec::new();
|
||||
let mut comps = Vec::new();
|
||||
for group in components(cons) {
|
||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
||||
let mut model = Model::build(d, &handles);
|
||||
let mut model = Model::build_budgeted(d, &handles, members_left);
|
||||
let mut built = Vec::new();
|
||||
for c in group {
|
||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
||||
|
||||
@@ -27,6 +27,11 @@ pub const MAX_PARAMS: usize = 4000;
|
||||
pub const MAX_SPLINE_POINTS: usize = 500;
|
||||
/// Most characteristic points of a block reference used as rigid constraint points.
|
||||
pub const MAX_RIGID_POINTS: usize = 256;
|
||||
/// Block members examined when sampling rigid points, counting those that contribute none, so a
|
||||
/// block padded with point-less entities cannot make sampling (repeated per Insert) unbounded.
|
||||
const MAX_RIGID_MEMBERS: usize = 100_000;
|
||||
/// Shared across every model built for one analysis request.
|
||||
pub(crate) const MAX_RIGID_WORK: usize = 1_000_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum Shape {
|
||||
@@ -74,6 +79,21 @@ pub(crate) struct Model {
|
||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Work for a spline that an endpoint residual actually reconstructs. Fit interpolation
|
||||
/// uses dense Gaussian elimination (cubic); control splines clone and evaluate their template.
|
||||
pub(crate) fn spline_reconstruct_cost(&self, handle: Handle) -> f64 {
|
||||
let Some(s) = self.splines.get(&handle) else { return 0.0 };
|
||||
let n = s.fit.len() as f64;
|
||||
if fit_mode(s) {
|
||||
n * n * n
|
||||
} else {
|
||||
let n = s.control.len() as f64;
|
||||
n * n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Can this entity take part in constraints?
|
||||
pub fn supported(kind: &EntityKind) -> bool {
|
||||
matches!(
|
||||
@@ -93,13 +113,24 @@ pub fn supported(kind: &EntityKind) -> bool {
|
||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
||||
let mut members_left = MAX_RIGID_MEMBERS;
|
||||
rigid_points_budgeted(d, ins, &mut members_left)
|
||||
}
|
||||
|
||||
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());
|
||||
let mut out = Vec::new();
|
||||
// Bound the members examined, not only the points collected: entities that contribute no
|
||||
// points (Text, Ray, …) still cost traversal, so a block full of them — scanned again for
|
||||
// every Insert — would otherwise be unbounded (CWE-407).
|
||||
let mut examined = 0usize;
|
||||
for e in block.entities.iter() {
|
||||
if out.len() >= MAX_RIGID_POINTS {
|
||||
examined += 1;
|
||||
if out.len() >= MAX_RIGID_POINTS || examined > MAX_RIGID_MEMBERS || *members_left == 0 {
|
||||
break;
|
||||
}
|
||||
*members_left -= 1;
|
||||
let pts: Vec<Vec2> = match &e.kind {
|
||||
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
|
||||
EntityKind::Arc(a) => {
|
||||
@@ -152,6 +183,11 @@ fn fit_mode(s: &GSpline) -> bool {
|
||||
impl Model {
|
||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
||||
let mut members_left = MAX_RIGID_WORK;
|
||||
Self::build_budgeted(d, handles, &mut members_left)
|
||||
}
|
||||
|
||||
pub(crate) fn build_budgeted(d: &Drawing, handles: &[Handle], members_left: &mut usize) -> Model {
|
||||
let mut m = Model::default();
|
||||
for &h in handles {
|
||||
if m.index.contains_key(&h) {
|
||||
@@ -208,12 +244,15 @@ impl Model {
|
||||
// fit point; a control polygon's ends are its first and last points when the
|
||||
// knots are clamped.
|
||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
||||
m.splines.insert(h, sp.clone());
|
||||
// Missing weights default to 1 in eval; only excess weights are unused.
|
||||
let mut kept = sp.clone();
|
||||
kept.weights.truncate(kept.control.len());
|
||||
m.splines.insert(h, kept);
|
||||
Shape::Spline { n, fit, ends }
|
||||
}
|
||||
EntityKind::Insert(ins) => {
|
||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
||||
let pts: Vec<Vec2> = rigid_points(d, ins).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
let pts: Vec<Vec2> = rigid_points_budgeted(d, ins, members_left).into_iter().map(|p| (p - base).rotate(-ins.rotation)).collect();
|
||||
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||
let n = pts.len();
|
||||
m.rigid.insert(h, pts);
|
||||
@@ -573,3 +612,58 @@ impl Model {
|
||||
moved
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::{Block, Common, Entity, Line, Space};
|
||||
use cadcraft_geom::Vec3;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn write_back_keeps_partial_rational_weights() {
|
||||
let mut drawing = Drawing::default();
|
||||
let mut spline = GSpline::from_control(vec![Vec2::ZERO, Vec2::new(1.0, 2.0), Vec2::new(3.0, 0.0)], 2);
|
||||
spline.weights = vec![2.0, 0.5];
|
||||
let h = drawing.add(&Space::Model, Common::default(), EntityKind::Spline(spline.clone())).unwrap();
|
||||
let model = Model::build(&drawing, &[h]);
|
||||
model.write_back(&mut drawing, &model.x0);
|
||||
let EntityKind::Spline(after) = &drawing.entity(h).unwrap().kind else { panic!("spline") };
|
||||
assert_eq!(after.weights, spline.weights);
|
||||
for i in 0..=10 {
|
||||
assert!(after.eval(i as f64 / 10.0).near(spline.eval(i as f64 / 10.0), 1e-12));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rigid_sampling_budget_is_shared_between_models() {
|
||||
let mut d = Drawing::default();
|
||||
let mut block = Block::new("BAR");
|
||||
for _ in 0..3 {
|
||||
block.entities.push(Entity {
|
||||
handle: d.new_handle(),
|
||||
common: Common::default(),
|
||||
kind: EntityKind::Line(Line { a: Vec3::ZERO, b: Vec3::new(1.0, 0.0, 0.0) }),
|
||||
});
|
||||
}
|
||||
d.blocks.insert("BAR".into(), Arc::new(block));
|
||||
let ins = Insert {
|
||||
block: "BAR".into(),
|
||||
insert: Vec3::ZERO,
|
||||
scale: Vec3::new(1.0, 1.0, 1.0),
|
||||
rotation: 0.0,
|
||||
attribs: Vec::new(),
|
||||
cols: 1,
|
||||
rows: 1,
|
||||
col_spacing: 0.0,
|
||||
row_spacing: 0.0,
|
||||
};
|
||||
let h = d.add(&Space::Model, Common::default(), EntityKind::Insert(ins)).unwrap();
|
||||
let mut budget = 2;
|
||||
let first = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert_eq!(first.rigid[&h].len(), 4);
|
||||
assert_eq!(budget, 0);
|
||||
let next = Model::build_budgeted(&d, &[h], &mut budget);
|
||||
assert!(next.rigid[&h].is_empty());
|
||||
}
|
||||
}
|
||||
@@ -116,6 +116,15 @@ fn r_at(c: &Constraint, i: usize) -> Option<&GeomRef> {
|
||||
}
|
||||
|
||||
impl Built {
|
||||
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 {
|
||||
Form::EndOn { spline, .. } => cost(spline),
|
||||
Form::Join { .. } => cost(0) + cost(1),
|
||||
_ => 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpret a constraint against the model at `x`. `target` is the evaluated value for
|
||||
/// dimensional constraints (degrees for angular). Errors explain what references are needed.
|
||||
pub fn new(c: &Constraint, m: &Model, x: &[f64], target: f64) -> Result<Built, String> {
|
||||
|
||||
@@ -363,3 +363,21 @@ fn hostile_new_geometry_never_panics() {
|
||||
let _ = dof(&d, &hs);
|
||||
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vertex_constraints_on_large_fit_spline_do_not_charge_reconstruction() {
|
||||
let mut d = Drawing::default();
|
||||
let pts: Vec<_> = (0..500).map(|i| Vec2::new(i as f64, (i % 3) as f64)).collect();
|
||||
let mut sp = GSpline::from_control(pts.clone(), 3);
|
||||
sp.fit = pts;
|
||||
let h = put(&mut d, EntityKind::Spline(sp));
|
||||
for i in 0..5 {
|
||||
let p = point(&mut d, (i as f64, (i % 3) as f64));
|
||||
let mut c = con(K::Coincident, &[s(h, Sub::Vertex(i)), w(p)]);
|
||||
c.id = i + 1;
|
||||
d.constraints.push(c);
|
||||
}
|
||||
let report = dof(&d, &[]).unwrap();
|
||||
assert_eq!(report.status, DofStatus::Under);
|
||||
assert_eq!(report.dof, Some(1000));
|
||||
}
|
||||
@@ -248,10 +248,17 @@ impl ArrayParams {
|
||||
return false;
|
||||
}
|
||||
let Some(items) = self.items() else { return false };
|
||||
// Index the replacement entries by placement so each item is matched in O(1) instead of
|
||||
// scanning the whole `replaced` vector per item, which is quadratic when both the items
|
||||
// and the replacements are large (CWE-407). Last entry wins, as `.rev().find` did.
|
||||
let mut last_replaced: std::collections::HashMap<(u32, u32, u32), usize> = std::collections::HashMap::with_capacity(self.replaced.len());
|
||||
for (idx, r) in self.replaced.iter().enumerate() {
|
||||
last_replaced.insert((r.i, r.j, r.level), idx);
|
||||
}
|
||||
let mut source: Option<&str> = None;
|
||||
for (it, e) in items.iter().zip(&ents) {
|
||||
let EntityKind::Insert(ins) = &e.kind else { return false };
|
||||
match self.replaced.iter_mut().rev().find(|r| (r.i, r.j, r.level) == (it.i, it.j, it.level)) {
|
||||
match last_replaced.get(&(it.i, it.j, it.level)).and_then(|&idx| self.replaced.get_mut(idx)) {
|
||||
Some(r) => r.block.clone_from(&ins.block),
|
||||
None => match source {
|
||||
None => source = Some(&ins.block),
|
||||
|
||||
@@ -334,7 +334,11 @@ fn graph_members<'a>(objects: &HashMap<Handle, &'a ForeignObject>, xdict: Handle
|
||||
}
|
||||
let graph = objects.get(&graph?).filter(|o| o.kind.eq_ignore_ascii_case("ACAD_EVALUATION_GRAPH"))?;
|
||||
let members = section(§ions(&graph.tags), "AcDbEvalGraph").iter().filter(|t| t.code == 360).filter_map(|t| Handle::parse_hex(&t.value));
|
||||
Some(members.take(MAX_NODES).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
// Deduplicate member handles before resolving them: a graph can list the same member many
|
||||
// times, and parsing each occurrence into its own owned parameter multiplies a single large
|
||||
// record into gigabytes of retained allocations (CWE-400).
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
Some(members.take(MAX_NODES).filter(|h| seen.insert(*h)).filter_map(|h| objects.get(&h).copied()).collect())
|
||||
}
|
||||
|
||||
/// The dynamic block data of block `name` (any case; `*Model_Space` for a drawing that is a
|
||||
|
||||
@@ -464,7 +464,15 @@ fn apply(
|
||||
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||
continue;
|
||||
}
|
||||
// Index this action's vertex lists by handle once, and skip handles already handled in
|
||||
// this action's selection, so a selection that repeats a handle does not re-scan the
|
||||
// vertex list and re-clone its index vector per occurrence (CWE-400).
|
||||
let vtx_by_handle: HashMap<Handle, &Vec<i32>> = vertices.iter().rev().map(|(vh, i)| (*vh, i)).collect();
|
||||
let mut handled: HashSet<Handle> = HashSet::new();
|
||||
for h in &a.selection {
|
||||
if !handled.insert(*h) {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = param_of.get(h) {
|
||||
// A parameter in the selection is carried along: its points in the frame. Which
|
||||
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
|
||||
@@ -489,7 +497,7 @@ fn apply(
|
||||
continue;
|
||||
}
|
||||
let Some(c) = copy_of(h) else { continue };
|
||||
let idx = vertices.iter().find(|(vh, _)| vh == h).map(|(_, i)| i.clone());
|
||||
let idx = vtx_by_handle.get(h).map(|i| (*i).clone());
|
||||
moves.entry(c).or_default().push((idx, v));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1408,9 +1408,12 @@ impl EntityKind {
|
||||
EntityKind::LwPolyline(p) => {
|
||||
let mut g: Vec<Vec2> = Vec::new();
|
||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
||||
// Compute the segments once, not once per vertex: rebuilding the whole segment
|
||||
// vector inside the loop is quadratic in the vertex count (CWE-407).
|
||||
let segs = pl.segments();
|
||||
for (i, v) in p.vertices.iter().enumerate() {
|
||||
g.push(v.p);
|
||||
if let Some(s) = pl.segments().get(i) {
|
||||
if let Some(s) = segs.get(i) {
|
||||
g.push(s.mid());
|
||||
}
|
||||
}
|
||||
@@ -1468,3 +1471,21 @@ fn transform_text(t: &mut Text, m: &Mat3, s: f64, rot: f64, mirror: bool) {
|
||||
// MIRRTEXT = 0 (the default): mirrored text keeps reading left to right.
|
||||
t.rotation = cadcraft_geom::norm_angle(t.rotation + if mirror { 0.0 } else { rot });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grip_tests {
|
||||
use super::*;
|
||||
use cadcraft_geom::PolyVertex;
|
||||
|
||||
#[test]
|
||||
fn polyline_grips_are_vertices_and_midpoints() {
|
||||
// Grips are each vertex plus the midpoint of each segment; the segment vector is computed
|
||||
// once rather than rebuilt per vertex (CWE-407, row 35).
|
||||
let verts: Vec<PolyVertex> = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)].iter().map(|&(x, y)| PolyVertex::new(Vec2::new(x, y))).collect();
|
||||
let e = EntityKind::LwPolyline(LwPolyline { vertices: verts, closed: false, const_width: 0.0, elevation: 0.0, plinegen: false });
|
||||
let g = e.grips();
|
||||
// 3 vertices + 2 segment midpoints.
|
||||
assert_eq!(g.len(), 5, "{g:?}");
|
||||
assert!(g.contains(&Vec2::new(0.0, 0.0)) && g.contains(&Vec2::new(5.0, 0.0)));
|
||||
}
|
||||
}
|
||||
@@ -165,9 +165,21 @@ fn bounds_in<'a>(d: &'a Drawing, e: &Entity, depth: usize, blocks: &mut HashMap<
|
||||
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
|
||||
&& depth < MAX_BLOCK_DEPTH
|
||||
{
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
b = b.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
// Reuse the shared block-bounds cache (as the Insert branch does) so a dimension
|
||||
// block with branching or self-referencing children costs one pass per definition
|
||||
// per depth, not one per reference (which grows exponentially) (CWE-400).
|
||||
let inner = match blocks.get(&(blk.name.as_str(), depth)) {
|
||||
Some(cached) => *cached,
|
||||
None => {
|
||||
let mut inner = Bounds2::EMPTY;
|
||||
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||
}
|
||||
blocks.insert((blk.name.as_str(), depth), inner);
|
||||
inner
|
||||
}
|
||||
};
|
||||
b = b.union(&inner);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -752,6 +752,8 @@ pub struct PageSetup {
|
||||
pub landscape: bool,
|
||||
/// "layout" | "extents" | "display" | "window" | "limits".
|
||||
pub plot_area: String,
|
||||
/// Plot window corners `[x1, y1, x2, y2]` (DXF plot layout groups 48, 49, 140, 141); used when `plot_area` is "window".
|
||||
pub plot_window: [f64; 4],
|
||||
pub scale_to_fit: bool,
|
||||
/// Paper units per drawing unit.
|
||||
pub scale: f64,
|
||||
@@ -779,6 +781,7 @@ impl Default for PageSetup {
|
||||
margins_mm: [6.35, 6.35, 6.35, 6.35],
|
||||
landscape: true,
|
||||
plot_area: "layout".into(),
|
||||
plot_window: [0.0; 4],
|
||||
scale_to_fit: false,
|
||||
scale: 1.0,
|
||||
plot_style_table: String::new(),
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -494,7 +513,24 @@ mod native {
|
||||
}
|
||||
}
|
||||
|
||||
/// The current text style (`$TEXTSTYLE`) by its name, the same way as [`current_dimstyle`]:
|
||||
/// the handle read with the DXF needn't be the style's in the document, in which case the DWG
|
||||
/// writer fell back to Standard and AutoCAD opened the drawing with Standard current (#1650).
|
||||
fn current_text_style(doc: &mut acadrust::CadDocument) {
|
||||
let name = doc.header.current_text_style_name.clone();
|
||||
if let Some(h) = doc.text_styles.iter().find(|ts| ts.name.eq_ignore_ascii_case(&name)).map(|ts| ts.handle) {
|
||||
doc.header.current_text_style_handle = h;
|
||||
}
|
||||
}
|
||||
|
||||
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())?
|
||||
@@ -512,6 +548,7 @@ mod native {
|
||||
dimension_flags(&mut doc, true);
|
||||
drop_unwritable(&mut doc);
|
||||
current_dimstyle(&mut doc);
|
||||
current_text_style(&mut doc);
|
||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&doc)))
|
||||
.map_err(|_| "the DWG writer failed on this drawing".to_string())?
|
||||
.map_err(|e| format!("DWG write: {e}"))
|
||||
@@ -585,12 +622,37 @@ mod tests {
|
||||
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
|
||||
}
|
||||
|
||||
/// A DXF with ROMANS as `$TEXTSTYLE` gives a DWG whose current text style is ROMANS, not
|
||||
/// Standard: the DXF names the style, and the handle read with it was Standard's (#1650).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn current_text_style_reaches_the_dwg() {
|
||||
let header = " 0\nSECTION\n 2\nHEADER\n 9\n$ACADVER\n 1\nAC1032\n 9\n$TEXTSTYLE\n 7\nROMANS\n 0\nENDSEC\n";
|
||||
let style = " 0\nSTYLE\n 5\n1F0\n100\nAcDbSymbolTableRecord\n100\nAcDbTextStyleTableRecord\n 2\nROMANS\n 70\n0\n";
|
||||
let style = format!("{style} 40\n0\n 41\n1\n 50\n0\n 71\n0\n 42\n2.5\n 3\nromans.shx\n 4\n\n");
|
||||
let dxf = format!("{header} 0\nSECTION\n 2\nTABLES\n 0\nTABLE\n 2\nSTYLE\n 70\n1\n{style} 0\nENDTAB\n 0\nENDSEC\n 0\nEOF\n");
|
||||
let dwg = dxf_to_dwg(dxf.as_bytes()).unwrap();
|
||||
let doc = acadrust::DwgReader::from_stream(std::io::Cursor::new(&dwg)).read().unwrap();
|
||||
let romans = doc.text_styles.iter().find(|ts| ts.name == "ROMANS").map(|ts| ts.handle);
|
||||
assert!(romans.is_some(), "ROMANS is missing from the DWG");
|
||||
assert_eq!(Some(doc.header.current_text_style_handle), romans);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_is_an_error_not_a_crash() {
|
||||
assert!(dwg_to_dxf(b"AC1032 this is not a dwg file at all").is_err());
|
||||
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 {
|
||||
|
||||
@@ -24,6 +24,9 @@ log = { workspace = true }
|
||||
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
|
||||
rstar = { version = "0.12", default-features = false }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# Real DXF files for the external reference tests (io is a lower layer).
|
||||
cadcraft-io = { workspace = true }
|
||||
@@ -0,0 +1,163 @@
|
||||
//! Safe, atomic file replacement.
|
||||
//!
|
||||
//! Save, export, plot, autosave and the audit log all write a file by first putting the bytes in
|
||||
//! a sibling temporary file and then renaming it over the destination, so a failed or partial
|
||||
//! write never truncates the real file. The temporary file must be created *exclusively*: opening
|
||||
//! a predictable, pre-existing temporary path for a truncating write lets another local user plant
|
||||
//! a symlink there and redirect the write onto a file the victim can write but the attacker chose
|
||||
//! (CWE-59). [`write_atomic`] creates a fresh temporary file with `create_new` (`O_CREAT|O_EXCL`),
|
||||
//! so an existing entry at that path — symlink or not — makes the create fail rather than follow
|
||||
//! it. The handle is retained for the whole write, and only its own fresh file is renamed into
|
||||
//! place.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::fs::{File, OpenOptions, Permissions};
|
||||
use std::io::{self, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
/// Monotonic counter making temporary names unique within this process without a RNG dependency.
|
||||
static SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// How many fresh temporary names to try before giving up (each collision is another local race).
|
||||
const MAX_TRIES: u32 = 32;
|
||||
|
||||
/// Write `bytes` to `path`, replacing it atomically and without ever following a pre-existing
|
||||
/// temporary symlink.
|
||||
///
|
||||
/// A fresh temporary file is created with exclusive semantics in `path`'s own directory, the bytes
|
||||
/// are written through the retained handle and flushed, and the temporary is then renamed over
|
||||
/// `path`. On any failure the temporary is removed and the original `path` is left untouched.
|
||||
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
|
||||
let permissions = match std::fs::symlink_metadata(path) {
|
||||
Ok(meta) if meta.is_file() => Some(meta.permissions()),
|
||||
Ok(_) => None,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let dir = path.parent().filter(|p| !p.as_os_str().is_empty()).map_or_else(|| PathBuf::from("."), Path::to_path_buf);
|
||||
let (mut file, tmp) = create_temp(&dir, permissions.as_ref())?;
|
||||
let write = (|| {
|
||||
if let Some(permissions) = permissions {
|
||||
file.set_permissions(permissions)?;
|
||||
}
|
||||
file.write_all(bytes)?;
|
||||
file.flush()?;
|
||||
file.sync_all()
|
||||
})();
|
||||
if let Err(e) = write {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
drop(file);
|
||||
if let Err(e) = std::fs::rename(&tmp, path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a fresh, exclusively-owned temporary file inside `dir`, returning its handle and path.
|
||||
///
|
||||
/// `create_new(true)` opens with `O_CREAT | O_EXCL`, which refuses to follow or reuse an existing
|
||||
/// entry (including a symlink), closing the plant-a-symlink race. Names are unpredictable enough to
|
||||
/// avoid collisions in practice, and genuine collisions are retried with a new name.
|
||||
fn create_temp(dir: &Path, permissions: Option<&Permissions>) -> io::Result<(File, PathBuf)> {
|
||||
let pid = std::process::id();
|
||||
let mut last_err = None;
|
||||
for _ in 0..MAX_TRIES {
|
||||
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let nanos = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.subsec_nanos()).unwrap_or(0);
|
||||
let tmp = dir.join(format!(".cadcraft-tmp-{pid}-{seq}-{nanos}"));
|
||||
match create_exclusive(&tmp, permissions) {
|
||||
Ok(file) => return Ok((file, tmp)),
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
|
||||
last_err = Some(e);
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
Err(last_err.unwrap_or_else(|| io::Error::new(io::ErrorKind::AlreadyExists, "could not create a unique temporary file")))
|
||||
}
|
||||
|
||||
fn create_exclusive(path: &Path, permissions: Option<&Permissions>) -> io::Result<File> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
if let Some(permissions) = permissions {
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
// Restrict access in the create syscall: chmod afterwards leaves a window in which a
|
||||
// different user can open the inode and retain read access to bytes written later.
|
||||
options.mode(permissions.mode() & 0o777);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = permissions;
|
||||
options.open(path)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replaces_contents_atomically() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("out.bin");
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
write_atomic(&dest, b"new bytes").unwrap();
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"new bytes");
|
||||
// No temporary files left behind.
|
||||
let leftovers: Vec<_> =
|
||||
std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name()).filter(|n| n.to_string_lossy().starts_with(".cadcraft-tmp")).collect();
|
||||
assert!(leftovers.is_empty(), "left temp files: {leftovers:?}");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn does_not_follow_a_planted_temp_symlink() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-sym-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let victim = dir.join("victim");
|
||||
std::fs::write(&victim, b"precious").unwrap();
|
||||
let planted = dir.join(".cadcraft-tmp-plant");
|
||||
symlink(&victim, &planted).unwrap();
|
||||
assert_eq!(create_exclusive(&planted, None).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
let dest = dir.join("drawing.dxf");
|
||||
write_atomic(&dest, b"drawing bytes").unwrap();
|
||||
assert_eq!(std::fs::read(&victim).unwrap(), b"precious", "victim file was overwritten");
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"drawing bytes");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn replacement_preserves_private_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let dest = dir.join("private.adt");
|
||||
std::fs::write(&dest, b"old").unwrap();
|
||||
std::fs::set_permissions(&dest, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
write_atomic(&dest, b"private report").unwrap();
|
||||
assert_eq!(std::fs::metadata(&dest).unwrap().permissions().mode() & 0o777, 0o600);
|
||||
assert_eq!(std::fs::read(&dest).unwrap(), b"private report");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn private_temp_is_restricted_from_creation() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-aw-create-mode-{}-{}", std::process::id(), SEQ.fetch_add(1, Ordering::Relaxed)));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("private-temp");
|
||||
let permissions = Permissions::from_mode(0o600);
|
||||
let file = create_exclusive(&path, Some(&permissions)).unwrap();
|
||||
assert_eq!(file.metadata().unwrap().permissions().mode() & 0o077, 0);
|
||||
drop(file);
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -339,9 +339,9 @@ mod native {
|
||||
if let Some(dir) = Path::new(path).parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let tmp = format!("{path}.tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
|
||||
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
impl Autosaver {
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Bounded reads of local files.
|
||||
//!
|
||||
//! Reading a path with [`std::fs::read`] buffers until EOF, so a path naming an endless readable
|
||||
//! device (for example `/dev/urandom` or a FIFO) exhausts process memory before any size check
|
||||
//! runs (CWE-400). [`read_capped`] opens the path once, refuses anything that is not a regular
|
||||
//! file, and reads through a reader limited to `max + 1` bytes so oversized input is rejected
|
||||
//! before it is fully buffered.
|
||||
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
|
||||
/// Read at most `max` bytes from the regular file at `path`.
|
||||
///
|
||||
/// Returns an error if the path is not a regular file, cannot be opened, or holds more than `max`
|
||||
/// bytes. At most `max + 1` bytes are ever buffered, so an endless device cannot exhaust memory.
|
||||
pub fn read_capped(path: &Path, max: usize) -> io::Result<Vec<u8>> {
|
||||
let mut options = OpenOptions::new();
|
||||
options.read(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NONBLOCK);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
let meta = file.metadata()?;
|
||||
if !meta.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "not a regular file"));
|
||||
}
|
||||
// Read one byte past the limit so we can tell "exactly max" from "too big" without trusting
|
||||
// the metadata length (which a device may misreport).
|
||||
let mut buf = Vec::new();
|
||||
let cap = (max as u64).saturating_add(1);
|
||||
let read = file.take(cap).read_to_end(&mut buf)?;
|
||||
if read as u64 > max as u64 {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, format!("file is larger than the {max}-byte limit")));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_within_limit_and_rejects_oversize() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cr-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let p = dir.join("f.bin");
|
||||
std::fs::write(&p, vec![7u8; 100]).unwrap();
|
||||
assert_eq!(read_capped(&p, 100).unwrap().len(), 100);
|
||||
assert_eq!(read_capped(&p, 200).unwrap().len(), 100);
|
||||
assert!(read_capped(&p, 99).is_err());
|
||||
assert_eq!(read_capped(&p, usize::MAX).unwrap().len(), 100);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_non_regular_file() {
|
||||
// /dev/zero is an endless readable device; it must be rejected, not buffered.
|
||||
let p = Path::new("/dev/zero");
|
||||
if p.exists() {
|
||||
assert!(read_capped(p, 8 << 20).is_err());
|
||||
}
|
||||
}
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rejects_fifo_without_a_writer() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-cr-fifo-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let fifo = dir.join("pipe");
|
||||
assert!(std::process::Command::new("mkfifo").arg(&fifo).status().unwrap().success());
|
||||
let started = std::time::Instant::now();
|
||||
assert_eq!(read_capped(&fifo, 1024).unwrap_err().kind(), io::ErrorKind::InvalidInput);
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -427,13 +427,13 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
let d = s.doc().ok()?;
|
||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
||||
let st = d.mleader_styles.iter().find(|m| m.name.eq_ignore_ascii_case(&cur)).or(d.mleader_styles.first()).cloned().unwrap_or_default();
|
||||
// An annotative style's sizes are paper sizes: the annotation scale applies when drawn.
|
||||
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
|
||||
let th = st.text_height * k;
|
||||
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
|
||||
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
|
||||
let th = st.text_height;
|
||||
let dir = if arrow_pts.first().is_some_and(|a| a.x > landing_pt.x) { -1.0 } else { 1.0 };
|
||||
let attach = if dir > 0.0 { 4 } else { 6 };
|
||||
let dogleg = if landing { st.dogleg * k } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
|
||||
let dogleg = if landing { st.dogleg } else { 0.0 };
|
||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
|
||||
Some(EntityKind::MLeader(MLeader {
|
||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||
landing: v3(*landing_pt),
|
||||
@@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
||||
columns: None,
|
||||
}),
|
||||
style: st.name.clone(),
|
||||
arrow_size: st.arrow_size * k,
|
||||
arrow_size: st.arrow_size,
|
||||
spline: false,
|
||||
block: None,
|
||||
}))
|
||||
|
||||
@@ -142,7 +142,10 @@ fn write_audit_log(s: &Session, report: &crate::audit::Report) {
|
||||
let Some(path) = &path_str else { return };
|
||||
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
|
||||
let content = report.format_adt(&title, path_str.as_deref());
|
||||
if let Err(e) = std::fs::write(&adt_path, content) {
|
||||
// Write through an exclusively-created temporary and rename over the companion path, so a
|
||||
// planted `.adt` symlink beside the drawing cannot redirect the report onto another file
|
||||
// writable by the victim (CWE-59).
|
||||
if let Err(e) = crate::atomic_write::write_atomic(&adt_path, content.as_bytes()) {
|
||||
log::warn!("AUDITCTL: could not write audit log {}: {e}", adt_path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,10 +79,9 @@ fn extension(path: &str) -> String {
|
||||
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -19,6 +19,15 @@ pub use pgp::{MAX_ALIASES, alias_name, command_id};
|
||||
pub(crate) use pgp::{specs as alias_specs, user_alias};
|
||||
pub use template::{qnew_template, set_qnew_template};
|
||||
|
||||
/// Largest drawing accepted from a local path, matching the per-file xref limit. A path naming an
|
||||
/// endless device must be rejected before it exhausts memory (CWE-400), so native opens read
|
||||
/// through [`crate::capped_read`] rather than buffering to EOF.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_DRAWING_BYTES: usize = 512 << 20;
|
||||
/// Largest `.scr` script accepted from a local path.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
const MAX_SCRIPT_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Plot a space to PDF bytes with JSON options (`{paper?, landscape?, fit?, lineweights?, …}`).
|
||||
pub type PlotHook = fn(&Drawing, &cadcraft_doc::Space, &Value) -> std::result::Result<Vec<u8>, String>;
|
||||
|
||||
@@ -192,7 +201,8 @@ fn run_script(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_SCRIPT_BYTES).map_err(|e| bad("script", format!("{path}: {e}")))?;
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -265,7 +275,8 @@ pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let r = std::panic::catch_unwind(|| {
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
read_bytes(&bytes, path, Some(path.to_string()))
|
||||
});
|
||||
match r {
|
||||
@@ -332,7 +343,8 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let path = _path;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let bytes = std::fs::read(path).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let bytes =
|
||||
crate::capped_read::read_capped(std::path::Path::new(path), MAX_DRAWING_BYTES).map_err(|e| bad("open", format!("{path}: {e}")))?;
|
||||
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||
super::blocks::xref::after_open(s, i);
|
||||
return Ok(opened(s, i, true));
|
||||
@@ -403,10 +415,9 @@ fn save_as(s: &mut Session, cmd: &str, path: &str, opts: &SaveAs) -> Result<usiz
|
||||
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
||||
Ok(())
|
||||
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
|
||||
@@ -826,9 +826,9 @@ fn run_plot(s: &mut Session, p: &Value, cmd: &str) -> Result<Value> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let path = _path;
|
||||
let tmp = format!("{path}.cadcraft-tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||
crate::atomic_write::write_atomic(std::path::Path::new(path), &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
||||
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
|
||||
@@ -9,9 +9,13 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod assoc;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod atomic_write;
|
||||
pub mod audit;
|
||||
pub mod autosave;
|
||||
pub mod blockedit;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod capped_read;
|
||||
pub mod cmd;
|
||||
pub mod doc_path;
|
||||
mod finite;
|
||||
|
||||
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
/// `=` and `/=`: numbers and strings compare as [`compare`] does, and any other atom compares by
|
||||
/// what it is, as `eq` does. AutoLISP answers `(= (type x) 'LIST)` -- the idiom for a type test,
|
||||
/// since `type` returns a symbol -- where coercing that symbol to a number failed the whole
|
||||
/// routine with "bad argument type" instead (#1597). A mismatch is `nil`, not an error, so
|
||||
/// `(= 1 "1")` and `(= '(1) '(1))` answer too. The ordering comparisons stay numbers and strings
|
||||
/// only, as AutoLISP has them.
|
||||
fn eq_chain(a: &[Value], want_equal: bool) -> R {
|
||||
arity(a, 1, usize::MAX)?;
|
||||
for w in a.windows(2) {
|
||||
let [x, y] = w else { continue };
|
||||
let same = match (x, y) {
|
||||
(Value::Str(_), Value::Str(_)) => compare(x, y)?.is_eq(),
|
||||
_ if x.as_number().is_some() && y.as_number().is_some() => compare(x, y)?.is_eq(),
|
||||
_ => eq(x, y),
|
||||
};
|
||||
if same != want_equal {
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
}
|
||||
Ok(Value::t())
|
||||
}
|
||||
fn num_eq(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_eq())
|
||||
eq_chain(a, true)
|
||||
}
|
||||
fn num_ne(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_ne())
|
||||
eq_chain(a, false)
|
||||
}
|
||||
fn lt(a: &[Value]) -> R {
|
||||
chain(a, |o| o.is_lt())
|
||||
@@ -658,19 +679,44 @@ fn wcmatch(a: &[Value]) -> R {
|
||||
arity(a, 2, 2)?;
|
||||
let (s, pat) = (string(&a[0])?, string(&a[1])?);
|
||||
let text: Vec<char> = s.chars().collect();
|
||||
Ok(Value::bool(split_patterns(&pat).iter().any(|p| match_pattern(&text, p))))
|
||||
// Native wildcard matching runs as a single interpreter step, so its internal `*` backtracking
|
||||
// must not explore exponentially many placements unchecked (CWE-400). Charge every glob step
|
||||
// against a shared budget and fail the call if it is exhausted.
|
||||
let mut budget: u64 = WCMATCH_BUDGET;
|
||||
for p in split_patterns(&pat, &mut budget).map_err(|()| LispError::new("wcmatch: pattern too complex"))? {
|
||||
match match_pattern(&text, &p, &mut budget) {
|
||||
Ok(true) => return Ok(Value::bool(true)),
|
||||
Ok(false) => {}
|
||||
Err(()) => return Err(LispError::new("wcmatch: pattern too complex")),
|
||||
}
|
||||
}
|
||||
Ok(Value::bool(false))
|
||||
}
|
||||
|
||||
fn split_patterns(pat: &str) -> Vec<Vec<char>> {
|
||||
/// Shared step budget for one `wcmatch` call's wildcard matching. Large enough for the patterns
|
||||
/// AutoLISP routines legitimately use (including the pathological-but-bounded cases the tests
|
||||
/// cover), but finite so truly exponential `*`-heavy patterns are rejected rather than hanging.
|
||||
const WCMATCH_BUDGET: u64 = 200_000_000;
|
||||
|
||||
fn charge_match(budget: &mut u64) -> Result<(), ()> {
|
||||
*budget = budget.checked_sub(1).ok_or(())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn split_patterns(pat: &str, budget: &mut u64) -> Result<Vec<Vec<char>>, ()> {
|
||||
let mut out = vec![Vec::new()];
|
||||
let mut chars = pat.chars().peekable();
|
||||
let mut in_set = false;
|
||||
while let Some(c) = chars.next() {
|
||||
charge_match(budget)?;
|
||||
match c {
|
||||
'`' => {
|
||||
if let Some(cur) = out.last_mut() {
|
||||
cur.push('`');
|
||||
cur.extend(chars.next());
|
||||
if let Some(escaped) = chars.next() {
|
||||
charge_match(budget)?;
|
||||
cur.push(escaped);
|
||||
}
|
||||
}
|
||||
}
|
||||
'[' => {
|
||||
@@ -693,41 +739,93 @@ fn split_patterns(pat: &str) -> Vec<Vec<char>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn match_pattern(text: &[char], pat: &[char]) -> bool {
|
||||
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()
|
||||
{
|
||||
return !glob(text, rest, 0);
|
||||
return glob(text, rest, 0, budget).map(|m| !m);
|
||||
}
|
||||
glob(text, pat, 0)
|
||||
glob(text, pat, 0, budget)
|
||||
}
|
||||
|
||||
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`.
|
||||
fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
||||
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is
|
||||
/// charged against `budget`; `Err(())` means the shared budget ran out (treated as too complex).
|
||||
fn glob(text: &[char], pat: &[char], depth: usize, budget: &mut u64) -> Result<bool, ()> {
|
||||
glob_cached(text, pat, depth, budget, &mut std::collections::HashSet::new())
|
||||
}
|
||||
|
||||
fn glob_cached(
|
||||
text: &[char],
|
||||
pat: &[char],
|
||||
depth: usize,
|
||||
budget: &mut u64,
|
||||
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
|
||||
) -> Result<bool, ()> {
|
||||
charge_match(budget)?;
|
||||
let key = (text.len(), pat.len(), depth);
|
||||
if failed.contains(&key) {
|
||||
return Ok(false);
|
||||
}
|
||||
let matched = glob_inner(text, pat, depth, budget, failed)?;
|
||||
// Avoid revisiting the same failed suffix exponentially. Bound the memo table independently
|
||||
// of the step limit so a very large pattern cannot turn the optimization into an allocation bomb.
|
||||
if !matched && failed.len() < 100_000 {
|
||||
failed.insert(key);
|
||||
}
|
||||
Ok(matched)
|
||||
}
|
||||
|
||||
fn glob_inner(
|
||||
text: &[char],
|
||||
pat: &[char],
|
||||
depth: usize,
|
||||
budget: &mut u64,
|
||||
failed: &mut std::collections::HashSet<(usize, usize, usize)>,
|
||||
) -> Result<bool, ()> {
|
||||
if *budget == 0 {
|
||||
return Err(());
|
||||
}
|
||||
*budget -= 1;
|
||||
if depth > 64 {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
let (mut t, mut p) = (0usize, 0usize);
|
||||
while p < pat.len() {
|
||||
charge_match(budget)?;
|
||||
match pat[p] {
|
||||
'*' => {
|
||||
let rest = &pat[p + 1..];
|
||||
return (t..=text.len()).any(|i| glob(&text[i..], rest, depth + 1));
|
||||
for i in t..=text.len() {
|
||||
if glob_cached(&text[i..], rest, depth + 1, budget, failed)? {
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
'`' => {
|
||||
let Some(&lit) = pat.get(p + 1) else { return false };
|
||||
let Some(&lit) = pat.get(p + 1) else { return Ok(false) };
|
||||
if text.get(t) != Some(&lit) {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
t += 1;
|
||||
p += 2;
|
||||
}
|
||||
'[' => {
|
||||
let Some(close) = pat[p..].iter().position(|c| *c == ']').map(|i| p + i) else { return false };
|
||||
let Some(&c) = text.get(t) else { return false };
|
||||
let mut close = p;
|
||||
while let Some(c) = pat.get(close) {
|
||||
charge_match(budget)?;
|
||||
if *c == ']' {
|
||||
break;
|
||||
}
|
||||
close += 1;
|
||||
}
|
||||
if close == pat.len() {
|
||||
return Ok(false);
|
||||
}
|
||||
let Some(&c) = text.get(t) else { return Ok(false) };
|
||||
let set = &pat[p + 1..close];
|
||||
let (neg, set) = match set.split_first() {
|
||||
Some(('~', r)) if !r.is_empty() => (true, r),
|
||||
@@ -736,6 +834,7 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
||||
let mut hit = false;
|
||||
let mut i = 0;
|
||||
while i < set.len() {
|
||||
charge_match(budget)?;
|
||||
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
||||
if set[i] <= c && c <= set[i + 2] {
|
||||
hit = true;
|
||||
@@ -749,13 +848,13 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
||||
}
|
||||
}
|
||||
if hit == neg {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
t += 1;
|
||||
p = close + 1;
|
||||
}
|
||||
pc => {
|
||||
let Some(&c) = text.get(t) else { return false };
|
||||
let Some(&c) = text.get(t) else { return Ok(false) };
|
||||
let ok = match pc {
|
||||
'#' => c.is_ascii_digit(),
|
||||
'@' => c.is_alphabetic(),
|
||||
@@ -764,14 +863,14 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
||||
other => other == c,
|
||||
};
|
||||
if !ok {
|
||||
return false;
|
||||
return Ok(false);
|
||||
}
|
||||
t += 1;
|
||||
p += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
t == text.len()
|
||||
Ok(t == text.len())
|
||||
}
|
||||
|
||||
// ---------------- geometry ----------------
|
||||
@@ -963,11 +1062,10 @@ fn findfile(a: &[Value]) -> R {
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn read_text_file(path: &str) -> Option<String> {
|
||||
let bytes = std::fs::read(path).ok()?;
|
||||
// Cap what a routine can pull into memory.
|
||||
if bytes.len() > 64 * 1024 * 1024 {
|
||||
return None;
|
||||
}
|
||||
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
|
||||
// cannot exhaust memory before the 64 MiB cap is enforced (CWE-400).
|
||||
const MAX: usize = 64 * 1024 * 1024;
|
||||
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
|
||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
@@ -1482,5 +1580,24 @@ pub fn real(r: f64) -> String {
|
||||
/// `wcmatch` for other callers (`ssget` filters).
|
||||
pub fn wild_match(text: &str, pattern: &str) -> bool {
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
split_patterns(pattern).iter().any(|p| match_pattern(&t, p))
|
||||
let mut budget: u64 = WCMATCH_BUDGET;
|
||||
split_patterns(pattern, &mut budget).is_ok_and(|patterns| patterns.iter().any(|p| match_pattern(&t, p, &mut budget) == Ok(true)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wildcard_budget_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn all_native_scans_consume_fuel() {
|
||||
assert!(split_patterns("abcdefgh", &mut 4).is_err());
|
||||
assert!(split_patterns("`a`b`c", &mut 4).is_err());
|
||||
let text: Vec<_> = "abcdefgh".chars().collect();
|
||||
assert!(glob(&text, &text, 0, &mut 4).is_err());
|
||||
let pattern: Vec<_> = "[abcdefgh]".chars().collect();
|
||||
assert!(glob(&['!'], &pattern, 0, &mut 6).is_err());
|
||||
// Enough for finding the closing bracket, but not scanning the entire set again.
|
||||
assert!(glob(&['!'], &pattern, 0, &mut 14).is_err());
|
||||
assert_eq!(glob(&['a'], &pattern, 0, &mut 100), Ok(true));
|
||||
}
|
||||
}
|
||||
@@ -151,16 +151,32 @@ pub fn from_json(v: &serde_json::Value) -> Value {
|
||||
}
|
||||
}
|
||||
|
||||
/// A LISP value as JSON (for `setvar`).
|
||||
pub fn to_json(v: &Value) -> serde_json::Value {
|
||||
match v {
|
||||
/// A LISP value as JSON (for `setvar`), bounded so a shared cons structure cannot expand into
|
||||
/// exponentially many owned array elements before the setter can reject it (CWE-400).
|
||||
///
|
||||
/// Largest number of elements it may expand, and the deepest nesting it follows.
|
||||
const TO_JSON_BUDGET: u64 = 2_000_000;
|
||||
const TO_JSON_MAX_DEPTH: usize = 256;
|
||||
|
||||
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;
|
||||
}
|
||||
*budget -= 1;
|
||||
Some(match v {
|
||||
Value::Nil => serde_json::Value::Null,
|
||||
Value::Int(i) => serde_json::json!(i),
|
||||
Value::Real(r) => serde_json::json!(r),
|
||||
Value::Str(s) => serde_json::json!(&**s),
|
||||
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
|
||||
Value::Cons(_) => {
|
||||
let mut arr = Vec::new();
|
||||
for x in v.iter() {
|
||||
arr.push(to_json_bounded(&x, depth + 1, budget)?);
|
||||
}
|
||||
serde_json::Value::Array(arr)
|
||||
}
|
||||
other => serde_json::json!(to_text(other, false)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
@@ -172,7 +188,11 @@ pub fn getvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||
arity(&a, 2, 2)?;
|
||||
let name = string(&a[0])?;
|
||||
match crate::sysvars::set(ctx.s, &name, &to_json(&a[1])) {
|
||||
let mut budget: u64 = TO_JSON_BUDGET;
|
||||
let Some(json) = to_json_bounded(&a[1], 0, &mut budget) else {
|
||||
return Err(LispError::new(format!("AutoCAD variable setting rejected: {} value too large", to_text(&a[0], true))));
|
||||
};
|
||||
match crate::sysvars::set(ctx.s, &name, &json) {
|
||||
Ok(()) => Ok(Flow::Value(a[1].clone())),
|
||||
Err(_) => Err(LispError::new(format!("AutoCAD variable setting rejected: {} {}", to_text(&a[0], true), to_text(&a[1], true)))),
|
||||
}
|
||||
|
||||
@@ -163,7 +163,8 @@ pub struct Ctx<'a> {
|
||||
pub const MAX_STACK: usize = 100_000;
|
||||
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
|
||||
const MAX_SYNC_DEPTH: usize = 32;
|
||||
|
||||
/// Steps per slice while driving a synchronous callback ([`Ctx::call_sync`]).
|
||||
const SYNC_SLICE: u64 = 400_000;
|
||||
enum State {
|
||||
Eval(Value),
|
||||
Ret(Value),
|
||||
@@ -645,12 +646,31 @@ impl Ctx<'_> {
|
||||
super::value::charge_result(&v)?;
|
||||
return Ok(v);
|
||||
}
|
||||
let cap = self.s.lisp.limits.callback_steps;
|
||||
let mut m = Machine::calling(f.clone(), args);
|
||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||
match m.run(&mut sub, u64::MAX) {
|
||||
Outcome::Done(v) => Ok(v),
|
||||
Outcome::Error(e) => Err(e),
|
||||
Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")),
|
||||
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||
// thread and bypassing the blocking execution cap (CWE-400).
|
||||
let mut spent: u64 = 0;
|
||||
loop {
|
||||
let remaining = cap.saturating_sub(spent);
|
||||
if remaining == 0 {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
let slice = remaining.min(SYNC_SLICE);
|
||||
match m.run(&mut sub, slice) {
|
||||
Outcome::Done(v) => return Ok(v),
|
||||
Outcome::Error(e) => return Err(e),
|
||||
Outcome::Wait(Need::Yield) => {
|
||||
spent = spent.saturating_add(slice);
|
||||
if spent >= cap {
|
||||
return Err(LispError::new(format!("callback stopped after {cap} steps")));
|
||||
}
|
||||
}
|
||||
Outcome::Wait(_) => return Err(LispError::new("this function can't ask for input here")),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,12 @@ impl GetRequest {
|
||||
let mut p = Prompt::new(message, accept).base_opt(base);
|
||||
p.keywords = self.initget.keywords.clone();
|
||||
p.raw = true;
|
||||
// `entsel`/`nentsel` pick one object. The routine writes the message, so the prompt says
|
||||
// so itself rather than being read for the word "Select" -- with a message of its own
|
||||
// ("\nSelect the second line: ", or no mention of selecting at all) the pick box went
|
||||
// missing, while the same call without one got it from the default "Select object: "
|
||||
// (#1598).
|
||||
p.picks_one = matches!(self.kind, GetKind::EntSel);
|
||||
p
|
||||
}
|
||||
|
||||
|
||||
@@ -126,11 +126,13 @@ pub(crate) struct Limits {
|
||||
pub cells: usize,
|
||||
/// Bytes of strings, printed text and files read per run.
|
||||
pub string_bytes: u64,
|
||||
/// Maximum interpreter steps per synchronous callback.
|
||||
pub callback_steps: u64,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET }
|
||||
Limits { cells: value::MAX_CELLS, string_bytes: value::STRING_BUDGET, callback_steps: 500_000_000 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,39 @@ fn arithmetic_follows_autolisp() {
|
||||
assert_eq!(eval(&mut s, "(+ 1 nil)"), Err("bad argument type: numberp: nil".into()));
|
||||
}
|
||||
|
||||
/// `=` answers for the symbols `type` returns, which is how AutoLISP routines test a type:
|
||||
/// `(= (type x) 'INT)`. Coercing the symbol to a number failed the whole routine with "bad
|
||||
/// argument type" instead, so a routine written for AutoLISP stopped at its first type test
|
||||
/// (#1597). The ordering comparisons are still numbers and strings, as AutoLISP has them.
|
||||
#[test]
|
||||
fn equality_compares_symbols_as_autolisp_does() {
|
||||
let mut s = session();
|
||||
assert_eq!(ok(&mut s, "(= (type 1) 'INT)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (type 1.0) 'INT)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(= (type '(1 2)) 'LIST)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= (type \"a\") 'STR)"), "T");
|
||||
assert_eq!(ok(&mut s, "(/= (type 1) 'REAL)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 'a 'a 'a)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 'a 'b)"), "nil");
|
||||
|
||||
// The routine from the report runs.
|
||||
ok(&mut s, "(defun isint (a) (if (= (type a) 'INT) T nil))");
|
||||
assert_eq!(ok(&mut s, "(isint 3)"), "T");
|
||||
assert_eq!(ok(&mut s, "(isint 3.0)"), "nil");
|
||||
assert_eq!(ok(&mut s, "(isint \"3\")"), "nil");
|
||||
|
||||
// A mismatch of kinds is nil, not an error; numbers and strings are unchanged.
|
||||
assert_eq!(ok(&mut s, "(= 1 \"1\")"), "nil");
|
||||
assert_eq!(ok(&mut s, "(= '(1) '(1))"), "nil", "`=` is not `equal`");
|
||||
assert_eq!(ok(&mut s, "(= nil nil)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 1 1.0)"), "T");
|
||||
assert_eq!(ok(&mut s, "(= \"a\" \"a\" \"a\")"), "T");
|
||||
assert_eq!(ok(&mut s, "(= 1 1 2)"), "nil");
|
||||
|
||||
// Ordering still refuses what AutoLISP refuses.
|
||||
assert_eq!(eval(&mut s, "(< 'a 'b)"), Err("bad argument type: numberp: A".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_and_strings() {
|
||||
let mut s = session();
|
||||
@@ -188,6 +221,64 @@ fn runaway_routines_are_errors_not_crashes() {
|
||||
assert_eq!(r, Ok("3000000".into()));
|
||||
}
|
||||
|
||||
#[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
|
||||
// now terminates at the synchronous-callback step cap (CWE-400, finding row 2).
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
assert!(eval(&mut s, "(vl-sort '(1 2) (function (lambda (a b) (while t))))").is_err());
|
||||
// A well-behaved comparator still works afterwards.
|
||||
assert_eq!(ok(&mut s, "(vl-sort '(3 1 2) '<)"), "(1 2 3)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zero_callback_budget_does_not_run_the_callback() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 0;
|
||||
ok(&mut s, "(setq callback-ran nil)");
|
||||
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (setq callback-ran t)) '(1))");
|
||||
assert_eq!(result, Err("callback stopped after 0 steps".into()));
|
||||
assert_eq!(ok(&mut s, "callback-ran"), "nil");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_callback_must_fit_within_a_partial_slice() {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = 1_000;
|
||||
ok(&mut s, "(setq callback-count 0)");
|
||||
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (repeat 1000 (setq callback-count (1+ callback-count)))) '(1))");
|
||||
assert_eq!(result, Err("callback stopped after 1000 steps".into()));
|
||||
assert_eq!(ok(&mut s, "(and (> callback-count 0) (< callback-count 1000))"), "T");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reaching_a_callback_slice_boundary_does_not_grant_another_slice() {
|
||||
let counts: Vec<u64> = [399_999, 400_000]
|
||||
.into_iter()
|
||||
.map(|cap| {
|
||||
let mut s = session();
|
||||
s.lisp.limits.callback_steps = cap;
|
||||
ok(&mut s, "(setq callback-count 0)");
|
||||
let result = eval(&mut s, "(vl-remove-if '(lambda (x) (while t (setq callback-count (1+ callback-count)))) '(1))");
|
||||
assert_eq!(result, Err(format!("callback stopped after {cap} steps")));
|
||||
ok(&mut s, "callback-count").parse().unwrap()
|
||||
})
|
||||
.collect();
|
||||
// One additional VM step can perform at most one assignment, even at SYNC_SLICE's boundary.
|
||||
assert!(counts[0] > 0);
|
||||
assert!(counts[1] >= counts[0] && counts[1] - counts[0] <= 1, "{counts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setvar_rejects_an_oversized_shared_structure() {
|
||||
// A list repeatedly sharing its own contents expands exponentially; setvar's bounded JSON
|
||||
// conversion rejects it instead of allocating gigabytes (CWE-400, finding row 39).
|
||||
let mut s = session();
|
||||
let build = "(setq x '(1)) (repeat 40 (setq x (list x x))) (setvar \"GRIDUNIT\" x)";
|
||||
assert!(eval(&mut s, build).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn printing() {
|
||||
let mut s = session();
|
||||
@@ -452,6 +543,37 @@ fn hostile_lisp_never_panics() {
|
||||
assert!(!s.lisp_running());
|
||||
}
|
||||
|
||||
/// `entsel` and `nentsel` pick one object, so the cursor shows the pick box at their prompt. It
|
||||
/// was read off the message's first word, and a routine's own message begins with a newline -- so
|
||||
/// the same call showed the pick box without a message and lost it with one (#1598).
|
||||
#[test]
|
||||
fn an_entsel_prompt_picks_objects_whatever_its_message_says() {
|
||||
let mut s = session();
|
||||
// No message: the default "Select object: ".
|
||||
s.cmdline("(nentsel)").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// The reported case: the routine's own message, starting with a newline.
|
||||
s.cmdline("(nentsel \"\nSelect second line or polyline: \")").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// Wording that never says "select" picks an object just the same.
|
||||
s.cmdline("(entsel \"\nPick a line: \")").unwrap();
|
||||
assert!(s.current_prompt().unwrap().picks_objects(), "{}", s.prompt_text());
|
||||
s.cancel();
|
||||
|
||||
// A point prompt is not a pick, and keeps its point modifiers -- including one whose own
|
||||
// message happens to begin with the word the built-in commands are read by. `picks_one` says
|
||||
// which calls pick, so the wording is only ever read where the engine wrote it (#1635 review).
|
||||
for point in ["(getpoint \"\nSpecify a point: \")", "(getpoint \"\nSelect a point: \")", "(getpoint \"Select a point: \")"] {
|
||||
s.cmdline(point).unwrap();
|
||||
assert!(!s.current_prompt().unwrap().picks_objects(), "{point}: {}", s.prompt_text());
|
||||
s.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
|
||||
let mut s = session();
|
||||
|
||||
@@ -343,18 +343,29 @@ pub fn eq(a: &Value, b: &Value) -> bool {
|
||||
|
||||
/// `equal`: the same structure, numbers within `fuzz`.
|
||||
pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
||||
equal_depth(a, b, fuzz, 0)
|
||||
// A shared cons DAG can expand into exponentially many node visits when compared as a tree.
|
||||
// Bound the total comparisons so `equal` on adversarial shared structure cannot monopolise the
|
||||
// thread (CWE-407); exhausting the budget reports "not equal" rather than looping.
|
||||
let mut budget: u64 = EQUAL_BUDGET;
|
||||
equal_depth(a, b, fuzz, 0, &mut budget)
|
||||
}
|
||||
|
||||
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize) -> bool {
|
||||
if depth > MAX_DEPTH {
|
||||
/// Largest number of node comparisons one `equal` call may perform.
|
||||
const EQUAL_BUDGET: u64 = 50_000_000;
|
||||
|
||||
fn equal_depth(a: &Value, b: &Value, fuzz: f64, depth: usize, budget: &mut u64) -> bool {
|
||||
if depth > MAX_DEPTH || *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
*budget -= 1;
|
||||
let (mut a, mut b) = (a.clone(), b.clone());
|
||||
loop {
|
||||
if *budget == 0 {
|
||||
return false;
|
||||
}
|
||||
match (&a, &b) {
|
||||
(Value::Cons(x), Value::Cons(y)) => {
|
||||
if !equal_depth(&x.car, &y.car, fuzz, depth + 1) {
|
||||
if !equal_depth(&x.car, &y.car, fuzz, depth + 1, budget) {
|
||||
return false;
|
||||
}
|
||||
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
|
||||
|
||||
@@ -62,6 +62,10 @@ pub struct Prompt {
|
||||
/// pointer boxes.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub distance: bool,
|
||||
/// The prompt picks one object whatever it says: an AutoLISP `entsel`/`nentsel`, whose message
|
||||
/// the routine writes, so it cannot be read for the word "Select" (#1598).
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub picks_one: bool,
|
||||
}
|
||||
|
||||
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||
@@ -128,11 +132,14 @@ impl Prompt {
|
||||
self.default = Some(d.into());
|
||||
self
|
||||
}
|
||||
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
|
||||
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
|
||||
/// single object themselves). The UI shows the pick box there.
|
||||
/// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object
|
||||
/// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to
|
||||
/// offset", "Select first line"…, the commands that pick a single object themselves). The UI
|
||||
/// shows the pick box there. The wording is only read where the engine wrote it: a routine
|
||||
/// writes its own message (`raw`), which may open with "Select" and still ask for a point,
|
||||
/// and says through `picks_one` when it picks instead (#1635 review).
|
||||
pub fn picks_objects(&self) -> bool {
|
||||
self.accept.select || (self.accept.point && self.message.starts_with("Select"))
|
||||
self.picks_one || self.accept.select || (self.accept.point && !self.raw && self.message.starts_with("Select"))
|
||||
}
|
||||
/// "Specify next point or [Undo/Close] <default>:".
|
||||
pub fn display(&self) -> String {
|
||||
|
||||
@@ -121,3 +121,20 @@ fn json_form_takes_leader_type_and_block() {
|
||||
assert!(s.execute("mleader", &bad).is_err(), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
/// DIMSCALE belongs to dimension styles: a multileader keeps its style's text height, arrowhead
|
||||
/// and dogleg whatever DIMSCALE is (#1648).
|
||||
#[test]
|
||||
fn mleader_sizes_ignore_dimscale() {
|
||||
let mut s = Session::new();
|
||||
s.execute("mleaderstyle", &json!({"name": "M5", "textHeight": 5, "arrowSize": 5, "current": true})).unwrap();
|
||||
s.execute("setvar", &json!({"name": "DIMSCALE", "value": 20})).unwrap();
|
||||
s.execute("mleader", &json!({"points": [[0, 0], [30, 20]], "text": "CADCRAFT"})).unwrap();
|
||||
let m = last_mleader(&s);
|
||||
assert_eq!(m.arrow_size, 5.0);
|
||||
let st = s.doc().unwrap().mleader_styles.iter().find(|x| x.name == "M5").unwrap().clone();
|
||||
assert_eq!(m.dogleg, st.dogleg);
|
||||
let t = m.text.unwrap();
|
||||
assert_eq!(t.height, 5.0);
|
||||
assert!((t.insert.x - (30.0 + st.dogleg + st.landing_gap)).abs() < 1e-9, "{}", t.insert.x);
|
||||
}
|
||||
@@ -68,6 +68,7 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
|
||||
let cs: Vec<char> = chars.iter().map(|c| c.0).collect();
|
||||
let flags = |i: usize| chars.get(i).map(|c| (c.1, c.2)).unwrap_or((false, false));
|
||||
let mut out = Shaped::default();
|
||||
let mut points_left = ttf::MAX_SHAPED_POINTS;
|
||||
let mut spans: Vec<(f64, f64, bool, bool)> = Vec::new();
|
||||
let mut x = 0.0;
|
||||
// Whether the rightmost glyph so far is a stroke-font glyph.
|
||||
@@ -102,11 +103,10 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
|
||||
let outline = ttf::glyph_by_id(face, g.gid);
|
||||
let k = h / outline.cap * wf;
|
||||
let dy = g.dy * h / outline.cap;
|
||||
let mut contours = ttf::place_glyph(&outline, x + g.dx * k, h, wf, shear);
|
||||
for p in contours.iter_mut().flatten() {
|
||||
*p += Vec2::new(dy * shear, dy);
|
||||
}
|
||||
if !contours.is_empty() {
|
||||
if let Some(mut contours) = ttf::place_glyph_budgeted(&outline, x + g.dx * k, h, wf, shear, &mut points_left) {
|
||||
for p in contours.iter_mut().flatten() {
|
||||
*p += Vec2::new(dy * shear, dy);
|
||||
}
|
||||
out.glyphs.push(contours);
|
||||
}
|
||||
let adv = g.advance * k;
|
||||
|
||||
@@ -334,7 +334,11 @@ pub struct TextGlyph {
|
||||
|
||||
impl Shaped {
|
||||
/// Add a glyph outline and, when it is a font glyph, its [`TextGlyph`].
|
||||
/// An empty outline is retained only when text metadata needs its slot.
|
||||
pub(crate) fn push_glyph(&mut self, contours: Vec<Vec<Vec2>>, text: Option<TextGlyph>) {
|
||||
if contours.is_empty() && text.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(t) = text {
|
||||
self.text.push(TextGlyph { outline: self.glyphs.len(), ..t });
|
||||
}
|
||||
|
||||
@@ -501,6 +501,9 @@ struct Line {
|
||||
width: f64,
|
||||
max_h: f64,
|
||||
text: String,
|
||||
/// Running character count of `text`, kept incrementally so appending a segment need not
|
||||
/// rescan the whole accumulated line (which is multiplicative on a long line) (CWE-407).
|
||||
chars: usize,
|
||||
/// Index of the paragraph the line belongs to.
|
||||
para: usize,
|
||||
}
|
||||
@@ -580,9 +583,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
finish(&mut lines, &mut line, fmt(last_fmt), para);
|
||||
} else if !line.items.is_empty() || pending_space.is_some() {
|
||||
if let Some(f) = pending_space {
|
||||
let at = line.text.chars().count();
|
||||
let at = line.chars;
|
||||
line.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at });
|
||||
line.text.push(' ');
|
||||
line.chars += 1;
|
||||
}
|
||||
line.width += sw;
|
||||
}
|
||||
@@ -590,8 +594,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
for (sg, f) in segs {
|
||||
let fw = seg_width(sg, fmt(*f));
|
||||
line.max_h = line.max_h.max(fmt(*f).height);
|
||||
let at = line.text.chars().count();
|
||||
line.text.push_str(&seg_plain(sg));
|
||||
let at = line.chars;
|
||||
let plain = seg_plain(sg);
|
||||
line.chars += plain.chars().count();
|
||||
line.text.push_str(&plain);
|
||||
line.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at });
|
||||
line.width += fw;
|
||||
last_fmt = *f;
|
||||
@@ -679,6 +685,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn line_char_offsets_track_incrementally() {
|
||||
// A multi-word line lays out correctly even though the per-line character count is now
|
||||
// maintained incrementally rather than rescanned per segment (CWE-407, row 20).
|
||||
let l = layout_mtext("alpha beta gamma", Vec2::ZERO, 1.0, 0.0, 1, 0.0, 1.0);
|
||||
assert_eq!(l.lines.len(), 1, "no wrap width: one line");
|
||||
assert_eq!(l.lines[0], "alpha beta gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_formatting() {
|
||||
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
|
||||
|
||||
+124
-19
@@ -107,6 +107,9 @@ const MAX_FIRST_WORD_FILES: usize = 24;
|
||||
const MAX_LOADED_BYTES: usize = 256 << 20;
|
||||
const MAX_CACHED_NAMES: usize = 4096;
|
||||
const MAX_CACHED_CHARS: usize = 50_000;
|
||||
/// Cumulative outline points one shaped line may retain, so an oversized expanded glyph repeated
|
||||
/// across a string cannot grow the result without bound (CWE-400).
|
||||
pub(crate) const MAX_SHAPED_POINTS: usize = 8_000_000;
|
||||
|
||||
/// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as
|
||||
/// substrings of the normalised file stem.
|
||||
@@ -595,6 +598,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
|
||||
#[derive(Default)]
|
||||
pub(crate) struct GlyphOutline {
|
||||
pub(crate) contours: Arc<[Vec<Vec2>]>,
|
||||
points: usize,
|
||||
pub(crate) advance: f64,
|
||||
pub(crate) cap: f64,
|
||||
/// The font maps the character (false: the missing-glyph shape).
|
||||
@@ -614,22 +618,46 @@ enum GlyphKey {
|
||||
/// Cached glyph outlines keyed by (font bytes address, length, face, glyph). Fonts live for the
|
||||
/// whole process in the font table, so the address identifies them. A read-write lock: display
|
||||
/// lists are built on all cores and nearly every lookup is a hit, which a mutex serialized.
|
||||
type GlyphCache = HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>;
|
||||
#[derive(Default)]
|
||||
struct GlyphCache {
|
||||
glyphs: HashMap<(usize, usize, u32, GlyphKey), Arc<GlyphOutline>>,
|
||||
points: usize,
|
||||
}
|
||||
|
||||
fn glyph_cache() -> &'static RwLock<GlyphCache> {
|
||||
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
|
||||
C.get_or_init(|| RwLock::new(HashMap::new()))
|
||||
C.get_or_init(|| RwLock::new(GlyphCache::default()))
|
||||
}
|
||||
|
||||
const MAX_CACHED_GLYPHS: usize = 40_000;
|
||||
const UPEM: f32 = 1000.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Pen {
|
||||
contours: Vec<Vec<Vec2>>,
|
||||
cur: Vec<Vec2>,
|
||||
points: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
const MAX_OUTLINE_POINTS: usize = 65_536;
|
||||
|
||||
impl Pen {
|
||||
fn push(&mut self, point: Vec2) {
|
||||
if self.truncated {
|
||||
return;
|
||||
}
|
||||
if self.points == MAX_OUTLINE_POINTS {
|
||||
// An incomplete contour is not safe to fill; retain only the advance for this glyph.
|
||||
self.truncated = true;
|
||||
self.cur.clear();
|
||||
self.contours.clear();
|
||||
return;
|
||||
}
|
||||
self.points += 1;
|
||||
self.cur.push(point);
|
||||
}
|
||||
|
||||
fn pt(x: f32, y: f32) -> Vec2 {
|
||||
Vec2::new(f64::from(x), f64::from(y))
|
||||
}
|
||||
@@ -638,9 +666,11 @@ impl Pen {
|
||||
if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied())
|
||||
&& f != l
|
||||
{
|
||||
self.cur.push(f);
|
||||
self.push(f);
|
||||
}
|
||||
if !self.truncated {
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
}
|
||||
self.contours.push(std::mem::take(&mut self.cur));
|
||||
} else {
|
||||
self.cur.clear();
|
||||
}
|
||||
@@ -650,10 +680,10 @@ impl Pen {
|
||||
impl OutlinePen for Pen {
|
||||
fn move_to(&mut self, x: f32, y: f32) {
|
||||
self.flush();
|
||||
self.cur.push(Self::pt(x, y));
|
||||
self.push(Self::pt(x, y));
|
||||
}
|
||||
fn line_to(&mut self, x: f32, y: f32) {
|
||||
self.cur.push(Self::pt(x, y));
|
||||
self.push(Self::pt(x, y));
|
||||
}
|
||||
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||
let a = self.cur.last().copied().unwrap_or_default();
|
||||
@@ -662,7 +692,7 @@ impl OutlinePen for Pen {
|
||||
for i in 1..=6 {
|
||||
let t = f64::from(i) / 6.0;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
|
||||
self.push(a * (u * u) + c * (2.0 * u * t) + b * (t * t));
|
||||
}
|
||||
}
|
||||
fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) {
|
||||
@@ -673,7 +703,7 @@ impl OutlinePen for Pen {
|
||||
for i in 1..=8 {
|
||||
let t = f64::from(i) / 8.0;
|
||||
let u = 1.0 - t;
|
||||
self.cur.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
|
||||
self.push(a * (u * u * u) + c0 * (3.0 * u * u * t) + c1 * (3.0 * u * t * t) + b * (t * t * t));
|
||||
}
|
||||
}
|
||||
fn close(&mut self) {
|
||||
@@ -714,7 +744,7 @@ pub fn glyph_contours(face: &Face, gid: u16) -> Arc<[Vec<Vec2>]> {
|
||||
|
||||
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).get(&key) {
|
||||
if let Some(g) = glyph_cache().read().unwrap_or_else(PoisonError::into_inner).glyphs.get(&key) {
|
||||
return g.clone();
|
||||
}
|
||||
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
|
||||
@@ -723,19 +753,24 @@ fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
|
||||
GlyphKey::Id(id) => Some(GlyphId::new(id)),
|
||||
};
|
||||
let gid = mapped.unwrap_or_default();
|
||||
let mut pen = Pen { contours: Vec::new(), cur: Vec::new() };
|
||||
let mut pen = Pen::default();
|
||||
if let Some(g) = f.outline_glyphs().get(gid) {
|
||||
let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen);
|
||||
pen.flush();
|
||||
}
|
||||
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
|
||||
let (cap, mapped, gid) = (cap_height(&f), mapped.is_some(), gid.to_u32());
|
||||
let g = Arc::new(GlyphOutline { contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
|
||||
let points = pen.contours.iter().map(Vec::len).sum();
|
||||
let g = Arc::new(GlyphOutline { points, contours: pen.contours.into(), advance, cap, mapped, gid, face: Some(face.clone()) });
|
||||
let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner);
|
||||
if cache.len() > MAX_CACHED_GLYPHS {
|
||||
cache.clear();
|
||||
if cache.glyphs.len() >= MAX_CACHED_GLYPHS || cache.points.saturating_add(points) > MAX_SHAPED_POINTS {
|
||||
cache.glyphs.clear();
|
||||
cache.points = 0;
|
||||
}
|
||||
cache.insert(key, g.clone());
|
||||
if let Some(old) = cache.glyphs.insert(key, g.clone()) {
|
||||
cache.points -= old.points;
|
||||
}
|
||||
cache.points += points;
|
||||
g
|
||||
}
|
||||
|
||||
@@ -777,6 +812,11 @@ pub(crate) fn text_glyph_at(g: &GlyphOutline, c: char, origin: Vec2, scale: f64,
|
||||
})
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
/// Contours of a glyph scaled so its font's cap height is `h`, at pen position `x`.
|
||||
pub(crate) fn place_glyph(g: &GlyphOutline, x: f64, h: f64, wf: f64, shear: f64) -> Vec<Vec<Vec2>> {
|
||||
place_glyph_at(g, Vec2::new(x, 0.0), h / g.cap, wf, shear)
|
||||
@@ -814,16 +854,20 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64)
|
||||
let mut out = Shaped::default();
|
||||
let mut spans = Vec::new();
|
||||
let mut x = 0.0;
|
||||
// Cumulative retained-point budget: a compact CFF/OTF font can expand one glyph into a huge
|
||||
// outline, and a short repeated string copies it per occurrence, so cap the total geometry
|
||||
// kept in the Shaped result (CWE-400). Advances still accumulate so the width stays sensible.
|
||||
let mut points_left: usize = MAX_SHAPED_POINTS;
|
||||
for (c, under, over) in crate::decode_controls(s) {
|
||||
if crate::bidi::is_format_control(c) {
|
||||
continue;
|
||||
}
|
||||
let g = glyph_or_fallback(face, c);
|
||||
if !c.is_whitespace() {
|
||||
let contours = place_glyph(&g, x, h, wf, shear);
|
||||
if !contours.is_empty() {
|
||||
out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear));
|
||||
}
|
||||
if !c.is_whitespace()
|
||||
&& let Some(contours) = place_glyph_budgeted(&g, x, h, wf, shear, &mut points_left)
|
||||
{
|
||||
// Even an empty mapped glyph needs a valid outline slot for text export.
|
||||
out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear));
|
||||
}
|
||||
let adv = g.advance * h / g.cap * wf;
|
||||
spans.push((x, x + adv, under, over));
|
||||
@@ -878,3 +922,64 @@ mod family_tests {
|
||||
assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod geometry_budget_tests {
|
||||
use super::*;
|
||||
#[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() };
|
||||
let mut budget = 3;
|
||||
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none());
|
||||
assert_eq!(budget, 3);
|
||||
let empty = GlyphOutline { cap: 1.0, ..Default::default() };
|
||||
assert_eq!(place_glyph_budgeted(&empty, 0.0, 1.0, 1.0, 0.0, &mut 0), Some(Vec::new()));
|
||||
let mut budget = 4;
|
||||
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_some());
|
||||
assert_eq!(budget, 0);
|
||||
assert!(place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut budget).is_none());
|
||||
}
|
||||
#[test]
|
||||
fn flattened_outlines_are_bounded_before_caching() {
|
||||
let mut pen = Pen::default();
|
||||
for _ in 0..MAX_OUTLINE_POINTS {
|
||||
pen.line_to(0.0, 0.0);
|
||||
}
|
||||
pen.line_to(1.0, 1.0);
|
||||
pen.close();
|
||||
assert!(pen.truncated);
|
||||
assert!(pen.contours.is_empty() && pen.cur.is_empty());
|
||||
assert_eq!(pen.points, MAX_OUTLINE_POINTS);
|
||||
}
|
||||
#[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.
|
||||
let glyph = GlyphOutline { cap: 1.0, mapped: true, gid: 1, face: Some(Face { bytes: Arc::new(Vec::new()), index: 0 }), ..Default::default() };
|
||||
let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap();
|
||||
let mut shaped = Shaped::default();
|
||||
shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0));
|
||||
assert_eq!(shaped.text.len(), 1);
|
||||
assert_eq!(shaped.text[0].outline, 0);
|
||||
assert!(shaped.glyphs[shaped.text[0].outline].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmapped_empty_glyph_does_not_make_the_shape_nonempty() {
|
||||
let glyph = GlyphOutline { cap: 1.0, ..Default::default() };
|
||||
let contours = place_glyph_budgeted(&glyph, 0.0, 1.0, 1.0, 0.0, &mut 0).unwrap();
|
||||
let mut shaped = Shaped::default();
|
||||
shaped.push_glyph(contours, text_glyph(&glyph, 'x', 0.0, 1.0, 1.0, 0.0));
|
||||
assert!(shaped.is_empty());
|
||||
assert!(shaped.text.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unmapped_glyph_with_contours_still_draws() {
|
||||
let mut shaped = Shaped::default();
|
||||
let contours = vec![vec![Vec2::ZERO, Vec2::new(1.0, 1.0)]];
|
||||
shaped.push_glyph(contours.clone(), None);
|
||||
assert!(!shaped.is_empty());
|
||||
assert_eq!(shaped.glyphs, vec![contours]);
|
||||
assert!(shaped.text.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -437,6 +437,10 @@ pub fn bulge_to_arc(a: Vec2, b: Vec2, bulge: f64) -> Option<(Arc, bool)> {
|
||||
return None;
|
||||
}
|
||||
let theta = 4.0 * bulge.atan(); // signed included angle
|
||||
// A sweep at or below EPS would be read back as a full circle by `ccw_sweep`; it is a straight segment.
|
||||
if theta.abs() <= EPS {
|
||||
return None;
|
||||
}
|
||||
let chord = a.dist(b);
|
||||
let r = chord / (2.0 * (theta / 2.0).sin().abs());
|
||||
let mid = a.mid(b);
|
||||
|
||||
+44
-14
@@ -158,7 +158,7 @@ fn seg_cross(a0: Vec2, a1: Vec2, b0: Vec2, b1: Vec2) -> Option<(f64, f64)> {
|
||||
}
|
||||
|
||||
/// Crossings of two sampled polylines, as parameter seeds, with chunked box culling.
|
||||
fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) {
|
||||
fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>, work: &mut usize) {
|
||||
const CHUNK: usize = 16;
|
||||
let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> {
|
||||
(0..s.len().saturating_sub(1))
|
||||
@@ -169,12 +169,16 @@ fn crossings(a: &[(f64, Vec2)], b: &[(f64, Vec2)], out: &mut Vec<(f64, f64)>) {
|
||||
let (ba, bb) = (boxes(a), boxes(b));
|
||||
for (ia, xa) in &ba {
|
||||
for (ib, xb) in &bb {
|
||||
let Some(left) = work.checked_sub(1) else { return };
|
||||
*work = left;
|
||||
if !xa.intersects(xb) {
|
||||
continue;
|
||||
}
|
||||
for i in *ia..(*ia + CHUNK).min(a.len().saturating_sub(1)) {
|
||||
let (Some(&(ta0, a0)), Some(&(ta1, a1))) = (a.get(i), a.get(i + 1)) else { continue };
|
||||
for j in *ib..(*ib + CHUNK).min(b.len().saturating_sub(1)) {
|
||||
let Some(left) = work.checked_sub(1) else { return };
|
||||
*work = left;
|
||||
let (Some(&(tb0, b0)), Some(&(tb1, b1))) = (b.get(j), b.get(j + 1)) else { continue };
|
||||
if let Some((u, v)) = seg_cross(a0, a1, b0, b1) {
|
||||
out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v));
|
||||
@@ -255,24 +259,35 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
|
||||
let (Some(r1), Some(r2)) = (c1.range(&win), c2.range(&win)) else { return Vec::new() };
|
||||
let signs: &[(f64, f64)] = if r > 0.0 { &[(1.0, 1.0), (1.0, -1.0), (-1.0, 1.0), (-1.0, -1.0)] } else { &[(0.0, 0.0)] };
|
||||
let mut out: Vec<CornerFit> = Vec::new();
|
||||
let mut work = 16_000_000usize;
|
||||
for (s1, s2) in signs {
|
||||
let (d1, d2) = (s1 * r, s2 * r);
|
||||
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
|
||||
let mut seeds = Vec::new();
|
||||
// Refine each pair before visiting the next: raw duplicate seeds in an early run must
|
||||
// not consume all candidate slots. A separate request-wide budget bounds actual work.
|
||||
for a in &o1 {
|
||||
for b in &o2 {
|
||||
crossings(a, b, &mut seeds);
|
||||
}
|
||||
}
|
||||
for seed in seeds {
|
||||
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
|
||||
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
|
||||
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
|
||||
if !out.iter().any(near_dup) {
|
||||
out.push(CornerFit { t1, t2, p1, p2, center });
|
||||
}
|
||||
if out.len() >= MAX_SOLUTIONS {
|
||||
return out;
|
||||
let mut seeds = Vec::new();
|
||||
crossings(a, b, &mut seeds, &mut work);
|
||||
seeds.sort_by(|x, y| x.0.total_cmp(&y.0).then(x.1.total_cmp(&y.1)));
|
||||
seeds.dedup_by(|x, y| (x.0 - y.0).abs() < 1e-9 && (x.1 - y.1).abs() < 1e-9);
|
||||
for seed in seeds {
|
||||
// Newton refinement has a fixed iteration cap and several curve evaluations.
|
||||
let Some(left) = work.checked_sub(128) else { return out };
|
||||
work = left;
|
||||
let Some((t1, t2)) = refine(c1, &r1, d1, c2, &r2, d2, seed, tol) else { continue };
|
||||
let (Some(center), p1, p2) = (c1.offset(t1, d1), c1.point(t1), c2.point(t2)) else { continue };
|
||||
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
|
||||
if !out.iter().any(near_dup) {
|
||||
out.push(CornerFit { t1, t2, p1, p2, center });
|
||||
}
|
||||
if out.len() >= MAX_SOLUTIONS {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
if work == 0 {
|
||||
return out;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -353,3 +368,18 @@ mod tests {
|
||||
assert!(tangent_circles(&bad, &s, 1.0, &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod seed_budget_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn crossing_visits_have_an_independent_work_limit() {
|
||||
let a = [(0.0, Vec2::new(0.0, 0.0)), (1.0, Vec2::new(1.0, 1.0))];
|
||||
let b = [(0.0, Vec2::new(0.0, 1.0)), (1.0, Vec2::new(1.0, 0.0))];
|
||||
let mut seeds = Vec::new();
|
||||
crossings(&a, &b, &mut seeds, &mut 0);
|
||||
assert!(seeds.is_empty());
|
||||
crossings(&a, &b, &mut seeds, &mut 10);
|
||||
assert_eq!(seeds, vec![(0.5, 0.5)]);
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ pub use curve::{
|
||||
pub use fillet::{CornerFit, TanCurve, tangent_circles};
|
||||
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
||||
pub use mat::{Mat3, Mat4};
|
||||
pub use spline::Spline;
|
||||
pub use spline::{MAX_DEGREE as MAX_SPLINE_DEGREE, Spline};
|
||||
pub use spline_fit::{FitOptions, KnotParam};
|
||||
pub use vec::{Vec2, Vec3};
|
||||
|
||||
|
||||
@@ -124,8 +124,15 @@ impl Spline {
|
||||
if !self.is_valid() {
|
||||
return Vec2::ZERO;
|
||||
}
|
||||
self.derivative_unchecked(t)
|
||||
}
|
||||
|
||||
/// First derivative without re-validating the spline. The caller must have confirmed
|
||||
/// [`Spline::is_valid`] already; integration calls this once per sample, and re-running the
|
||||
/// O(knots) validity scan each time would make measuring a valid spline quadratic (CWE-407).
|
||||
fn derivative_unchecked(&self, t: f64) -> Vec2 {
|
||||
let p = self.degree;
|
||||
let n = self.control.len() - 1;
|
||||
let Some(n) = self.control.len().checked_sub(1) else { return Vec2::ZERO };
|
||||
let (lo, hi) = self.domain();
|
||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||
let span = find_span(n, p, t, &self.knots);
|
||||
@@ -470,9 +477,11 @@ impl Spline {
|
||||
out
|
||||
}
|
||||
|
||||
/// Arc length of one integration panel.
|
||||
/// Arc length of one integration panel. The spline is validated once by the public callers
|
||||
/// ([`Spline::length_between`]/[`Spline::length`]) before any panel is measured, so the speed
|
||||
/// closure uses the unchecked derivative to avoid re-scanning the knots per sample.
|
||||
fn panel_length(&self, a: f64, b: f64) -> f64 {
|
||||
let speed = |t: f64| self.derivative(t).len();
|
||||
let speed = |t: f64| self.derivative_unchecked(t).len();
|
||||
adaptive_gauss(a, b, gauss(a, b, speed), &speed, MAX_GAUSS_DEPTH)
|
||||
}
|
||||
|
||||
|
||||
@@ -114,9 +114,19 @@ fn build(polys: &[Vec<Vec2>], tol: f64) -> Option<Graph> {
|
||||
}
|
||||
let mut splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||
let mut tested = std::collections::HashSet::new();
|
||||
// Count every candidate-pair *visit*, including repeats of a pair that shares several grid
|
||||
// cells, not only the distinct pairs actually tested. A long segment replicated across many
|
||||
// cells makes the same pair reappear in each, so a budget on distinct pairs alone leaves the
|
||||
// repeated hashing unbounded (CWE-407).
|
||||
let mut visits: u64 = 0;
|
||||
const MAX_PAIR_VISITS: u64 = 60_000_000;
|
||||
for list in grid.values() {
|
||||
for (k, &i) in list.iter().enumerate() {
|
||||
for &j in list.iter().skip(k + 1) {
|
||||
visits += 1;
|
||||
if visits > MAX_PAIR_VISITS {
|
||||
return None;
|
||||
}
|
||||
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::spline_fit::FitOptions;
|
||||
use crate::{Bounds2, Vec2};
|
||||
|
||||
/// Largest B-spline degree treated as valid. Basis evaluation is O(degree²) per sample and
|
||||
/// tessellation takes O(degree) samples, so an unbounded degree (DXF/HATCH can declare up to
|
||||
/// 100 000) turns a tiny spline into billions of iterations (CWE-407). Real splines are low
|
||||
/// degree (AutoCAD caps at 11); above this a spline evaluates to its control polygon instead.
|
||||
pub const MAX_DEGREE: usize = 64;
|
||||
|
||||
/// A (possibly rational) B-spline curve, DXF SPLINE style.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Spline {
|
||||
@@ -57,7 +63,11 @@ impl Spline {
|
||||
|
||||
pub fn is_valid(&self) -> bool {
|
||||
let n = self.control.len();
|
||||
n > self.degree && self.degree >= 1 && self.knots.len() == n + self.degree + 1 && self.knots.windows(2).all(|w| w.first() <= w.get(1))
|
||||
n > self.degree
|
||||
&& self.degree >= 1
|
||||
&& self.degree <= MAX_DEGREE
|
||||
&& self.knots.len() == n + self.degree + 1
|
||||
&& self.knots.windows(2).all(|w| w.first() <= w.get(1))
|
||||
}
|
||||
|
||||
pub fn domain(&self) -> (f64, f64) {
|
||||
@@ -192,3 +202,29 @@ pub(crate) fn solve(mut a: Vec<Vec<f64>>, mut b: Vec<f64>) -> Option<Vec<f64>> {
|
||||
}
|
||||
Some(x)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_huge_degree_spline_is_invalid_and_cheap_to_tessellate() {
|
||||
// A HATCH/DXF spline edge can declare a very high degree with matching knots. Such a
|
||||
// spline is treated as invalid, so eval/tessellate fall back to the control polygon
|
||||
// instead of doing O(degree^2) work per sample (CWE-407, rows 18/28).
|
||||
let degree = 50_000usize;
|
||||
let control = vec![Vec2::ZERO; degree + 2];
|
||||
let knots = clamped_uniform_knots(control.len(), degree);
|
||||
let s = Spline { degree, knots, control: control.clone(), ..Spline::default() };
|
||||
assert!(!s.is_valid(), "a degree-{degree} spline must be rejected as invalid");
|
||||
// Returns the control polygon rather than sampling, so this is fast.
|
||||
assert_eq!(s.tessellate(1e-3).len(), control.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_normal_low_degree_spline_is_valid() {
|
||||
let s = Spline::from_control(vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 1.0), Vec2::new(2.0, 0.0), Vec2::new(3.0, 1.0)], 3);
|
||||
assert!(s.is_valid());
|
||||
assert!(s.tessellate(1e-3).len() > 4);
|
||||
}
|
||||
}
|
||||
@@ -172,8 +172,21 @@ impl Spline {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Other writers use the parameters themselves as interior knots.
|
||||
u.len() > 2 && u.iter().skip(1).take(u.len() - 2).all(|q| norm.iter().any(|k| close(*k, *q)))
|
||||
// Other writers use the parameters themselves as interior knots. Both `u` and
|
||||
// `norm` are ascending, so a single linear merge decides containment instead of
|
||||
// re-scanning `norm` from the start for every parameter, which is quadratic on an
|
||||
// otherwise valid spline (CWE-407).
|
||||
if u.len() <= 2 {
|
||||
return false;
|
||||
}
|
||||
let interior = u.get(1..u.len() - 1).unwrap_or(&[]);
|
||||
let mut j = 0usize;
|
||||
interior.iter().all(|q| {
|
||||
while j < norm.len() && norm[j] < *q && !close(norm[j], *q) {
|
||||
j += 1;
|
||||
}
|
||||
norm.get(j).is_some_and(|k| close(*k, *q))
|
||||
})
|
||||
})
|
||||
.unwrap_or(KnotParam::Chord)
|
||||
}
|
||||
@@ -895,3 +908,17 @@ mod tests {
|
||||
assert!(s.fit_opts.start_tangent.is_some_and(|t| t.near(Vec2::Y, 1e-12)));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod knot_boundary_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn uniform_knots_skip_the_strict_lower_tolerance_boundary() {
|
||||
let q = 2.0 / 3.0;
|
||||
let mut s = Spline::from_control(vec![Vec2::ZERO; 5], 1);
|
||||
s.fit = vec![Vec2::new(0.0, 0.0), Vec2::new(1.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(9.0, 0.0)];
|
||||
s.knots = vec![0.0, 0.0, 1.0 / 3.0, q - 1e-6, q, 1.0, 1.0];
|
||||
assert!(s.is_valid());
|
||||
assert_eq!(s.infer_knot_param(), KnotParam::Uniform);
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,16 @@ fn bulge_quarter_arc() {
|
||||
assert!(close(arc.sweep(), PI / 2.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_bulge_is_not_a_full_circle() {
|
||||
let a = Vec2::new(0.0, 0.0);
|
||||
let b = Vec2::new(10.0, 0.0);
|
||||
let p = Polyline { vertices: vec![PolyVertex::with_bulge(a, 1e-10), PolyVertex::new(b)], closed: false };
|
||||
assert!((p.len() - 10.0).abs() < 1e-6, "len={}", p.len());
|
||||
let bb = p.bounds();
|
||||
assert!(bb.max.x < 10.001 && bb.max.y < 0.001 && bb.min.y > -0.001, "bounds={bb:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn polyline_area_and_length() {
|
||||
let p = Polyline::from_points(&[Vec2::new(0.0, 0.0), Vec2::new(4.0, 0.0), Vec2::new(4.0, 3.0), Vec2::new(0.0, 3.0)], true);
|
||||
|
||||
@@ -141,26 +141,36 @@ pub(crate) fn resolve(secs: &[Section], d: &mut Drawing) {
|
||||
if by_image.is_empty() {
|
||||
return;
|
||||
}
|
||||
fill(&mut d.model, &by_image);
|
||||
// Aggregate budget on the image-definition strings copied into referring images, so an
|
||||
// attacker cannot multiply a long path/name across many references into gigabytes (CWE-400).
|
||||
const MAX_RESOLVED_STRING_BYTES: usize = 64 << 20;
|
||||
let mut budget = MAX_RESOLVED_STRING_BYTES;
|
||||
fill(&mut d.model, &by_image, &mut budget);
|
||||
for l in &mut d.layouts {
|
||||
fill(&mut l.entities, &by_image);
|
||||
fill(&mut l.entities, &by_image, &mut budget);
|
||||
}
|
||||
for b in d.blocks.values_mut() {
|
||||
if b.entities.iter().any(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)) {
|
||||
fill(&mut Arc::make_mut(b).entities, &by_image);
|
||||
fill(&mut Arc::make_mut(b).entities, &by_image, &mut budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fill(store: &mut EntityStore, by_image: &HashMap<Handle, &Def>) {
|
||||
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();
|
||||
for h in hs {
|
||||
let Some(def) = by_image.get(&h) else { continue };
|
||||
// Charge the bytes this copy adds; once the shared budget is spent, stop copying strings
|
||||
// (geometry fields are still applied) so retained memory stays bounded.
|
||||
let cost = def.path.len() + def.name.len();
|
||||
let copy_strings = budget.checked_sub(cost).map(|left| *budget = left).is_some();
|
||||
store.modify(h, |e| {
|
||||
if let EntityKind::Image(i) = &mut e.kind {
|
||||
i.path = def.path.clone();
|
||||
i.name = def.name.clone();
|
||||
if copy_strings {
|
||||
i.path = def.path.clone();
|
||||
i.name = def.name.clone();
|
||||
}
|
||||
if let Some(p) = def.pixel_size {
|
||||
i.pixel_size = p;
|
||||
}
|
||||
@@ -215,6 +225,10 @@ pub(crate) fn plan<'a>(entities: impl Iterator<Item = &'a cadcraft_doc::Entity>,
|
||||
let mut p = Plan::default();
|
||||
let mut index: HashMap<(String, String), usize> = HashMap::new();
|
||||
let mut used: HashSet<String> = HashSet::new();
|
||||
// Next suffix to try for each normalised base name, so allocating a unique name resumes where
|
||||
// the previous collision left off instead of re-probing `_2, _3, …` from the start — otherwise
|
||||
// many images sharing a base name make name allocation quadratic (CWE-407).
|
||||
let mut next_suffix: HashMap<String, usize> = HashMap::new();
|
||||
for e in entities {
|
||||
let EntityKind::Image(i) = &e.kind else { continue };
|
||||
if p.by_entity.contains_key(&e.handle) {
|
||||
@@ -227,11 +241,12 @@ pub(crate) fn plan<'a>(entities: impl Iterator<Item = &'a cadcraft_doc::Entity>,
|
||||
// Dictionary keys are unique regardless of case.
|
||||
let base = def_name(i);
|
||||
let mut name = base.clone();
|
||||
let mut n = 2usize;
|
||||
let mut n = next_suffix.get(&key(&base)).copied().unwrap_or(2);
|
||||
while !used.insert(key(&name)) {
|
||||
name = format!("{base}_{n}");
|
||||
n = n.saturating_add(1);
|
||||
}
|
||||
next_suffix.insert(key(&base), n);
|
||||
p.defs.push(PlannedDef {
|
||||
handle: next(),
|
||||
name,
|
||||
|
||||
@@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000;
|
||||
const MAX_XDATA_GROUPS: usize = 16_384;
|
||||
/// Longest ownership chain followed.
|
||||
const MAX_DEPTH: usize = 64;
|
||||
/// Handles above this are not believed (hostile files); references to them are dropped.
|
||||
const MAX_HANDLE: u64 = 1 << 48;
|
||||
|
||||
/// Record types that structure a file: never written as an entity of another type.
|
||||
const STRUCTURAL: &[&str] = &["", "SECTION", "ENDSEC", "EOF", "TABLE", "ENDTAB", "BLOCK", "ENDBLK", "SEQEND", "VERTEX", "ATTRIB", "CLASS"];
|
||||
@@ -85,9 +83,10 @@ fn tag(r: &RawTag) -> Tag {
|
||||
Tag::new(r.code, value.unwrap_or_else(|| Value::Str(r.value.clone())))
|
||||
}
|
||||
|
||||
/// A handle value we believe: hexadecimal, not null, not absurdly large.
|
||||
/// A handle value we believe: hexadecimal and not null. DXF handles are up to 16 hex digits and
|
||||
/// AutoCAD writes some above 2^48 (e.g. 0xDAD1…), so the whole 64-bit range is believed.
|
||||
fn handle(s: &str) -> Option<Handle> {
|
||||
Handle::parse_hex(s).filter(|h| h.0 != 0 && h.0 <= MAX_HANDLE)
|
||||
Handle::parse_hex(s).filter(|h| h.0 != 0)
|
||||
}
|
||||
|
||||
/// Group codes whose values are handles of other objects (DXF Reference, group code ranges).
|
||||
|
||||
+78
-13
@@ -245,6 +245,10 @@ fn mirror_text(t: &mut Text, align: Option<Vec3>, insert: Vec3) {
|
||||
|
||||
/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller.
|
||||
fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
entity_with_table_budget(kind, tags, &mut 4_000_000)
|
||||
}
|
||||
|
||||
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) {
|
||||
return None;
|
||||
@@ -480,7 +484,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
}
|
||||
k
|
||||
}
|
||||
"ACAD_TABLE" => EntityKind::Table(acad_table(tags)),
|
||||
"ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)),
|
||||
"VIEWPORT" => EntityKind::Viewport(Viewport {
|
||||
center: t.p(10),
|
||||
width: t.fd(40, 1.0),
|
||||
@@ -525,7 +529,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
|
||||
/// An ACAD_TABLE entity: sizes, cell texts and merges (DXF Reference), plus CADCraft's
|
||||
/// title/header flags, text height and style name from xdata when present.
|
||||
fn acad_table(tags: &[Tag]) -> Table {
|
||||
fn acad_table(tags: &[Tag], table_cells_left: &mut usize) -> Table {
|
||||
const MAX_CELLS: usize = 1_000_000;
|
||||
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0);
|
||||
let tail = tags.get(start..).unwrap_or(&[]);
|
||||
@@ -540,6 +544,12 @@ fn acad_table(tags: &[Tag]) -> Table {
|
||||
rows = 0;
|
||||
cols = 0;
|
||||
}
|
||||
// Charge before any cell vector is materialized, including discarded tables.
|
||||
let count = rows.saturating_mul(cols);
|
||||
match table_cells_left.checked_sub(count) {
|
||||
Some(left) => *table_cells_left = left,
|
||||
None => return Table { insert: T(tags).p(10), ..Table::default() },
|
||||
}
|
||||
let size = |v: f64| if v.is_finite() && v > 0.0 { v } else { 0.27 };
|
||||
let mut row_heights: Vec<f64> = head.all_f(141).into_iter().take(rows).map(size).collect();
|
||||
row_heights.resize(rows, 0.27);
|
||||
@@ -1098,7 +1108,13 @@ fn entity_extras(kind: &str, tags: &[Tag], h: Handle, k: &mut EntityKind, rx: &m
|
||||
|
||||
/// Parse a run of entity records (ENTITIES section or a block's body) into entities. Records that
|
||||
/// can't be placed are counted in `skipped`.
|
||||
fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, skipped: &mut usize) -> Vec<(Option<String>, bool, Entity)> {
|
||||
fn parse_entities(
|
||||
recs: &[(String, Vec<Tag>)],
|
||||
d: &mut Drawing,
|
||||
rx: &mut Rx,
|
||||
skipped: &mut usize,
|
||||
table_cells_left: &mut usize,
|
||||
) -> Vec<(Option<String>, bool, Entity)> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some((kind, tags)) = recs.get(i) {
|
||||
@@ -1106,7 +1122,7 @@ fn parse_entities(recs: &[(String, Vec<Tag>)], d: &mut Drawing, rx: &mut Rx, ski
|
||||
let owner = crate::dxf_ext::owner(tags);
|
||||
let paper = t.i(67) == Some(1);
|
||||
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
|
||||
let mut kindent = entity(kind, tags);
|
||||
let mut kindent = entity_with_table_budget(kind, tags, table_cells_left);
|
||||
if let Some((c, _)) = kindent.as_mut()
|
||||
&& let Some(n) = entity_plot_style(tags, rx)
|
||||
{
|
||||
@@ -1420,12 +1436,16 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
}
|
||||
let mut active_vport = false;
|
||||
// Index of layers already appended, by lower-cased name, so each LAYER record updates or
|
||||
// appends in O(1) instead of scanning the growing layer list — otherwise many distinct layer
|
||||
// names make import quadratic (CWE-407).
|
||||
let mut layer_index: HashMap<String, usize> = d.layers.iter().enumerate().map(|(i, l)| (l.name.to_ascii_lowercase(), i)).collect();
|
||||
for (kind, tg) in recs {
|
||||
let t = T(&tg);
|
||||
match kind.as_str() {
|
||||
"LAYER" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.layers.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let aci = t.i(62).unwrap_or(7);
|
||||
@@ -1455,14 +1475,21 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
if let Some(desc) = crate::dxf_ext::xdata(&tg, crate::dxf_ext::LAYER_DESCRIPTION_APP).iter().filter(|x| x.code == 1000).nth(1) {
|
||||
l.description = desc.str();
|
||||
}
|
||||
match d.layer_mut(&name) {
|
||||
Some(x) => *x = l,
|
||||
None => d.layers.push(l),
|
||||
match layer_index.get(&name.to_ascii_lowercase()) {
|
||||
Some(&i) => {
|
||||
if let Some(x) = d.layers.get_mut(i) {
|
||||
*x = l;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
layer_index.insert(name.to_ascii_lowercase(), d.layers.len());
|
||||
d.layers.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
"LTYPE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() || d.linetype(&name).is_some() {
|
||||
if name.is_empty() || d.linetype(&name).is_some() || d.linetypes.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
let pattern = ltype_pattern(&tg, <ype_styles);
|
||||
@@ -1470,7 +1497,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"STYLE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
|
||||
@@ -1552,7 +1579,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
||||
}
|
||||
"DIMSTYLE" => {
|
||||
let name = t.s(2).unwrap_or_default();
|
||||
if name.is_empty() {
|
||||
if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS {
|
||||
continue;
|
||||
}
|
||||
// Every variable with a DIMSTYLE group code; values the style can't take are
|
||||
@@ -1651,6 +1678,10 @@ fn ltype_pattern(tags: &[Tag], styles: &HashMap<String, String>) -> Vec<DashElem
|
||||
|
||||
/// Read a DXF file into a drawing.
|
||||
pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
read_budgeted(bytes, 4_000_000)
|
||||
}
|
||||
|
||||
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);
|
||||
if secs.is_empty() {
|
||||
@@ -1705,7 +1736,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
j += 1;
|
||||
}
|
||||
let body = recs.get(i + 1..j).unwrap_or(&[]);
|
||||
let ents = parse_entities(body, &mut d, &mut rx, &mut n);
|
||||
let ents = parse_entities(body, &mut d, &mut rx, &mut n, &mut table_cells_left);
|
||||
blocks.push((name, base, description, ents, xref));
|
||||
i = j + 1;
|
||||
} else {
|
||||
@@ -1721,7 +1752,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
||||
// A damaged file may split its entities over several ENTITIES sections (#865).
|
||||
"ENTITIES" => {
|
||||
let mut n = 0;
|
||||
entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n));
|
||||
entities.extend(parse_entities(&records(&s.tags), &mut d, &mut rx, &mut n, &mut table_cells_left));
|
||||
count_skipped(&mut skipped, "ENTITIES", n);
|
||||
}
|
||||
"OBJECTS" => {
|
||||
@@ -2248,6 +2279,7 @@ fn page_setup(t: &T) -> PageSetup {
|
||||
_ => "layout",
|
||||
}
|
||||
.into();
|
||||
p.plot_window = [t.fd(48, 0.0), t.fd(49, 0.0), t.fd(140, 0.0), t.fd(141, 0.0)].map(|v| if v.is_finite() { v } else { 0.0 });
|
||||
p.plot_style_table = t.s(7).unwrap_or_default();
|
||||
p
|
||||
}
|
||||
@@ -2309,3 +2341,36 @@ fn helix(tags: &[Tag]) -> Helix {
|
||||
pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||
entity(&kind.to_ascii_uppercase(), tags)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod table_budget_tests {
|
||||
use super::*;
|
||||
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")]
|
||||
}
|
||||
#[test]
|
||||
fn exhausted_budget_rejects_before_materializing_cells() {
|
||||
let mut left = 3;
|
||||
let table = acad_table(&table_tags(2, 2), &mut left);
|
||||
assert!(table.cells.is_empty() && table.row_heights.is_empty() && table.col_widths.is_empty());
|
||||
assert_eq!(left, 3);
|
||||
let table = acad_table(&table_tags(1, 2), &mut left);
|
||||
assert_eq!(table.cells[0].len(), 2);
|
||||
assert_eq!(left, 1);
|
||||
}
|
||||
#[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";
|
||||
let dxf = format!(
|
||||
"0\nSECTION\n2\nBLOCKS\n0\nBLOCK\n2\nTEST\n10\n0\n20\n0\n{table}0\nENDBLK\n0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nSECTION\n2\nENTITIES\n{table}0\nENDSEC\n0\nEOF\n"
|
||||
);
|
||||
let d = read_budgeted(dxf.as_bytes(), 2).unwrap();
|
||||
let block = d.block("TEST").unwrap();
|
||||
let count = |e: &Entity| match &e.kind {
|
||||
EntityKind::Table(t) => t.cells.iter().map(Vec::len).sum::<usize>(),
|
||||
_ => 0,
|
||||
};
|
||||
assert_eq!(block.entities.iter().map(|e| count(e)).sum::<usize>(), 2);
|
||||
assert_eq!(d.model.iter().map(|e| count(e)).sum::<usize>(), 0);
|
||||
}
|
||||
}
|
||||
@@ -1441,8 +1441,11 @@ fn table_size(t: &Table) -> (usize, usize) {
|
||||
|
||||
/// The anonymous `*T` block of a table: cell fills, borders and texts as its cell styles draw
|
||||
/// them, for readers that only draw the block.
|
||||
fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
|
||||
let g = cadcraft_render::table_geometry(d, t);
|
||||
///
|
||||
/// The block is relative to its own origin: the ACAD_TABLE reference adds the insertion point.
|
||||
pub(crate) fn table_block_entities(d: &Drawing, t: &Table, layer: &str) -> Vec<Entity> {
|
||||
let at_origin = Table { insert: Vec3::ZERO, ..t.clone() };
|
||||
let g = cadcraft_render::table_geometry(d, &at_origin);
|
||||
let c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
|
||||
let mut out = Vec::new();
|
||||
for f in &g.fills {
|
||||
@@ -2565,10 +2568,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
||||
(45, page.height_mm),
|
||||
(46, 0.0),
|
||||
(47, 0.0),
|
||||
(48, 0.0),
|
||||
(49, 0.0),
|
||||
(140, 0.0),
|
||||
(141, 0.0),
|
||||
(48, page.plot_window[0]),
|
||||
(49, page.plot_window[1]),
|
||||
(140, page.plot_window[2]),
|
||||
(141, page.plot_window[3]),
|
||||
(142, scale),
|
||||
(143, 1.0),
|
||||
] {
|
||||
|
||||
@@ -511,9 +511,12 @@ fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, link
|
||||
if links.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
// Membership by handle, built once, so scanning primitives is O(primitives) rather than
|
||||
// O(primitives × links): otherwise the two counts multiply into excessive work (CWE-407).
|
||||
let linked: std::collections::HashSet<Handle> = links.iter().map(|(h, _)| *h).collect();
|
||||
let mut areas: std::collections::HashMap<Handle, Bounds2> = std::collections::HashMap::new();
|
||||
for p in &list.prims {
|
||||
if matches!(p.kind, Kind::Infinite { .. }) || !links.iter().any(|(h, _)| *h == p.handle) {
|
||||
if matches!(p.kind, Kind::Infinite { .. }) || !linked.contains(&p.handle) {
|
||||
continue;
|
||||
}
|
||||
let b = areas.entry(p.handle).or_insert(Bounds2::EMPTY);
|
||||
|
||||
@@ -38,6 +38,9 @@ const MAX_LAYERS: usize = 256;
|
||||
const MAX_TRANSFORM_DEPTH: usize = 64;
|
||||
/// Flattened chords per elliptical arc under a non-uniform transform.
|
||||
const MAX_ARC_CHORDS: usize = 10_000;
|
||||
/// Generated vertices retained across the whole document (all paths and shapes), so repeated
|
||||
/// arc/curve flattening cannot amplify a compact input into unbounded geometry.
|
||||
const MAX_TOTAL_VERTS: usize = 8_000_000;
|
||||
|
||||
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
|
||||
const SKIP: &[&[u8]] = &[
|
||||
@@ -126,6 +129,10 @@ struct Parser {
|
||||
layers: HashSet<String>,
|
||||
layer_color: u8,
|
||||
elements: usize,
|
||||
/// Generated vertices still allowed across the whole document. Arc/curve flattening can turn
|
||||
/// a compact path into many samples, and each flushed run is retained, so without a shared
|
||||
/// budget repeated arcs amplify a small input into gigabytes of geometry (CWE-400).
|
||||
verts_left: usize,
|
||||
/// > 0 while inside a skipped subtree; counts the starts seen there.
|
||||
skip: usize,
|
||||
/// What was left out, by kind ([`LeftOut`]).
|
||||
@@ -173,6 +180,7 @@ impl Parser {
|
||||
layers: HashSet::from(["0".to_string()]),
|
||||
layer_color: 1,
|
||||
elements: 0,
|
||||
verts_left: MAX_TOTAL_VERTS,
|
||||
skip: 0,
|
||||
left_out: BTreeMap::new(),
|
||||
}
|
||||
@@ -331,6 +339,14 @@ impl Parser {
|
||||
if self.elements > MAX_ELEMENTS {
|
||||
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} elements")));
|
||||
}
|
||||
// Charge the vertices this entity retains against the shared document budget, so repeated
|
||||
// flattened arcs/curves cannot amplify a compact path into unbounded geometry (CWE-400).
|
||||
let verts = match &kind {
|
||||
EntityKind::LwPolyline(p) => p.vertices.len(),
|
||||
EntityKind::Spline(s) => s.control.len(),
|
||||
_ => 1,
|
||||
};
|
||||
self.verts_left = self.verts_left.checked_sub(verts).ok_or_else(|| IoError::Format("SVG: too much generated geometry".into()))?;
|
||||
let common = Common { layer: self.layer.clone(), ..Common::default() };
|
||||
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
|
||||
}
|
||||
|
||||
@@ -1343,6 +1343,23 @@ fn tables_roundtrip_with_title_header_and_merges() {
|
||||
assert_eq!(again.blocks.len(), back.blocks.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_block_is_relative_to_the_insertion_point() {
|
||||
let d = Drawing::new_imperial();
|
||||
let t = table_sample();
|
||||
let ents = crate::dxf_write::table_block_entities(&d, &t, "0");
|
||||
let mut xs = Vec::new();
|
||||
for e in &ents {
|
||||
if let EntityKind::Line(l) = &e.kind {
|
||||
xs.extend([l.a.x, l.b.x]);
|
||||
}
|
||||
}
|
||||
assert!(!xs.is_empty());
|
||||
let (min, max) = xs.iter().fold((f64::MAX, f64::MIN), |(lo, hi), &x| (lo.min(x), hi.max(x)));
|
||||
// Columns total 8 wide; the block starts at its own origin, not at the insert x of 5.
|
||||
assert!(min.abs() < 1e-9 && (max - 8.0).abs() < 1e-9, "block x range {min}..{max}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_style_flags_roundtrip() {
|
||||
let mut d = Drawing::new_imperial();
|
||||
|
||||
@@ -536,3 +536,29 @@ fn dragging_a_distance_grip_changes_the_distance() {
|
||||
assert!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
|
||||
assert_eq!(d, before);
|
||||
}
|
||||
|
||||
/// The DOOR drawing with every handle moved above 2^48: AutoCAD writes such handles (seen in
|
||||
/// drawings whose handles start at 0xDAD1…), and the dynamic block's objects must still be kept.
|
||||
fn far_handles(src: &str) -> String {
|
||||
const SHIFT: u64 = 0x000D_AD10_0000_0000;
|
||||
let is_handle = |code: i32| matches!(code, 5 | 105 | 320..=369 | 390..=399 | 480 | 481 | 1005);
|
||||
let mut out = String::new();
|
||||
for line in src.lines() {
|
||||
let (code, value) = line.trim().split_once(' ').unwrap_or((line.trim(), ""));
|
||||
let moved = match (code.parse::<i32>(), u64::from_str_radix(value, 16)) {
|
||||
(Ok(c), Ok(h)) if is_handle(c) && h != 0 => format!("{:X}", h + SHIFT),
|
||||
_ => value.to_string(),
|
||||
};
|
||||
out.push_str(&format!("{code} {moved}\n"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dynamic_block_with_handles_above_2_pow_48_is_kept() {
|
||||
let src = far_handles(&fixture());
|
||||
let d = read_dxf(dxf(&src).as_bytes()).unwrap();
|
||||
let before = dynamic_block(&d, "Door").expect("the dynamic block with far handles");
|
||||
let again = read_dxf(write_dxf(&d).as_bytes()).unwrap();
|
||||
assert_eq!(by_node(dynamic_block(&again, "Door").unwrap()), by_node(before));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//! #1667: a layout whose page setup plots a window (DXF plot type 4) lost the window corners
|
||||
//! (groups 48, 49, 140, 141) on save: they were written as zero.
|
||||
|
||||
use cadcraft_doc::{Drawing, Layout, PageSetup};
|
||||
use cadcraft_io::{read, write_dxf};
|
||||
|
||||
#[test]
|
||||
fn plot_window_corners_round_trip_through_dxf() {
|
||||
let mut d = Drawing::new_metric();
|
||||
let mut l = Layout::new("WIN", 3);
|
||||
l.page = PageSetup {
|
||||
width_mm: 297.0,
|
||||
height_mm: 420.0,
|
||||
margins_mm: [0.0; 4],
|
||||
plot_area: "window".into(),
|
||||
plot_window: [0.0, -297.0, 420.0, 0.0],
|
||||
..PageSetup::default()
|
||||
};
|
||||
d.layouts.push(l);
|
||||
|
||||
let back = read(write_dxf(&d).as_bytes(), "win.dxf").unwrap();
|
||||
let page = &back.layouts.iter().find(|l| l.name == "WIN").unwrap().page;
|
||||
assert_eq!(page.plot_area, "window");
|
||||
assert_eq!(page.plot_window, [0.0, -297.0, 420.0, 0.0]);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Reading an SVG whose paths flatten into a huge number of retained vertices must be rejected by
|
||||
//! the document-wide generated-geometry budget, not amplified into gigabytes (CWE-400, row 31).
|
||||
|
||||
use cadcraft_io::read;
|
||||
|
||||
/// A path with many elliptical-arc subpaths under a non-uniform group transform; each subpath
|
||||
/// flushes to its own retained polyline, so enough of them must exceed the document vertex budget.
|
||||
fn arc_bomb(arcs: usize) -> Vec<u8> {
|
||||
// `scale(1 2)` makes the transform non-uniform, forcing per-sample flattening rather than a
|
||||
// single bulge arc. Each `M … a …` is a separate subpath that is flushed and retained.
|
||||
let mut d = String::new();
|
||||
for i in 0..arcs {
|
||||
let y = i as i32;
|
||||
d.push_str(&format!("M 0 {y} a 1000 500 0 1 1 1 1 "));
|
||||
}
|
||||
let svg = format!(r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><g transform="scale(1 2)"><path d="{d}"/></g></svg>"#);
|
||||
svg.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_modest_arc_svg_still_reads() {
|
||||
// A handful of arcs is well within budget and parses fine.
|
||||
let d = read(&arc_bomb(4), "a.svg").expect("a small arc SVG should read");
|
||||
assert!(d.model.iter().count() >= 1, "expected some geometry");
|
||||
}
|
||||
|
||||
#[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.
|
||||
let err = read(&arc_bomb(30000), "a.svg");
|
||||
assert!(err.unwrap_err().to_string().contains("SVG: too much generated geometry"));
|
||||
}
|
||||
@@ -14,3 +14,5 @@ cadcraft-render = { workspace = true }
|
||||
cadcraft-io = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
# Secure randomness for the per-session control token (MIT OR Apache-2.0).
|
||||
getrandom = "0.3"
|
||||
+106
-13
@@ -23,6 +23,14 @@ pub struct Remote {
|
||||
next_id: u64,
|
||||
/// Open the app when nothing answers (see [`Remote::lazy`]).
|
||||
launch: bool,
|
||||
/// The per-session control token for this port, read from the owner-only token file on first
|
||||
/// use so requests can authenticate (CWE-306).
|
||||
token: Option<String>,
|
||||
}
|
||||
|
||||
/// The port component of a `HOST:PORT` address, for locating the token file.
|
||||
fn port_of(addr: &str) -> Option<u16> {
|
||||
addr.rsplit(':').next().and_then(|p| p.parse::<u16>().ok())
|
||||
}
|
||||
|
||||
/// How long a launched app has to open its control port.
|
||||
@@ -30,7 +38,7 @@ const LAUNCH_WAIT: Duration = Duration::from_secs(30);
|
||||
|
||||
impl Remote {
|
||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false };
|
||||
let mut r = Self { addr: addr.to_string(), conn: None, next_id: 1, launch: false, token: None };
|
||||
r.reconnect()?;
|
||||
Ok(r)
|
||||
}
|
||||
@@ -38,7 +46,20 @@ impl Remote {
|
||||
/// the server before the app is open. With `launch`, a call that finds nothing at `addr`
|
||||
/// opens the app with that control port first (macOS) and waits for it.
|
||||
pub fn lazy(addr: &str, launch: bool) -> Self {
|
||||
Self { addr: addr.to_string(), conn: None, next_id: 1, launch }
|
||||
Self { addr: addr.to_string(), conn: None, next_id: 1, launch, token: None }
|
||||
}
|
||||
|
||||
/// The per-session token for this port, read (and cached) from the owner-only token file. A
|
||||
/// missing file is not fatal here: the request is sent without a token and the server replies
|
||||
/// with an authentication error the user can act on.
|
||||
fn token(&mut self) -> Option<&str> {
|
||||
if self.token.is_none()
|
||||
&& let Some(port) = port_of(&self.addr)
|
||||
&& let Ok(t) = crate::control_token::read(port)
|
||||
{
|
||||
self.token = Some(t);
|
||||
}
|
||||
self.token.as_deref()
|
||||
}
|
||||
/// Connect, opening the app first when asked to and nothing answers.
|
||||
fn reach(&mut self) -> std::io::Result<()> {
|
||||
@@ -91,7 +112,9 @@ impl Remote {
|
||||
let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush());
|
||||
if let Err(e) = sent {
|
||||
self.conn = None;
|
||||
return Err((e, false));
|
||||
// write_all can fail after partial delivery. The server may have received the full
|
||||
// JSON before the newline write failed, so a mutation must not be retried blindly.
|
||||
return Err((e, true));
|
||||
}
|
||||
let mut reply = String::new();
|
||||
match reader.read_line(&mut reply) {
|
||||
@@ -162,17 +185,35 @@ impl Backend for Remote {
|
||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
||||
let reply = match self.roundtrip(&line) {
|
||||
let token = self.token().map(str::to_string);
|
||||
let mut req = json!({"id": id, "method": method, "params": params});
|
||||
if let (Some(obj), Some(token)) = (req.as_object_mut(), token) {
|
||||
obj.insert("token".into(), Value::String(token));
|
||||
}
|
||||
let reply = match self.roundtrip(&req.to_string()) {
|
||||
Ok(r) => r,
|
||||
// Resend only when the request cannot have been applied. After a sent mutation with no reply the outcome is unknown, and
|
||||
// resending could apply it twice (for example two circles from one call), so report that instead.
|
||||
Err((_, sent)) if !sent || is_read_only(method) => self.roundtrip(&line).map_err(|(e, _)| format!("CADCraft at {}: {e}", self.addr))?,
|
||||
Err((e, _)) => {
|
||||
return Err(format!(
|
||||
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
|
||||
self.addr
|
||||
));
|
||||
Err((e, sent)) => {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
// A mutation whose reply was lost must never be sent twice.
|
||||
if sent && !is_read_only(method) {
|
||||
return Err(format!(
|
||||
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
|
||||
self.addr
|
||||
));
|
||||
}
|
||||
// A restarted server may have rotated its token before dropping the old socket.
|
||||
if let Some(obj) = req.as_object_mut() {
|
||||
obj.remove("token");
|
||||
if let Some(token) = self.token() {
|
||||
obj.insert("token".into(), Value::String(token.to_owned()));
|
||||
}
|
||||
}
|
||||
self.roundtrip(&req.to_string()).map_err(|(e, _)| {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
format!("CADCraft at {}: {e}", self.addr)
|
||||
})?
|
||||
}
|
||||
};
|
||||
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
|
||||
@@ -180,6 +221,10 @@ impl Backend for Remote {
|
||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||
} else {
|
||||
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
|
||||
if e.starts_with("unauthorized") {
|
||||
self.token = None;
|
||||
self.conn = None;
|
||||
}
|
||||
// A timeout says whether the request can still have run (#345).
|
||||
Err(match v.get("state").and_then(Value::as_str) {
|
||||
Some("not-run") => format!("{e}: CADCraft did not start `{method}` and won't; it is safe to send again"),
|
||||
@@ -375,9 +420,12 @@ mod tests {
|
||||
requests
|
||||
});
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
remote.token = Some("stale-token".into());
|
||||
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
|
||||
assert!(err.contains("may or may not have been applied"), "{err}");
|
||||
assert_eq!(server.join().unwrap(), 1);
|
||||
assert!(remote.token.is_none());
|
||||
assert!(remote.conn.is_none());
|
||||
}
|
||||
|
||||
/// The app's timeout reply says whether the request can still run; the error passes that on (#345).
|
||||
@@ -399,4 +447,49 @@ mod tests {
|
||||
server.join().unwrap();
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn safe_retry_rereads_a_rotated_token_after_a_lost_reply() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let old = crate::control_token::issue(addr.port()).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (conn, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
let mut reader = BufReader::new(conn);
|
||||
reader.read_line(&mut line).unwrap();
|
||||
let first: Value = serde_json::from_str(&line).unwrap();
|
||||
assert!(first["token"].as_str() == Some(old.as_str()));
|
||||
let fresh = crate::control_token::issue(addr.port()).unwrap();
|
||||
drop(reader);
|
||||
let (mut conn, _) = listener.accept().unwrap();
|
||||
line.clear();
|
||||
BufReader::new(conn.try_clone().unwrap()).read_line(&mut line).unwrap();
|
||||
let retry: Value = serde_json::from_str(&line).unwrap();
|
||||
assert!(retry["token"].as_str() == Some(fresh.as_str()));
|
||||
writeln!(conn, "{}", json!({"ok": true, "result": "pong"})).unwrap();
|
||||
crate::control_token::revoke(addr.port());
|
||||
});
|
||||
let mut remote = Remote::connect(&addr.to_string()).unwrap();
|
||||
assert_eq!(remote.call("ping", json!({})).unwrap(), json!("pong"));
|
||||
server.join().unwrap();
|
||||
}
|
||||
#[test]
|
||||
fn failed_mutation_write_is_not_resent() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
let mut remote = Remote::connect(&addr).unwrap();
|
||||
remote.conn.as_ref().unwrap().1.shutdown(std::net::Shutdown::Write).unwrap();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (conn, _) = listener.accept().unwrap();
|
||||
let mut line = String::new();
|
||||
assert_eq!(BufReader::new(conn).read_line(&mut line).unwrap(), 0);
|
||||
listener.set_nonblocking(true).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
listener.accept().is_ok()
|
||||
});
|
||||
let error = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
|
||||
assert!(error.contains("may or may not have been applied"));
|
||||
assert!(!server.join().unwrap(), "a failed mutation write was retried");
|
||||
assert!(remote.token.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! A per-session credential for the loopback control channel.
|
||||
//!
|
||||
//! The control server listens on `127.0.0.1`, but loopback TCP does not distinguish operating
|
||||
//! system users: any local account can connect and drive the desktop user's command and file
|
||||
//! authority (CWE-306). To authenticate the peer without changing the transport, the app issues an
|
||||
//! unguessable token at startup and writes it to a file only the desktop user can read. A client
|
||||
//! (the MCP bridge) reads the token from that file — which requires the same user's filesystem
|
||||
//! access — and sends it with every request. A different local user cannot read the token, so
|
||||
//! cannot authenticate.
|
||||
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Length of the token in bytes (256 bits), rendered as lowercase hex.
|
||||
const TOKEN_BYTES: usize = 32;
|
||||
|
||||
/// The file holding the control token for `port`, inside a per-user private directory.
|
||||
///
|
||||
/// On Unix the runtime dir (`XDG_RUNTIME_DIR`, already `0700` and per-user) is preferred, else the
|
||||
/// user's home; on Windows the local app-data directory. The file itself is created with
|
||||
/// owner-only permissions regardless.
|
||||
pub fn token_path(port: u16) -> io::Result<PathBuf> {
|
||||
Ok(private_dir()?.join(format!("cadcraft-control-{port}.token")))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn private_dir() -> io::Result<PathBuf> {
|
||||
if let Some(d) = std::env::var_os("XDG_RUNTIME_DIR").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(d));
|
||||
}
|
||||
if let Some(h) = std::env::var_os("HOME").filter(|v| !v.is_empty()) {
|
||||
return Ok(PathBuf::from(h).join(".cache"));
|
||||
}
|
||||
Ok(std::env::temp_dir())
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn private_dir() -> io::Result<PathBuf> {
|
||||
windows_private_dir(std::env::var_os("LOCALAPPDATA"))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_private_dir(local: Option<std::ffi::OsString>) -> io::Result<PathBuf> {
|
||||
local
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "LOCALAPPDATA is required for a private control token"))
|
||||
}
|
||||
|
||||
#[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"))
|
||||
}
|
||||
|
||||
/// Generate a fresh token and write it to [`token_path`] with owner-only permissions, returning
|
||||
/// the token. The file is replaced if it already exists.
|
||||
pub fn issue(port: u16) -> io::Result<String> {
|
||||
let token = random_hex()?;
|
||||
let path = token_path(port)?;
|
||||
issue_at(&path, token.as_bytes())?;
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
fn issue_at(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
write_private(path, bytes)
|
||||
}
|
||||
|
||||
/// Read the token for `port`, trimming trailing whitespace. Used by clients (the MCP bridge).
|
||||
pub fn read(port: u16) -> io::Result<String> {
|
||||
let raw = std::fs::read_to_string(token_path(port)?)?;
|
||||
let token = raw.trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "empty control token"));
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Remove the token file (best effort), called when the server stops.
|
||||
pub fn revoke(port: u16) {
|
||||
if let Ok(path) = token_path(port) {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Constant-time comparison of two tokens, so a wrong token cannot be guessed byte by byte.
|
||||
pub fn matches(expected: &str, given: &str) -> bool {
|
||||
let (a, b) = (expected.as_bytes(), given.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff = 0u8;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
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}")))?;
|
||||
let mut s = String::with_capacity(TOKEN_BYTES * 2);
|
||||
for b in bytes {
|
||||
use std::fmt::Write as _;
|
||||
let _ = write!(s, "{b:02x}");
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
// Replace any stale file, then create fresh with mode 0600 so no other user can read the token.
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.flush()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn write_private(path: &std::path::Path, bytes: &[u8]) -> io::Result<()> {
|
||||
use std::io::Write;
|
||||
// On Windows the per-user LOCALAPPDATA directory is already ACL-protected to the user.
|
||||
let _ = std::fs::remove_file(path);
|
||||
let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
|
||||
f.write_all(bytes)?;
|
||||
f.flush()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn issue_then_read_round_trips() {
|
||||
let port = 50000 + (std::process::id() % 10000) as u16;
|
||||
let token = issue(port).unwrap();
|
||||
assert_eq!(token.len(), TOKEN_BYTES * 2);
|
||||
assert_eq!(read(port).unwrap(), token);
|
||||
revoke(port);
|
||||
assert!(read(port).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matches_is_exact() {
|
||||
assert!(matches("abc", "abc"));
|
||||
assert!(!matches("abc", "abd"));
|
||||
assert!(!matches("abc", "ab"));
|
||||
assert!(!matches("", "x"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn token_file_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let port = 40000 + (std::process::id() % 10000) as u16;
|
||||
issue(port).unwrap();
|
||||
let mode = std::fs::metadata(token_path(port).unwrap()).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o077, 0, "token file is readable by group/other: {:o}", mode);
|
||||
revoke(port);
|
||||
}
|
||||
#[test]
|
||||
fn windows_requires_local_app_data() {
|
||||
assert!(windows_private_dir(None).is_err());
|
||||
assert!(windows_private_dir(Some("".into())).is_err());
|
||||
assert_eq!(windows_private_dir(Some("private-app-data".into())).unwrap(), PathBuf::from("private-app-data"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issuance_creates_missing_parent() {
|
||||
let dir = std::env::temp_dir().join(format!("cadcraft-token-parent-{}", std::process::id()));
|
||||
let path = dir.join("missing/cache/session.token");
|
||||
issue_at(&path, b"secret").unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"secret");
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod backend;
|
||||
pub mod control_token;
|
||||
mod server;
|
||||
mod tools;
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ pub fn boundary_polygon(k: &EntityKind) -> Option<Vec<Vec2>> {
|
||||
g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts);
|
||||
}
|
||||
EntityKind::Spline(s) => {
|
||||
if s.control.len() > 100_000 || s.knots.len() > 100_000 {
|
||||
if s.control.len() > 100_000 || s.knots.len() > 100_000 || s.degree > cadcraft_geom::MAX_SPLINE_DEGREE {
|
||||
return None;
|
||||
}
|
||||
pts = s.tessellate(tol_for(Bounds2::from_points(s.control.iter().copied())));
|
||||
|
||||
@@ -414,7 +414,14 @@ fn tol_height_code(st: &DimStyle) -> String {
|
||||
}
|
||||
|
||||
fn post(template: &str, meas: &str) -> String {
|
||||
if template.contains("<>") { template.replace("<>", meas) } else { format!("{meas}{template}") }
|
||||
if template.contains("<>") {
|
||||
bounded_replace(template, meas)
|
||||
} else {
|
||||
let mut out = String::new();
|
||||
append_dim_text(&mut out, meas);
|
||||
append_dim_text(&mut out, template);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_override(base: &str, d: &Dimension) -> String {
|
||||
@@ -423,10 +430,40 @@ fn apply_override(base: &str, d: &Dimension) -> String {
|
||||
} else if d.text.trim().is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
d.text.replace("<>", base)
|
||||
bounded_replace(&d.text, base)
|
||||
}
|
||||
}
|
||||
|
||||
/// Largest dimension text produced by substituting the measurement into style/override text. Two
|
||||
/// modest strings can otherwise expand into gigabytes when the template holds many `<>` markers
|
||||
/// (CWE-789); past this the output is truncated rather than allocated unbounded.
|
||||
const MAX_DIM_TEXT: usize = 64 * 1024;
|
||||
|
||||
/// Replace every `<>` in `template` with `value`, with the expanded length checked against
|
||||
/// [`MAX_DIM_TEXT`] before allocating so a many-placeholder template cannot blow up memory.
|
||||
fn bounded_replace(template: &str, value: &str) -> String {
|
||||
let mut out = String::new();
|
||||
let mut rest = template;
|
||||
while let Some(i) = rest.find("<>") {
|
||||
if !append_dim_text(&mut out, &rest[..i]) || !append_dim_text(&mut out, value) {
|
||||
return out;
|
||||
}
|
||||
rest = &rest[i + 2..];
|
||||
}
|
||||
append_dim_text(&mut out, rest);
|
||||
out
|
||||
}
|
||||
|
||||
// Returns false when truncation consumed the remaining UTF-8-safe space.
|
||||
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) {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(&text[..end]);
|
||||
end == text.len()
|
||||
}
|
||||
|
||||
fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option<Vec<Vec2>> {
|
||||
let v = foot - origin;
|
||||
let len = v.len();
|
||||
@@ -1322,3 +1359,21 @@ mod tests {
|
||||
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod text_budget_tests {
|
||||
use super::*;
|
||||
#[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"));
|
||||
}
|
||||
#[test]
|
||||
fn oversized_value_is_substituted_and_truncated_at_a_character_boundary() {
|
||||
let text = bounded_replace("prefix <> mm", &"é".repeat(MAX_DIM_TEXT));
|
||||
assert!(text.starts_with("prefix é"));
|
||||
assert!(!text.contains("<>"));
|
||||
assert!(text.len() <= MAX_DIM_TEXT && text.len() >= MAX_DIM_TEXT - 1);
|
||||
}
|
||||
}
|
||||
@@ -80,18 +80,47 @@ fn same_polygon(a: &[Vec2], b: &[Vec2]) -> bool {
|
||||
}
|
||||
|
||||
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
|
||||
// geometry could force quadratic-on-quadratic work. Charge every vertex comparison against a
|
||||
// shared budget; once it is spent, stop deduplicating and keep the remaining loops as-is
|
||||
// (CWE-407).
|
||||
let mut budget: u64 = MAX_DEDUP_COMPARISONS;
|
||||
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
|
||||
for l in loops.drain(..) {
|
||||
if l.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
if !unique.iter().any(|u| same_polygon(u, &l)) {
|
||||
let mut dup = false;
|
||||
for u in &unique {
|
||||
if u.len() != l.len() {
|
||||
continue;
|
||||
}
|
||||
// Each `same_polygon` call costs about 2·len² comparisons; stop if that would overrun.
|
||||
let cost = 2u64.saturating_mul((u.len() as u64).saturating_mul(l.len() as u64));
|
||||
match budget.checked_sub(cost) {
|
||||
Some(left) => budget = left,
|
||||
None => {
|
||||
// Out of budget: keep `l` without further comparison.
|
||||
budget = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if same_polygon(u, &l) {
|
||||
dup = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
unique.push(l);
|
||||
}
|
||||
}
|
||||
*loops = unique;
|
||||
}
|
||||
|
||||
/// Vertex comparisons `dedup_polygons` may perform before it stops deduplicating.
|
||||
const MAX_DEDUP_COMPARISONS: u64 = 50_000_000;
|
||||
|
||||
/// The tessellated boundary loops that take part in the fill under the hatch's island
|
||||
/// detection style (DXF group 75), filled with odd parity: 0 normal keeps every loop (islands
|
||||
/// within islands alternate), 1 outer keeps the outermost loops and their first islands, 2
|
||||
@@ -261,6 +290,9 @@ const MAX_PATTERN_LINES: usize = 1024;
|
||||
/// (a hostile or mis-scaled pattern can't exhaust memory). A view-limited build makes at most the
|
||||
/// runs of a full build, so it never leaves out runs that a full build draws.
|
||||
const MAX_RUNS: usize = 1_000_000;
|
||||
/// Shared budget on dash-processing steps across all of a hatch's families and spans, counted
|
||||
/// independently of emitted geometry so gap-only patterns cannot spin without bound (CWE-400).
|
||||
const MAX_HATCH_WORK: u64 = 50_000_000;
|
||||
/// Upper bound on the lines one family sweeps over the whole hatch; past it the family is left
|
||||
/// out (a full-detail build of a pattern far too fine for its area).
|
||||
const MAX_FAMILY_LINES: i64 = 20_000;
|
||||
@@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until(
|
||||
let scale = scale_of(h);
|
||||
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
|
||||
let mut out = Vec::new();
|
||||
// Shared work budget across all families and spans, counting dash-processing steps even when
|
||||
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
|
||||
// their cost (CWE-400).
|
||||
let mut work: u64 = MAX_HATCH_WORK;
|
||||
for fam in &lines {
|
||||
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
|
||||
// are not swept at all.
|
||||
if !fam.dashes.is_empty() && !fam.dashes.iter().any(|d| *d >= 0.0) {
|
||||
continue;
|
||||
}
|
||||
let ang = fam.angle.to_radians() + h.angle;
|
||||
let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale;
|
||||
let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale;
|
||||
@@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until(
|
||||
continue;
|
||||
}
|
||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out);
|
||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
||||
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -422,7 +463,7 @@ impl Family<'_> {
|
||||
self.origin + p.rotate(self.ang)
|
||||
}
|
||||
|
||||
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>) {
|
||||
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());
|
||||
if bb.is_empty() {
|
||||
@@ -456,7 +497,7 @@ impl Family<'_> {
|
||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
||||
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
|
||||
for k in k0..=k1 {
|
||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
||||
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||
return;
|
||||
}
|
||||
let y = k as f64 * self.spacing;
|
||||
@@ -484,21 +525,22 @@ impl Family<'_> {
|
||||
if solid_line {
|
||||
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
|
||||
} else {
|
||||
self.dash(x0, x1, y, offset, pat_len, out);
|
||||
self.dash(x0, x1, y, offset, pat_len, out, work);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The dashes and dots of the span `x0..x1` of the line at `y`, phase anchored at x = `offset`.
|
||||
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>) {
|
||||
fn dash(&self, x0: f64, x1: f64, y: f64, offset: f64, pat_len: f64, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
|
||||
let start_rep = ((x0 - offset) / pat_len).floor();
|
||||
let mut x = offset + start_rep * pat_len;
|
||||
let mut guard = 0;
|
||||
'outer: while x < x1 {
|
||||
for d in self.dashes {
|
||||
guard += 1;
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS {
|
||||
*work = work.saturating_sub(1);
|
||||
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
|
||||
break 'outer;
|
||||
}
|
||||
let len = d.abs();
|
||||
@@ -560,3 +602,18 @@ mod tests {
|
||||
assert!((area - 100.0).abs() < 1e-6, "triangulated area must be 100.0, not zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod dedup_budget_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn unequal_loops_do_not_spend_the_comparison_budget() {
|
||||
// The old estimate exhausted the budget on this unequal pair before seeing the triangle.
|
||||
let a = vec![Vec2::ZERO; 5_000];
|
||||
let b = vec![Vec2::ZERO; 5_001];
|
||||
let tri = vec![Vec2::ZERO, Vec2::new(1.0, 0.0), Vec2::new(0.0, 1.0)];
|
||||
let mut loops = vec![a, b, tri.clone(), tri];
|
||||
dedup_polygons(&mut loops);
|
||||
assert_eq!(loops.len(), 3);
|
||||
}
|
||||
}
|
||||
@@ -735,13 +735,17 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
|
||||
let eb = cadcraft_doc::entity_bounds(d, me, 0);
|
||||
eb.is_empty() || eb.intersects(&win)
|
||||
};
|
||||
// One expansion budget for the whole build: seed it with what the enclosing list has already
|
||||
// spent so a viewport's contents cannot start over with a fresh allowance and exceed the
|
||||
// documented limit (CWE-400). Both the serial sub-builder and the parallel chunks share it.
|
||||
let budget = std::sync::atomic::AtomicUsize::new(b.list.expanded);
|
||||
let mut sub = Builder {
|
||||
list: DisplayList::default(),
|
||||
opts: b.opts,
|
||||
plotting: b.plotting,
|
||||
styles: b.styles,
|
||||
named: None,
|
||||
shared_budget: None,
|
||||
shared_budget: Some(&budget),
|
||||
transparency: 0,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -754,7 +758,6 @@ fn contents(b: &mut Builder, d: &Drawing, vp: &cadcraft_doc::Viewport, area: Bou
|
||||
// As `build_parallel`: chunks built side by side, joined in drawing order.
|
||||
use rayon::prelude::*;
|
||||
let ents: Vec<&std::sync::Arc<Entity>> = d.model.iter().collect();
|
||||
let budget = std::sync::atomic::AtomicUsize::new(0);
|
||||
let opts = b.opts;
|
||||
let parts: Vec<DisplayList> = ents
|
||||
.par_chunks(PARALLEL_CHUNK)
|
||||
|
||||
@@ -94,8 +94,16 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut pos = 0.0;
|
||||
// Gap-only patterns draw nothing, matching `apply`, without walking every repeat.
|
||||
if !lt.pattern.iter().any(|d| d.length >= 0.0) {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
// Bound the pattern elements visited, independent of emitted ranges, so a pattern that emits
|
||||
// little still cannot cycle hundreds of millions of times.
|
||||
let mut visits: u64 = 0;
|
||||
for d in lt.pattern.iter().map(|d| d.length * scale).cycle() {
|
||||
if pos >= len || out.len() > 2_000_000 {
|
||||
visits += 1;
|
||||
if pos >= len || out.len() > 2_000_000 || visits > 4_000_000 {
|
||||
break;
|
||||
}
|
||||
if d >= 0.0 {
|
||||
@@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_all_gap_pattern_draws_nothing_without_spinning() {
|
||||
let lt = Linetype::simple("GAPS", "", &[-0.001, -0.002]);
|
||||
assert_eq!(ranges(1.0, <, 1.0, 0.0), Some(Vec::new()));
|
||||
assert!(apply(&[Vec2::ZERO, Vec2::new(1.0, 0.0)], <, 1.0, 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dashes_split_line() {
|
||||
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
|
||||
|
||||
+70
-16
@@ -53,17 +53,30 @@ pub struct TableGeom {
|
||||
|
||||
/// Which cells are covered by a merge (and so not drawn on their own).
|
||||
pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
|
||||
coverage(t).unwrap_or_else(|partial| partial)
|
||||
}
|
||||
|
||||
// No partial coverage may reach owner assignment: it would revisit unbounded merge rectangles.
|
||||
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);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
|
||||
return vec![Vec::new(); rows];
|
||||
return Err(vec![Vec::new(); rows]);
|
||||
}
|
||||
let mut cov = vec![vec![false; cols]; rows];
|
||||
// Coverage marking visits each merge's whole rectangle. Overlapping merges re-mark cells that
|
||||
// are already covered, so bound the total marking steps to a small multiple of the cell count
|
||||
// rather than letting overlapping declarations cause quadratic work (CWE-407).
|
||||
let mut budget: u64 = (rows as u64).saturating_mul(cols as u64).saturating_mul(4).saturating_add(1024);
|
||||
for (r, row) in t.cells.iter().enumerate().take(rows) {
|
||||
for (c, cell) in row.iter().enumerate().take(cols) {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
for rr in r..(r + rs.max(1) as usize).min(rows) {
|
||||
for cc in c..(c + cs.max(1) as usize).min(cols) {
|
||||
budget = budget.saturating_sub(1);
|
||||
if budget == 0 {
|
||||
return Err(cov);
|
||||
}
|
||||
if (rr, cc) != (r, c)
|
||||
&& let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc))
|
||||
{
|
||||
@@ -74,7 +87,7 @@ pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
|
||||
}
|
||||
}
|
||||
}
|
||||
cov
|
||||
Ok(cov)
|
||||
}
|
||||
|
||||
/// The table style a table uses (Standard defaults when missing).
|
||||
@@ -134,19 +147,25 @@ pub fn table_geometry(d: &Drawing, t: &Table) -> TableGeom {
|
||||
/// One part of a broken table as a table of its own: its rows (with their cell styles) at
|
||||
/// `origin`; merges are cut at the part's edge.
|
||||
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||
let cols = t.col_widths.len();
|
||||
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect();
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
let cols = t.col_widths.len().min(10_000);
|
||||
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> =
|
||||
rows.iter().take(10_000).map(|r| t.cells.get(*r).map(|row| row.iter().take(cols).cloned().collect()).unwrap_or_default()).collect();
|
||||
// Only expand sparse rows to the full column count when detailed layout is actually permitted.
|
||||
// Otherwise `layout` renders an outline, so materialising millions of empty cells here would be
|
||||
// wasted work an attacker can trigger with a compact dimension array (CWE-789).
|
||||
if rows.len().min(10_000).saturating_mul(cols) <= MAX_LAYOUT_CELLS {
|
||||
for (i, row) in cells.iter_mut().enumerate() {
|
||||
row.resize_with(cols, Default::default);
|
||||
for cell in row.iter_mut() {
|
||||
if let Some((rs, cs)) = cell.merged {
|
||||
// Rows of the merge that follow in this part.
|
||||
let start = rows.get(i).copied().unwrap_or(0);
|
||||
let mut n = 1u32;
|
||||
while n < rs && rows.get(i + n as usize).is_some_and(|r| *r == start + n as usize) {
|
||||
n += 1;
|
||||
}
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
cell.merged = (n > 1 || cs > 1).then_some((n, cs));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
if rows == 0 || cols == 0 {
|
||||
return g;
|
||||
}
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
|
||||
let cov = coverage(t);
|
||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS || cov.is_err() {
|
||||
let (w, h) = (x_at(cols), y_at(rows));
|
||||
let corners = [p(0.0, 0.0), p(w, 0.0), p(w, h), p(0.0, h), p(0.0, 0.0)];
|
||||
for s in corners.windows(2) {
|
||||
@@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
||||
i
|
||||
};
|
||||
// Anchors and the anchor owning each grid cell.
|
||||
let cov = table_covered(t);
|
||||
let cov = cov.unwrap_or_default();
|
||||
let mut anchors: Vec<Anchor> = Vec::new();
|
||||
let mut owner = vec![usize::MAX; rows * cols];
|
||||
for r in 0..rows {
|
||||
@@ -369,3 +389,37 @@ pub(crate) fn draw(b: &mut Builder, ctx: &Ctx, t: &Table, rgb: Ink, lw: f32) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod budget_tests {
|
||||
use super::*;
|
||||
use cadcraft_doc::TableCell;
|
||||
|
||||
#[test]
|
||||
fn overlapping_merges_use_outline_fallback() {
|
||||
let t = Table {
|
||||
row_heights: vec![1.0; 40],
|
||||
col_widths: vec![1.0; 40],
|
||||
cells: vec![vec![TableCell { merged: Some((40, 40)), ..Default::default() }; 40]; 40],
|
||||
..Default::default()
|
||||
};
|
||||
assert!(coverage(&t).is_err());
|
||||
let g = layout(&Drawing::default(), &t);
|
||||
assert_eq!(g.lines.len(), 4);
|
||||
assert!(g.fills.is_empty() && g.texts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn part_clips_merges_using_displayed_column_count() {
|
||||
let t = Table {
|
||||
row_heights: vec![1.0; 102],
|
||||
col_widths: vec![1.0; 10_001],
|
||||
cells: vec![vec![TableCell { merged: Some((100, 1)), ..Default::default() }]],
|
||||
..Default::default()
|
||||
};
|
||||
let mut rows: Vec<_> = (0..99).collect();
|
||||
rows.push(101);
|
||||
let part = part_table(&t, &rows, Vec2::ZERO);
|
||||
assert_eq!(part.cells[0][0].merged, Some((99, 1)));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ egui-wgpu = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
image = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -111,23 +111,36 @@ impl CadApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC).
|
||||
/// `secs` since 1970 as `YYYY-MM-DD HH:MM` (UTC). Past chrono's year 262143 range the proleptic
|
||||
/// Gregorian date is kept via the 400-year cycle rather than falling back to the epoch.
|
||||
fn utc(secs: u64) -> String {
|
||||
let days = (secs / 86_400) as i64;
|
||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let mo = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = yoe + era * 400 + i64::from(mo <= 2);
|
||||
let (y, mo, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// A recovery file's mtime can in principle land past `NaiveDate`'s ±262143-year range; the old
|
||||
/// hand-rolled converter still produced a date there, so falling back to the epoch would change
|
||||
/// the shown time. The Gregorian calendar repeats every 400 years (146_097 days), so chrono
|
||||
/// converts the day within one cycle and whole cycles go back into the year (an `i128`, then
|
||||
/// saturated into `i64`), keeping chrono as the only calendar logic and reproducing the old
|
||||
/// result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
/// The Drawing Recovery window, while autosave files from a crash are waiting.
|
||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||
if app.recovery.items.is_empty() {
|
||||
@@ -233,5 +246,9 @@ mod tests {
|
||||
assert_eq!(super::utc(0), "1970-01-01 00:00");
|
||||
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01");
|
||||
assert_eq!(super::utc(1_791_676_800), "2026-10-11 00:00");
|
||||
assert_eq!(super::utc(253_402_300_800), "10000-01-01 00:00");
|
||||
// Past chrono's range the proleptic Gregorian date is kept, not the epoch (old output).
|
||||
assert_eq!(super::utc(10_000_000_000_000), "318857-05-20 17:46");
|
||||
assert_eq!(super::utc(u64::MAX), "584554051223-11-09 07:00");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,10 +5,18 @@ Start the desktop app with `--control PORT` (or `CADCRAFT_CONTROL_PORT=PORT`). I
|
||||
`--control` wins over the variable; a bare `--control` keeps the variable's port.
|
||||
|
||||
```json
|
||||
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}}
|
||||
{"id": 1, "method": "cmdline.input", "params": {"text": "circle 0,0 5"}, "token": "<session token>"}
|
||||
{"id": 1, "ok": true, "result": {"prompt": "Command:", "output": ["Command: CIRCLE"], ...}}
|
||||
```
|
||||
|
||||
Every request must carry the session `token`. Because loopback TCP cannot tell local operating
|
||||
system users apart, the app writes an unguessable per-session token to an owner-only file when the
|
||||
control server starts, and refuses (`unauthorized`) any request without a matching token. The file
|
||||
is `cadcraft-control-<port>.token` in the per-user runtime directory (`$XDG_RUNTIME_DIR`, else
|
||||
`~/.cache`; `%LOCALAPPDATA%` on Windows); its path is logged at startup. A client reads the token
|
||||
from that file — which requires the desktop user's own filesystem access — and includes it on each
|
||||
line. The MCP bridge (`cadcraft-cli mcp --connect`) does this automatically.
|
||||
|
||||
Errors come back as `{"id": …, "ok": false, "error": "message"}`. An unknown method's error points
|
||||
to `control.methods`, which lists the methods the server answers.
|
||||
|
||||
|
||||
@@ -18,11 +18,16 @@ if [ -z "${APPLE_CERTIFICATE:-}" ]; then
|
||||
fi
|
||||
|
||||
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
||||
# Create a private per-run directory (mode 0700 via the umask above) so the decoded signing
|
||||
# container cannot be read by, or pre-created by, another local user in a shared temp directory.
|
||||
CERT_DIR="$(mktemp -d "$TMP/cadcraft-signing.XXXXXXXX")"
|
||||
KEYCHAIN="$TMP/cadcraft-signing.keychain-db"
|
||||
CERT="$TMP/cadcraft-signing.p12"
|
||||
CERT="$CERT_DIR/cadcraft-signing.p12"
|
||||
KC_PASS="${KEYCHAIN_PASSWORD:-$(openssl rand -hex 24)}"
|
||||
|
||||
trap 'rm -f "$CERT"' EXIT
|
||||
trap 'rm -f "$CERT"; rm -rf "$CERT_DIR"' EXIT
|
||||
# Create the certificate file privately before writing, refusing to follow a pre-existing entry.
|
||||
( set -C; : >"$CERT" )
|
||||
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
|
||||
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||
|
||||
@@ -7,3 +7,4 @@ publish = false
|
||||
|
||||
[dependencies]
|
||||
serde_json = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
+40
-18
@@ -28,25 +28,39 @@ pub fn leaves(catalog: &str) -> Vec<String> {
|
||||
/// Where the generated checklist goes.
|
||||
pub const OUT: &str = "docs/parity-checklist.md";
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (from the system clock; no date crate needed).
|
||||
/// Today's UTC date as `YYYY-MM-DD` (from the system clock).
|
||||
fn today() -> String {
|
||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
||||
date(std::time::SystemTime::now())
|
||||
}
|
||||
|
||||
/// `t` as a UTC `YYYY-MM-DD`. A clock before 1970 reads as 1970-01-01 (the `unwrap_or(0)` clamp);
|
||||
/// past chrono's year 262143 range the proleptic Gregorian date is kept via the 400-year cycle
|
||||
/// rather than falling back to the epoch.
|
||||
fn date(t: std::time::SystemTime) -> String {
|
||||
let secs = t.duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
||||
let (y, m, d) = civil_from_days(secs / 86_400);
|
||||
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||
format!("{y:04}-{m:02}-{d:02}")
|
||||
}
|
||||
|
||||
/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar.
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||||
(y, m, d)
|
||||
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||
///
|
||||
/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past
|
||||
/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097
|
||||
/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an
|
||||
/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing
|
||||
/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||
use chrono::Datelike;
|
||||
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||
Some(d) => {
|
||||
let y = i128::from(d.year()) + i128::from(cycles) * 400;
|
||||
(i64::try_from(y).unwrap_or(i64::MAX), d.month(), d.day())
|
||||
}
|
||||
None => (1970, 1, 1), // unreachable
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(root: &Path) -> Result<(), String> {
|
||||
@@ -116,9 +130,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn civil_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
||||
assert_eq!(civil_from_days(20_736), (2026, 10, 10));
|
||||
let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400));
|
||||
assert_eq!(day(0), "1970-01-01");
|
||||
assert_eq!(day(11_016), "2000-02-29");
|
||||
assert_eq!(day(20_736), "2026-10-10");
|
||||
assert_eq!(date(std::time::UNIX_EPOCH - std::time::Duration::from_secs(86_400)), "1970-01-01");
|
||||
// Past chrono's range keeps the proleptic Gregorian date, not the epoch (old `civil_from_days`).
|
||||
assert_eq!(day(115_740_740), "318857-05-20");
|
||||
// Windows' SystemTime can't hold u64::MAX / 2 seconds, so it is only checked where it exists.
|
||||
if let Some(far) = std::time::UNIX_EPOCH.checked_add(std::time::Duration::from_secs(u64::MAX / 2)) {
|
||||
assert_eq!(date(far), "292277026596-12-04");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+58
-7
@@ -10,20 +10,41 @@ struct Counts {
|
||||
proptests: usize,
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) {
|
||||
/// Upper bound on directory entries visited in one `walk`, so even a crafted tree of aliases can
|
||||
/// never make the stats command run unbounded work.
|
||||
const MAX_ENTRIES: usize = 2_000_000;
|
||||
|
||||
fn walk(dir: &Path, c: &mut Counts) -> Result<(), String> {
|
||||
let mut budget = MAX_ENTRIES;
|
||||
walk_inner(dir, c, &mut budget)
|
||||
}
|
||||
|
||||
fn walk_inner(dir: &Path, c: &mut Counts, budget: &mut usize) -> Result<(), String> {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
return Ok(());
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
if *budget == 0 {
|
||||
return Err(format!("stats traversal budget exhausted at {}", dir.display()));
|
||||
}
|
||||
*budget -= 1;
|
||||
// Use the entry's own type and never follow symlinks: a directory symlink would otherwise
|
||||
// be re-scanned as a fresh directory, so a small graph of aliases could cause exponential
|
||||
// repeated enumeration and reads (CWE-400).
|
||||
let Ok(ft) = e.file_type() else { continue };
|
||||
if ft.is_symlink() {
|
||||
continue;
|
||||
}
|
||||
let p = e.path();
|
||||
let name = e.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
if p.is_dir() {
|
||||
if ft.is_dir() {
|
||||
// Nested fuzz workspaces and build output are not part of the crate.
|
||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
||||
walk(&p, c);
|
||||
walk_inner(&p, c, budget)?;
|
||||
}
|
||||
} else if name.ends_with(".rs")
|
||||
} else if ft.is_file()
|
||||
&& name.ends_with(".rs")
|
||||
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||
{
|
||||
c.files += 1;
|
||||
@@ -33,6 +54,7 @@ fn walk(dir: &Path, c: &mut Counts) {
|
||||
c.proptests += pt;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||
@@ -71,7 +93,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
continue;
|
||||
};
|
||||
let mut c = Counts::default();
|
||||
walk(&dir, &mut c);
|
||||
walk(&dir, &mut c)?;
|
||||
let ex = if exact { exact_tests(&name) } else { None };
|
||||
rows.push((name, c, ex));
|
||||
}
|
||||
@@ -108,11 +130,40 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::count_tests;
|
||||
use super::{Counts, count_tests, walk};
|
||||
|
||||
#[test]
|
||||
fn counts_attributes_and_proptest_blocks() {
|
||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||
assert_eq!(count_tests(src), (2, 1));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn walk_does_not_follow_directory_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-xtask-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let real = base.join("real");
|
||||
std::fs::create_dir_all(&real).unwrap();
|
||||
std::fs::write(real.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
// A directory symlink pointing back at `real` would be re-scanned if followed.
|
||||
symlink(&real, base.join("alias")).unwrap();
|
||||
let mut c = Counts::default();
|
||||
walk(&base, &mut c).unwrap();
|
||||
assert_eq!(c.files, 1, "the aliased directory was followed and counted twice");
|
||||
std::fs::remove_dir_all(&base).ok();
|
||||
}
|
||||
#[test]
|
||||
fn walk_reports_budget_exhaustion() {
|
||||
let base = std::env::temp_dir().join(format!("cadcraft-stats-budget-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
std::fs::write(base.join("a.rs"), "fn a() {}\n").unwrap();
|
||||
let error = super::walk_inner(&base, &mut Counts::default(), &mut 0).unwrap_err();
|
||||
assert!(error.contains("budget exhausted"));
|
||||
let mut c = Counts::default();
|
||||
super::walk_inner(&base, &mut c, &mut 1).unwrap();
|
||||
assert_eq!(c.files, 1);
|
||||
std::fs::remove_dir_all(base).unwrap();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user