mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-25 03:16:50 -06:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0931c8b286 | ||
|
|
3a2d3608d3 | ||
|
|
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 |
@@ -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.
|
- **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.**
|
- **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`.
|
- **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`).
|
- **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`.
|
- **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]]
|
[[package]]
|
||||||
name = "cadcraft"
|
name = "cadcraft"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-engine",
|
"cadcraft-engine",
|
||||||
"cadcraft-io",
|
"cadcraft-io",
|
||||||
|
"cadcraft-mcp",
|
||||||
"cadcraft-ui-egui",
|
"cadcraft-ui-egui",
|
||||||
|
"chrono",
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
"hidapi",
|
"hidapi",
|
||||||
@@ -569,7 +571,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-cli"
|
name = "cadcraft-cli"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-engine",
|
"cadcraft-engine",
|
||||||
"cadcraft-io",
|
"cadcraft-io",
|
||||||
@@ -580,14 +582,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-color"
|
name = "cadcraft-color"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-constraints"
|
name = "cadcraft-constraints"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-doc",
|
"cadcraft-doc",
|
||||||
"cadcraft-geom",
|
"cadcraft-geom",
|
||||||
@@ -597,7 +599,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-doc"
|
name = "cadcraft-doc"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-color",
|
"cadcraft-color",
|
||||||
"cadcraft-geom",
|
"cadcraft-geom",
|
||||||
@@ -608,14 +610,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-dwg"
|
name = "cadcraft-dwg"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"acadrust",
|
"acadrust",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-dxf"
|
name = "cadcraft-dxf"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"log",
|
"log",
|
||||||
@@ -624,7 +626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-engine"
|
name = "cadcraft-engine"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-color",
|
"cadcraft-color",
|
||||||
"cadcraft-constraints",
|
"cadcraft-constraints",
|
||||||
@@ -635,6 +637,7 @@ dependencies = [
|
|||||||
"cadcraft-io",
|
"cadcraft-io",
|
||||||
"cadcraft-render",
|
"cadcraft-render",
|
||||||
"craft-docpath",
|
"craft-docpath",
|
||||||
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"rstar",
|
"rstar",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -644,7 +647,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-fonts"
|
name = "cadcraft-fonts"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-geom",
|
"cadcraft-geom",
|
||||||
"harfrust",
|
"harfrust",
|
||||||
@@ -654,14 +657,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-geom"
|
name = "cadcraft-geom"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-io"
|
name = "cadcraft-io"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"acadrust",
|
"acadrust",
|
||||||
"cadcraft-color",
|
"cadcraft-color",
|
||||||
@@ -681,18 +684,19 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-mcp"
|
name = "cadcraft-mcp"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-engine",
|
"cadcraft-engine",
|
||||||
"cadcraft-io",
|
"cadcraft-io",
|
||||||
"cadcraft-render",
|
"cadcraft-render",
|
||||||
|
"getrandom 0.3.4",
|
||||||
"log",
|
"log",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-render"
|
name = "cadcraft-render"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-color",
|
"cadcraft-color",
|
||||||
"cadcraft-doc",
|
"cadcraft-doc",
|
||||||
@@ -706,7 +710,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-ui-egui"
|
name = "cadcraft-ui-egui"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-color",
|
"cadcraft-color",
|
||||||
"cadcraft-doc",
|
"cadcraft-doc",
|
||||||
@@ -714,6 +718,7 @@ dependencies = [
|
|||||||
"cadcraft-fonts",
|
"cadcraft-fonts",
|
||||||
"cadcraft-geom",
|
"cadcraft-geom",
|
||||||
"cadcraft-render",
|
"cadcraft-render",
|
||||||
|
"chrono",
|
||||||
"craft-ui",
|
"craft-ui",
|
||||||
"egui",
|
"egui",
|
||||||
"egui-wgpu",
|
"egui-wgpu",
|
||||||
@@ -728,7 +733,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cadcraft-web"
|
name = "cadcraft-web"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cadcraft-engine",
|
"cadcraft-engine",
|
||||||
"cadcraft-io",
|
"cadcraft-io",
|
||||||
@@ -825,6 +830,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "chrono"
|
||||||
|
version = "0.4.45"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "clipboard-win"
|
name = "clipboard-win"
|
||||||
version = "5.4.1"
|
version = "5.4.1"
|
||||||
@@ -4811,8 +4825,9 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
# default-members
|
# default-members
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.10.0"
|
version = "0.11.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.90"
|
rust-version = "1.90"
|
||||||
@@ -32,6 +32,8 @@ thiserror = "2"
|
|||||||
serde = { version = "1", features = ["derive", "rc"] }
|
serde = { version = "1", features = ["derive", "rc"] }
|
||||||
serde_json = { version = "1", features = ["float_roundtrip"] }
|
serde_json = { version = "1", features = ["float_roundtrip"] }
|
||||||
log = "0.4"
|
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"
|
rayon = "1"
|
||||||
|
|
||||||
# rendering / formats
|
# rendering / formats
|
||||||
|
|||||||
@@ -7,6 +7,10 @@
|
|||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
<h1 align="center">CADCraft</h1>
|
||||||
|
|
||||||
<p align="center">
|
<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.
|
**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
|
### Windows
|
||||||
|
|
||||||
| Build | Installer | Portable |
|
| Build | Installer | Portable |
|
||||||
|
|||||||
@@ -14,10 +14,13 @@ workspace = true
|
|||||||
cadcraft-engine = { workspace = true }
|
cadcraft-engine = { workspace = true }
|
||||||
cadcraft-ui-egui = { workspace = true }
|
cadcraft-ui-egui = { workspace = true }
|
||||||
cadcraft-io = { 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"] }
|
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11", "links", "persistence"] }
|
||||||
egui = { workspace = true }
|
egui = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
winresource = "0.1"
|
winresource = "0.1"
|
||||||
|
|||||||
@@ -51,8 +51,21 @@ impl Default for Limits {
|
|||||||
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
pub fn start(port: u16, ctx: egui::Context) -> Receiver<ControlRequest> {
|
||||||
match TcpListener::bind(("127.0.0.1", port)) {
|
match TcpListener::bind(("127.0.0.1", port)) {
|
||||||
Ok(l) => {
|
Ok(l) => {
|
||||||
log::info!("control server listening on 127.0.0.1:{port}");
|
// Loopback TCP cannot tell local users apart, so issue an unguessable per-session
|
||||||
serve_listener(l, Limits::default(), ctx)
|
// 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) => {
|
Err(e) => {
|
||||||
log::error!("control server failed to bind 127.0.0.1:{port}: {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.
|
/// 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));
|
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
|
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));
|
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));
|
let _ = stream.set_write_timeout(Some(limits.write_timeout));
|
||||||
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
if open.fetch_add(1, Ordering::SeqCst) >= limits.max_clients {
|
||||||
open.fetch_sub(1, Ordering::SeqCst);
|
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 slot = Slot(Arc::clone(&open));
|
||||||
let tx = tx.clone();
|
let tx = tx.clone();
|
||||||
let ctx = ctx.clone();
|
let ctx = ctx.clone();
|
||||||
|
let token = Arc::clone(&token);
|
||||||
let _ = std::thread::Builder::new().spawn(move || {
|
let _ = std::thread::Builder::new().spawn(move || {
|
||||||
let _slot = slot;
|
let _slot = slot;
|
||||||
serve(stream, &tx, &ctx, limits);
|
serve(stream, &tx, &ctx, limits, &token);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +155,7 @@ enum Line {
|
|||||||
id: Value,
|
id: Value,
|
||||||
method: String,
|
method: String,
|
||||||
params: Value,
|
params: Value,
|
||||||
|
token: Option<String>,
|
||||||
},
|
},
|
||||||
Blank,
|
Blank,
|
||||||
/// Not a request: reply with this error and close the connection.
|
/// 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),
|
id: obj.get("id").cloned().unwrap_or(Value::Null),
|
||||||
method: method.to_string(),
|
method: method.to_string(),
|
||||||
params: obj.get("params").cloned().unwrap_or(json!({})),
|
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/"))
|
|| 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() {
|
if stream.set_read_timeout(Some(limits.idle_timeout)).is_err() || stream.set_write_timeout(Some(limits.write_timeout)).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,7 +216,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
|||||||
Ok(true) => match parse_line(&buf) {
|
Ok(true) => match parse_line(&buf) {
|
||||||
Line::Blank => continue,
|
Line::Blank => continue,
|
||||||
Line::Reject(e) => e,
|
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 (req, pending) = ControlRequest::new(method, params);
|
||||||
let mut r = match tx.try_send(req) {
|
let mut r = match tx.try_send(req) {
|
||||||
Ok(()) => {
|
Ok(()) => {
|
||||||
@@ -222,6 +259,18 @@ fn serve(stream: TcpStream, tx: &SyncSender<ControlRequest>, ctx: &egui::Context
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn lines_are_read_up_to_the_limit() {
|
fn lines_are_read_up_to_the_limit() {
|
||||||
let mut buf = Vec::new();
|
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.
|
/// The control server on a loopback port under `limits`; requests arrive on the receiver.
|
||||||
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
fn serve_with(limits: Limits) -> (std::net::SocketAddr, Receiver<ControlRequest>) {
|
||||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||||
let addr = listener.local_addr().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.
|
/// The control server on a loopback port, with a stand-in UI that echoes each method.
|
||||||
@@ -273,6 +325,11 @@ mod tests {
|
|||||||
addr
|
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.
|
/// 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> {
|
fn exchange(addr: std::net::SocketAddr, input: &[u8]) -> Vec<Value> {
|
||||||
let mut c = TcpStream::connect(addr).unwrap();
|
let mut c = TcpStream::connect(addr).unwrap();
|
||||||
@@ -332,12 +389,12 @@ mod tests {
|
|||||||
fn a_full_queue_answers_busy() {
|
fn a_full_queue_answers_busy() {
|
||||||
let (addr, rx) = serve_with(short());
|
let (addr, rx) = serve_with(short());
|
||||||
// No UI drains the one-place queue: the first request fills it, the second finds it full.
|
// 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 deadline = std::time::Instant::now() + Duration::from_secs(4);
|
||||||
let busy = loop {
|
let busy = loop {
|
||||||
let mut c = TcpStream::connect(addr).unwrap();
|
let mut c = TcpStream::connect(addr).unwrap();
|
||||||
c.set_read_timeout(Some(Duration::from_secs(5))).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();
|
let mut line = String::new();
|
||||||
BufReader::new(c).read_line(&mut line).unwrap();
|
BufReader::new(c).read_line(&mut line).unwrap();
|
||||||
let v: Value = serde_json::from_str(&line).unwrap();
|
let v: Value = serde_json::from_str(&line).unwrap();
|
||||||
@@ -355,7 +412,8 @@ mod tests {
|
|||||||
let addr = serve_echo();
|
let addr = serve_echo();
|
||||||
let mut c = TcpStream::connect(addr).unwrap();
|
let mut c = TcpStream::connect(addr).unwrap();
|
||||||
c.set_read_timeout(Some(Duration::from_secs(5))).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);
|
let mut r = BufReader::new(c);
|
||||||
for (id, method) in [(3, "ping"), (4, "pong")] {
|
for (id, method) in [(3, "ping"), (4, "pong")] {
|
||||||
let mut line = String::new();
|
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]
|
#[test]
|
||||||
fn extra_connections_are_refused() {
|
fn extra_connections_are_refused() {
|
||||||
let addr = serve_echo();
|
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 {
|
pub fn timestamp(t: SystemTime) -> String {
|
||||||
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
let since = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
|
||||||
let secs = since.as_secs();
|
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())
|
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
|
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||||
/// `civil_from_days`, unsigned because `timestamp` clamps at the epoch; saturating so an absurd
|
///
|
||||||
/// clock can't overflow).
|
/// `timestamp` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run
|
||||||
fn civil_from_days(days: u64) -> (u64, u64, u64) {
|
/// past `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years
|
||||||
let z = days.saturating_add(719_468);
|
/// (146_097 days), so chrono converts the day within one cycle and whole cycles go back into the
|
||||||
let era = z / 146_097;
|
/// year (an `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and
|
||||||
let doe = z % 146_097;
|
/// reproducing the old saturating `civil_from_days` result exactly without hand-rolled day math.
|
||||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
/// 1970-01-01 is `num_days_from_ce` 719_163.
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||||
let mp = (5 * doy + 2) / 153;
|
use chrono::Datelike;
|
||||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||||
let y = era.saturating_mul(400).saturating_add(yoe).saturating_add(u64::from(m <= 2));
|
match i32::try_from(rest + 719_163).ok().and_then(chrono::NaiveDate::from_num_days_from_ce_opt) {
|
||||||
(y, m, d)
|
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 {
|
fn numbered(dir: &Path, n: usize) -> PathBuf {
|
||||||
@@ -385,8 +393,20 @@ mod tests {
|
|||||||
// 2000 is a leap year (divisible by 400), 2100 is not.
|
// 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(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");
|
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");
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -102,7 +102,11 @@ fn ang_between(d1: Vec2, d2: Vec2) -> f64 {
|
|||||||
pub fn infer(d: &Drawing, handles: &[Handle], o: &InferOptions) -> Vec<Constraint> {
|
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 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 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 g = collect(d, handles);
|
||||||
let mut out: Vec<Constraint> = Vec::new();
|
let mut out: Vec<Constraint> = Vec::new();
|
||||||
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
|
let mut push = |kind: ConstraintKind, refs: Vec<GeomRef>| {
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ use cadcraft_doc::{Constraint, Drawing, Handle};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::model::Model;
|
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).
|
/// Most free parameters for which per-object freedom is analysed (null-space basis size).
|
||||||
const MAX_NULL_COLS: usize = 600;
|
const MAX_NULL_COLS: usize = 600;
|
||||||
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
/// Largest Jacobian (rows × columns × min) whose rank is computed.
|
||||||
const MAX_RANK_WORK: f64 = 4e8;
|
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.
|
/// Most objects listed in one report.
|
||||||
const MAX_OBJECTS: usize = 10_000;
|
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 {
|
if rows > lm::MAX_ROWS || (rows as f64) * (nf as f64) * (rows.min(nf) as f64) > MAX_RANK_WORK {
|
||||||
return unknown;
|
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 f = |x: &[f64], out: &mut Vec<f64>| comp.eval(x, out, None);
|
||||||
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
|
let jac = lm::jacobian(&f, &comp.model.x0, &free, rows);
|
||||||
if nf > MAX_NULL_COLS {
|
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.
|
/// (constrained or not) and the constraint components they belong to.
|
||||||
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
||||||
let cons: Vec<&Constraint> = d.constraints.iter().take(MAX_CONSTRAINTS).collect();
|
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 want = |h: Handle| handles.is_empty() || handles.contains(&h);
|
||||||
let mut objects = Vec::new();
|
let mut objects = Vec::new();
|
||||||
let mut seen = HashSet::new();
|
let mut seen = HashSet::new();
|
||||||
@@ -165,7 +177,7 @@ pub fn dof(d: &Drawing, handles: &[Handle]) -> Result<DofReport, SolveError> {
|
|||||||
if seen.contains(&h) {
|
if seen.contains(&h) {
|
||||||
continue;
|
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 };
|
let Some(s) = m.slots.first() else { continue };
|
||||||
seen.insert(h);
|
seen.insert(h);
|
||||||
free_objects = true;
|
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 })
|
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.
|
/// 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> {
|
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 e = env(d);
|
||||||
let mut skipped = Vec::new();
|
let mut skipped = Vec::new();
|
||||||
let mut comps = Vec::new();
|
let mut comps = Vec::new();
|
||||||
for group in components(cons) {
|
for group in components(cons) {
|
||||||
let handles: Vec<Handle> = group.iter().flat_map(|c| c.refs.iter().map(|r| r.handle)).collect();
|
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();
|
let mut built = Vec::new();
|
||||||
for c in group {
|
for c in group {
|
||||||
let target = if c.kind.is_dimensional() { constraint_value(&mut e, c)? } else { 0.0 };
|
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;
|
pub const MAX_SPLINE_POINTS: usize = 500;
|
||||||
/// Most characteristic points of a block reference used as rigid constraint points.
|
/// Most characteristic points of a block reference used as rigid constraint points.
|
||||||
pub const MAX_RIGID_POINTS: usize = 256;
|
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)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
pub(crate) enum Shape {
|
pub(crate) enum Shape {
|
||||||
@@ -74,6 +79,21 @@ pub(crate) struct Model {
|
|||||||
rigid: HashMap<Handle, Vec<Vec2>>,
|
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?
|
/// Can this entity take part in constraints?
|
||||||
pub fn supported(kind: &EntityKind) -> bool {
|
pub fn supported(kind: &EntityKind) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -93,13 +113,24 @@ pub fn supported(kind: &EntityKind) -> bool {
|
|||||||
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
/// centres, vertices, nested insertion points) of the block's own objects, at most
|
||||||
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
/// [`MAX_RIGID_POINTS`]. Nested blocks are not entered.
|
||||||
pub fn rigid_points(d: &Drawing, ins: &Insert) -> Vec<Vec2> {
|
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 Some(block) = d.block(&ins.block) else { return Vec::new() };
|
||||||
let m = ins.transform(block.base.xy());
|
let m = ins.transform(block.base.xy());
|
||||||
let mut out = Vec::new();
|
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() {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
*members_left -= 1;
|
||||||
let pts: Vec<Vec2> = match &e.kind {
|
let pts: Vec<Vec2> = match &e.kind {
|
||||||
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
|
EntityKind::Line(l) => vec![l.a.xy(), l.b.xy()],
|
||||||
EntityKind::Arc(a) => {
|
EntityKind::Arc(a) => {
|
||||||
@@ -152,6 +183,11 @@ fn fit_mode(s: &GSpline) -> bool {
|
|||||||
impl Model {
|
impl Model {
|
||||||
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
/// Build a model for the given entities. Unsupported or missing entities are skipped.
|
||||||
pub fn build(d: &Drawing, handles: &[Handle]) -> Model {
|
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();
|
let mut m = Model::default();
|
||||||
for &h in handles {
|
for &h in handles {
|
||||||
if m.index.contains_key(&h) {
|
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
|
// fit point; a control polygon's ends are its first and last points when the
|
||||||
// knots are clamped.
|
// knots are clamped.
|
||||||
let ends = !sp.closed && (fit || (clamped(sp, false) && clamped(sp, true)));
|
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 }
|
Shape::Spline { n, fit, ends }
|
||||||
}
|
}
|
||||||
EntityKind::Insert(ins) => {
|
EntityKind::Insert(ins) => {
|
||||||
let base = Vec2::new(ins.insert.x, ins.insert.y);
|
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]);
|
m.x0.extend([ins.insert.x, ins.insert.y, ins.rotation]);
|
||||||
let n = pts.len();
|
let n = pts.len();
|
||||||
m.rigid.insert(h, pts);
|
m.rigid.insert(h, pts);
|
||||||
@@ -573,3 +612,58 @@ impl Model {
|
|||||||
moved
|
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 {
|
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
|
/// 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.
|
/// 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> {
|
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, &hs);
|
||||||
let _ = dof(&d, &[Handle(0), Handle(u64::MAX)]);
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
let Some(items) = self.items() else { 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;
|
let mut source: Option<&str> = None;
|
||||||
for (it, e) in items.iter().zip(&ents) {
|
for (it, e) in items.iter().zip(&ents) {
|
||||||
let EntityKind::Insert(ins) = &e.kind else { return false };
|
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),
|
Some(r) => r.block.clone_from(&ins.block),
|
||||||
None => match source {
|
None => match source {
|
||||||
None => source = Some(&ins.block),
|
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 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));
|
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
|
/// 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) {
|
if !(v.x.is_finite() && v.y.is_finite()) || (v.x == 0.0 && v.y == 0.0) {
|
||||||
continue;
|
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 {
|
for h in &a.selection {
|
||||||
|
if !handled.insert(*h) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if let Some(node) = param_of.get(h) {
|
if let Some(node) = param_of.get(h) {
|
||||||
// A parameter in the selection is carried along: its points in the frame. Which
|
// 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
|
// points are in it is decided where the dynamic block has them (AutoCAD evaluates
|
||||||
@@ -489,7 +497,7 @@ fn apply(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let Some(c) = copy_of(h) else { 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));
|
moves.entry(c).or_default().push((idx, v));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1408,9 +1408,12 @@ impl EntityKind {
|
|||||||
EntityKind::LwPolyline(p) => {
|
EntityKind::LwPolyline(p) => {
|
||||||
let mut g: Vec<Vec2> = Vec::new();
|
let mut g: Vec<Vec2> = Vec::new();
|
||||||
let pl = Polyline { vertices: p.vertices.clone(), closed: p.closed };
|
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() {
|
for (i, v) in p.vertices.iter().enumerate() {
|
||||||
g.push(v.p);
|
g.push(v.p);
|
||||||
if let Some(s) = pl.segments().get(i) {
|
if let Some(s) = segs.get(i) {
|
||||||
g.push(s.mid());
|
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.
|
// 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 });
|
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))
|
if let Some(blk) = dm.block.as_ref().and_then(|n| d.block(n))
|
||||||
&& depth < MAX_BLOCK_DEPTH
|
&& depth < MAX_BLOCK_DEPTH
|
||||||
{
|
{
|
||||||
|
// 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)) {
|
for be in blk.entities.iter().filter(|be| in_block_visible(d, be)) {
|
||||||
b = b.union(&bounds_in(d, be, depth + 1, blocks));
|
inner = inner.union(&bounds_in(d, be, depth + 1, blocks));
|
||||||
}
|
}
|
||||||
|
blocks.insert((blk.name.as_str(), depth), inner);
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
};
|
||||||
|
b = b.union(&inner);
|
||||||
}
|
}
|
||||||
b
|
b
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -752,6 +752,8 @@ pub struct PageSetup {
|
|||||||
pub landscape: bool,
|
pub landscape: bool,
|
||||||
/// "layout" | "extents" | "display" | "window" | "limits".
|
/// "layout" | "extents" | "display" | "window" | "limits".
|
||||||
pub plot_area: String,
|
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,
|
pub scale_to_fit: bool,
|
||||||
/// Paper units per drawing unit.
|
/// Paper units per drawing unit.
|
||||||
pub scale: f64,
|
pub scale: f64,
|
||||||
@@ -779,6 +781,7 @@ impl Default for PageSetup {
|
|||||||
margins_mm: [6.35, 6.35, 6.35, 6.35],
|
margins_mm: [6.35, 6.35, 6.35, 6.35],
|
||||||
landscape: true,
|
landscape: true,
|
||||||
plot_area: "layout".into(),
|
plot_area: "layout".into(),
|
||||||
|
plot_window: [0.0; 4],
|
||||||
scale_to_fit: false,
|
scale_to_fit: false,
|
||||||
scale: 1.0,
|
scale: 1.0,
|
||||||
plot_style_table: String::new(),
|
plot_style_table: String::new(),
|
||||||
|
|||||||
@@ -494,6 +494,16 @@ 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> {
|
fn convert(dxf: &[u8], version: Option<acadrust::DxfVersion>) -> Result<Vec<u8>, String> {
|
||||||
let data = dxf.to_vec();
|
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()))
|
let mut doc = std::panic::catch_unwind(move || acadrust::DxfReader::from_reader(Cursor::new(data)).and_then(|r| r.read()))
|
||||||
@@ -512,6 +522,7 @@ mod native {
|
|||||||
dimension_flags(&mut doc, true);
|
dimension_flags(&mut doc, true);
|
||||||
drop_unwritable(&mut doc);
|
drop_unwritable(&mut doc);
|
||||||
current_dimstyle(&mut doc);
|
current_dimstyle(&mut doc);
|
||||||
|
current_text_style(&mut doc);
|
||||||
std::panic::catch_unwind(AssertUnwindSafe(|| acadrust::DwgWriter::write_to_vec(&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(|_| "the DWG writer failed on this drawing".to_string())?
|
||||||
.map_err(|e| format!("DWG write: {e}"))
|
.map_err(|e| format!("DWG write: {e}"))
|
||||||
@@ -585,6 +596,22 @@ mod tests {
|
|||||||
assert_eq!(version(b"AC1018xxxx").as_deref(), Some("AC1018"));
|
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]
|
#[test]
|
||||||
fn garbage_is_an_error_not_a_crash() {
|
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"AC1032 this is not a dwg file at all").is_err());
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ log = { workspace = true }
|
|||||||
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
|
# R-tree spatial index for picking, window selection and object snaps (MIT OR Apache-2.0).
|
||||||
rstar = { version = "0.12", default-features = false }
|
rstar = { version = "0.12", default-features = false }
|
||||||
|
|
||||||
|
[target.'cfg(unix)'.dependencies]
|
||||||
|
libc = "0.2"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
# Real DXF files for the external reference tests (io is a lower layer).
|
# Real DXF files for the external reference tests (io is a lower layer).
|
||||||
cadcraft-io = { workspace = true }
|
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() {
|
if let Some(dir) = Path::new(path).parent() {
|
||||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
let tmp = format!("{path}.tmp");
|
// Create a fresh temporary file exclusively and rename it into place, so a planted
|
||||||
std::fs::write(&tmp, bytes).map_err(|e| e.to_string())?;
|
// `<path>.tmp` symlink in a shared autosave directory cannot redirect the write (CWE-59).
|
||||||
std::fs::rename(&tmp, path).map_err(|e| e.to_string())
|
crate::atomic_write::write_atomic(Path::new(path), &bytes).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Autosaver {
|
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 d = s.doc().ok()?;
|
||||||
let cur = d.header.str("CMLEADERSTYLE", "Standard");
|
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();
|
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.
|
// The style's sizes are the drawn sizes: a multileader style has no scale of its own and
|
||||||
let k = if st.annotative { 1.0 } else { d.header.f64("DIMSCALE", 1.0) };
|
// DIMSCALE (a dimension-style setting) does not apply, as in AutoCAD.
|
||||||
let th = st.text_height * k;
|
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 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 attach = if dir > 0.0 { 4 } else { 6 };
|
||||||
let dogleg = if landing { st.dogleg * k } else { 0.0 };
|
let dogleg = if landing { st.dogleg } else { 0.0 };
|
||||||
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap * k) * dir, 0.0);
|
let tpos = *landing_pt + Vec2::new((dogleg + st.landing_gap) * dir, 0.0);
|
||||||
Some(EntityKind::MLeader(MLeader {
|
Some(EntityKind::MLeader(MLeader {
|
||||||
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
leaders: vec![arrow_pts.iter().map(|p| v3(*p)).collect()],
|
||||||
landing: v3(*landing_pt),
|
landing: v3(*landing_pt),
|
||||||
@@ -451,7 +451,7 @@ fn mleader_kind(s: &Session, pts: &[Vec2], text: &str, landing: bool) -> Option<
|
|||||||
columns: None,
|
columns: None,
|
||||||
}),
|
}),
|
||||||
style: st.name.clone(),
|
style: st.name.clone(),
|
||||||
arrow_size: st.arrow_size * k,
|
arrow_size: st.arrow_size,
|
||||||
spline: false,
|
spline: false,
|
||||||
block: None,
|
block: None,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -142,7 +142,10 @@ fn write_audit_log(s: &Session, report: &crate::audit::Report) {
|
|||||||
let Some(path) = &path_str else { return };
|
let Some(path) = &path_str else { return };
|
||||||
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
|
let adt_path = std::path::PathBuf::from(path).with_extension("adt");
|
||||||
let content = report.format_adt(&title, path_str.as_deref());
|
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());
|
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<()> {
|
fn write_file(cmd: &str, path: &str, bytes: &[u8]) -> Result<()> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let tmp = format!("{path}.cadcraft-tmp");
|
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||||
std::fs::write(&tmp, bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
// `<path>.cadcraft-tmp` symlink cannot redirect the export write (CWE-59).
|
||||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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(crate) use pgp::{specs as alias_specs, user_alias};
|
||||||
pub use template::{qnew_template, set_qnew_template};
|
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?, …}`).
|
/// 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>;
|
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;
|
let path = _path;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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()
|
String::from_utf8_lossy(&bytes).into_owned()
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -265,7 +275,8 @@ pub fn read_file(path: &str) -> std::result::Result<ReadFile, String> {
|
|||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let r = std::panic::catch_unwind(|| {
|
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()))
|
read_bytes(&bytes, path, Some(path.to_string()))
|
||||||
});
|
});
|
||||||
match r {
|
match r {
|
||||||
@@ -332,7 +343,8 @@ fn run_open(s: &mut Session, p: &Value) -> Result<Value> {
|
|||||||
let path = _path;
|
let path = _path;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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()))?;
|
let i = open_bytes(s, &bytes, path, Some(path.to_string()))?;
|
||||||
super::blocks::xref::after_open(s, i);
|
super::blocks::xref::after_open(s, i);
|
||||||
return Ok(opened(s, i, true));
|
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<()> {
|
fn store(path: &str, bytes: &[u8]) -> Result<()> {
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let tmp = format!("{path}.cadcraft-tmp");
|
// Exclusively create a fresh temporary file and rename it over `path`, so a planted
|
||||||
std::fs::write(&tmp, bytes).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
// `<path>.cadcraft-tmp` symlink cannot redirect the write onto another file (CWE-59).
|
||||||
std::fs::rename(&tmp, path).map_err(|e| bad("save", format!("{path}: {e}")))?;
|
crate::atomic_write::write_atomic(std::path::Path::new(path), bytes).map_err(|e| bad("save", format!("{path}: {e}")))
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[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"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
let path = _path;
|
let path = _path;
|
||||||
let tmp = format!("{path}.cadcraft-tmp");
|
// Exclusively create a fresh temporary file and rename it into place so a planted
|
||||||
std::fs::write(&tmp, &bytes).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
// `<path>.cadcraft-tmp` symlink cannot redirect the plot write (CWE-59).
|
||||||
std::fs::rename(&tmp, path).map_err(|e| bad(cmd, format!("{path}: {e}")))?;
|
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 }))
|
Ok(json!({ "path": path, "bytes": bytes.len(), "layout": layout, "pages": pages }))
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
|||||||
@@ -9,9 +9,13 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
pub mod assoc;
|
pub mod assoc;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod atomic_write;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
pub mod autosave;
|
pub mod autosave;
|
||||||
pub mod blockedit;
|
pub mod blockedit;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod capped_read;
|
||||||
pub mod cmd;
|
pub mod cmd;
|
||||||
pub mod doc_path;
|
pub mod doc_path;
|
||||||
mod finite;
|
mod finite;
|
||||||
|
|||||||
@@ -307,11 +307,32 @@ fn chain(a: &[Value], ok: fn(std::cmp::Ordering) -> bool) -> R {
|
|||||||
}
|
}
|
||||||
Ok(Value::t())
|
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 {
|
fn num_eq(a: &[Value]) -> R {
|
||||||
chain(a, |o| o.is_eq())
|
eq_chain(a, true)
|
||||||
}
|
}
|
||||||
fn num_ne(a: &[Value]) -> R {
|
fn num_ne(a: &[Value]) -> R {
|
||||||
chain(a, |o| o.is_ne())
|
eq_chain(a, false)
|
||||||
}
|
}
|
||||||
fn lt(a: &[Value]) -> R {
|
fn lt(a: &[Value]) -> R {
|
||||||
chain(a, |o| o.is_lt())
|
chain(a, |o| o.is_lt())
|
||||||
@@ -658,19 +679,44 @@ fn wcmatch(a: &[Value]) -> R {
|
|||||||
arity(a, 2, 2)?;
|
arity(a, 2, 2)?;
|
||||||
let (s, pat) = (string(&a[0])?, string(&a[1])?);
|
let (s, pat) = (string(&a[0])?, string(&a[1])?);
|
||||||
let text: Vec<char> = s.chars().collect();
|
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 out = vec![Vec::new()];
|
||||||
let mut chars = pat.chars().peekable();
|
let mut chars = pat.chars().peekable();
|
||||||
let mut in_set = false;
|
let mut in_set = false;
|
||||||
while let Some(c) = chars.next() {
|
while let Some(c) = chars.next() {
|
||||||
|
charge_match(budget)?;
|
||||||
match c {
|
match c {
|
||||||
'`' => {
|
'`' => {
|
||||||
if let Some(cur) = out.last_mut() {
|
if let Some(cur) = out.last_mut() {
|
||||||
cur.push('`');
|
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))
|
if let Some(('~', rest)) = pat.split_first().map(|(c, r)| (*c, r))
|
||||||
&& !rest.is_empty()
|
&& !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 `*`.
|
/// Iterative-enough glob with `*` backtracking; `depth` bounds the recursion on `*`. Every step is
|
||||||
fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
/// 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 {
|
if depth > 64 {
|
||||||
return false;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
let (mut t, mut p) = (0usize, 0usize);
|
let (mut t, mut p) = (0usize, 0usize);
|
||||||
while p < pat.len() {
|
while p < pat.len() {
|
||||||
|
charge_match(budget)?;
|
||||||
match pat[p] {
|
match pat[p] {
|
||||||
'*' => {
|
'*' => {
|
||||||
let rest = &pat[p + 1..];
|
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) {
|
if text.get(t) != Some(&lit) {
|
||||||
return false;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
t += 1;
|
t += 1;
|
||||||
p += 2;
|
p += 2;
|
||||||
}
|
}
|
||||||
'[' => {
|
'[' => {
|
||||||
let Some(close) = pat[p..].iter().position(|c| *c == ']').map(|i| p + i) else { return false };
|
let mut close = p;
|
||||||
let Some(&c) = text.get(t) else { return false };
|
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 set = &pat[p + 1..close];
|
||||||
let (neg, set) = match set.split_first() {
|
let (neg, set) = match set.split_first() {
|
||||||
Some(('~', r)) if !r.is_empty() => (true, r),
|
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 hit = false;
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < set.len() {
|
while i < set.len() {
|
||||||
|
charge_match(budget)?;
|
||||||
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
if set.get(i + 1) == Some(&'-') && i + 2 < set.len() {
|
||||||
if set[i] <= c && c <= set[i + 2] {
|
if set[i] <= c && c <= set[i + 2] {
|
||||||
hit = true;
|
hit = true;
|
||||||
@@ -749,13 +848,13 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if hit == neg {
|
if hit == neg {
|
||||||
return false;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
t += 1;
|
t += 1;
|
||||||
p = close + 1;
|
p = close + 1;
|
||||||
}
|
}
|
||||||
pc => {
|
pc => {
|
||||||
let Some(&c) = text.get(t) else { return false };
|
let Some(&c) = text.get(t) else { return Ok(false) };
|
||||||
let ok = match pc {
|
let ok = match pc {
|
||||||
'#' => c.is_ascii_digit(),
|
'#' => c.is_ascii_digit(),
|
||||||
'@' => c.is_alphabetic(),
|
'@' => c.is_alphabetic(),
|
||||||
@@ -764,14 +863,14 @@ fn glob(text: &[char], pat: &[char], depth: usize) -> bool {
|
|||||||
other => other == c,
|
other => other == c,
|
||||||
};
|
};
|
||||||
if !ok {
|
if !ok {
|
||||||
return false;
|
return Ok(false);
|
||||||
}
|
}
|
||||||
t += 1;
|
t += 1;
|
||||||
p += 1;
|
p += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
t == text.len()
|
Ok(t == text.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------- geometry ----------------
|
// ---------------- geometry ----------------
|
||||||
@@ -963,11 +1062,10 @@ fn findfile(a: &[Value]) -> R {
|
|||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub fn read_text_file(path: &str) -> Option<String> {
|
pub fn read_text_file(path: &str) -> Option<String> {
|
||||||
let bytes = std::fs::read(path).ok()?;
|
// Read through a byte-limited, regular-file-only reader so a path naming an endless device
|
||||||
// Cap what a routine can pull into memory.
|
// cannot exhaust memory before the 64 MiB cap is enforced (CWE-400).
|
||||||
if bytes.len() > 64 * 1024 * 1024 {
|
const MAX: usize = 64 * 1024 * 1024;
|
||||||
return None;
|
let bytes = crate::capped_read::read_capped(std::path::Path::new(path), MAX).ok()?;
|
||||||
}
|
|
||||||
Some(String::from_utf8_lossy(&bytes).into_owned())
|
Some(String::from_utf8_lossy(&bytes).into_owned())
|
||||||
}
|
}
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
@@ -1482,5 +1580,24 @@ pub fn real(r: f64) -> String {
|
|||||||
/// `wcmatch` for other callers (`ssget` filters).
|
/// `wcmatch` for other callers (`ssget` filters).
|
||||||
pub fn wild_match(text: &str, pattern: &str) -> bool {
|
pub fn wild_match(text: &str, pattern: &str) -> bool {
|
||||||
let t: Vec<char> = text.chars().collect();
|
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`).
|
/// A LISP value as JSON (for `setvar`), bounded so a shared cons structure cannot expand into
|
||||||
pub fn to_json(v: &Value) -> serde_json::Value {
|
/// exponentially many owned array elements before the setter can reject it (CWE-400).
|
||||||
match v {
|
///
|
||||||
|
/// 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::Nil => serde_json::Value::Null,
|
||||||
Value::Int(i) => serde_json::json!(i),
|
Value::Int(i) => serde_json::json!(i),
|
||||||
Value::Real(r) => serde_json::json!(r),
|
Value::Real(r) => serde_json::json!(r),
|
||||||
Value::Str(s) => serde_json::json!(&**s),
|
Value::Str(s) => serde_json::json!(&**s),
|
||||||
Value::Cons(_) => serde_json::Value::Array(v.iter().map(|x| to_json(&x)).collect()),
|
Value::Cons(_) => {
|
||||||
other => serde_json::json!(to_text(other, false)),
|
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> {
|
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> {
|
pub fn setvar(ctx: &mut Ctx<'_>, a: Vec<Value>) -> Result<Flow, LispError> {
|
||||||
arity(&a, 2, 2)?;
|
arity(&a, 2, 2)?;
|
||||||
let name = string(&a[0])?;
|
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())),
|
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)))),
|
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;
|
pub const MAX_STACK: usize = 100_000;
|
||||||
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
|
/// Nesting depth of synchronous callbacks (`vl-sort` comparing with a LISP function, …).
|
||||||
const MAX_SYNC_DEPTH: usize = 32;
|
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 {
|
enum State {
|
||||||
Eval(Value),
|
Eval(Value),
|
||||||
Ret(Value),
|
Ret(Value),
|
||||||
@@ -645,12 +646,31 @@ impl Ctx<'_> {
|
|||||||
super::value::charge_result(&v)?;
|
super::value::charge_result(&v)?;
|
||||||
return Ok(v);
|
return Ok(v);
|
||||||
}
|
}
|
||||||
|
let cap = self.s.lisp.limits.callback_steps;
|
||||||
let mut m = Machine::calling(f.clone(), args);
|
let mut m = Machine::calling(f.clone(), args);
|
||||||
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
let mut sub = Ctx { s: &mut *self.s, env: &mut *self.env, depth: self.depth + 1 };
|
||||||
match m.run(&mut sub, u64::MAX) {
|
// Drive the callback in bounded slices instead of granting it `u64::MAX` fuel. A `Yield`
|
||||||
Outcome::Done(v) => Ok(v),
|
// (the step budget ran out) is retried until a hard cap, so a constant-memory infinite
|
||||||
Outcome::Error(e) => Err(e),
|
// loop inside a comparator or predicate terminates with an error rather than hanging the
|
||||||
Outcome::Wait(_) => Err(LispError::new("this function can't ask for input here")),
|
// 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);
|
let mut p = Prompt::new(message, accept).base_opt(base);
|
||||||
p.keywords = self.initget.keywords.clone();
|
p.keywords = self.initget.keywords.clone();
|
||||||
p.raw = true;
|
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
|
p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,11 +126,13 @@ pub(crate) struct Limits {
|
|||||||
pub cells: usize,
|
pub cells: usize,
|
||||||
/// Bytes of strings, printed text and files read per run.
|
/// Bytes of strings, printed text and files read per run.
|
||||||
pub string_bytes: u64,
|
pub string_bytes: u64,
|
||||||
|
/// Maximum interpreter steps per synchronous callback.
|
||||||
|
pub callback_steps: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for Limits {
|
impl Default for Limits {
|
||||||
fn default() -> Self {
|
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()));
|
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]
|
#[test]
|
||||||
fn lists_and_strings() {
|
fn lists_and_strings() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
@@ -188,6 +221,64 @@ fn runaway_routines_are_errors_not_crashes() {
|
|||||||
assert_eq!(r, Ok("3000000".into()));
|
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]
|
#[test]
|
||||||
fn printing() {
|
fn printing() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
@@ -452,6 +543,37 @@ fn hostile_lisp_never_panics() {
|
|||||||
assert!(!s.lisp_running());
|
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]
|
#[test]
|
||||||
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
|
fn a_waiting_routine_is_a_prompt_with_a_rubber_band() {
|
||||||
let mut s = session();
|
let mut s = session();
|
||||||
|
|||||||
@@ -343,18 +343,29 @@ pub fn eq(a: &Value, b: &Value) -> bool {
|
|||||||
|
|
||||||
/// `equal`: the same structure, numbers within `fuzz`.
|
/// `equal`: the same structure, numbers within `fuzz`.
|
||||||
pub fn equal(a: &Value, b: &Value, fuzz: f64) -> bool {
|
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 {
|
/// Largest number of node comparisons one `equal` call may perform.
|
||||||
if depth > MAX_DEPTH {
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
*budget -= 1;
|
||||||
let (mut a, mut b) = (a.clone(), b.clone());
|
let (mut a, mut b) = (a.clone(), b.clone());
|
||||||
loop {
|
loop {
|
||||||
|
if *budget == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
match (&a, &b) {
|
match (&a, &b) {
|
||||||
(Value::Cons(x), Value::Cons(y)) => {
|
(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;
|
return false;
|
||||||
}
|
}
|
||||||
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
|
let (na, nb) = (x.cdr.clone(), y.cdr.clone());
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ pub struct Prompt {
|
|||||||
/// pointer boxes.
|
/// pointer boxes.
|
||||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||||
pub distance: bool,
|
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)`.
|
/// Parse `@distance<angle` (relative polar), returning `(distance, angle in radians)`.
|
||||||
@@ -128,11 +132,14 @@ impl Prompt {
|
|||||||
self.default = Some(d.into());
|
self.default = Some(d.into());
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
/// The prompt picks objects: a "Select objects" prompt, or a point prompt whose click selects
|
/// The prompt picks objects: a "Select objects" prompt, one that says it picks a single object
|
||||||
/// one object ("Select object to offset", "Select first line"…, the commands that pick a
|
/// ([`Prompt::picks_one`]), or a point prompt whose click selects one ("Select object to
|
||||||
/// single object themselves). The UI shows the pick box there.
|
/// 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 {
|
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>:".
|
/// "Specify next point or [Undo/Close] <default>:".
|
||||||
pub fn display(&self) -> String {
|
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}");
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ pub(crate) fn shape(primary: Option<&Face>, s: &str, height: f64, width_factor:
|
|||||||
catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok()
|
catch_unwind(AssertUnwindSafe(|| shape_chars(primary, &chars, height, width_factor, oblique))).ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lay out decoded directional runs, keeping advances and decoration spans for empty glyphs.
|
||||||
fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped {
|
fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64, width_factor: f64, oblique: f64) -> Shaped {
|
||||||
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
|
let h = if height.is_finite() && height > 0.0 { height } else { 1.0 };
|
||||||
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
let wf = if width_factor.is_finite() && width_factor.abs() > 1e-6 { width_factor } else { 1.0 };
|
||||||
@@ -68,6 +69,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 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 flags = |i: usize| chars.get(i).map(|c| (c.1, c.2)).unwrap_or((false, false));
|
||||||
let mut out = Shaped::default();
|
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 spans: Vec<(f64, f64, bool, bool)> = Vec::new();
|
||||||
let mut x = 0.0;
|
let mut x = 0.0;
|
||||||
// Whether the rightmost glyph so far is a stroke-font glyph.
|
// Whether the rightmost glyph so far is a stroke-font glyph.
|
||||||
@@ -102,12 +104,11 @@ fn shape_chars(primary: Option<&Face>, chars: &[(char, bool, bool)], height: f64
|
|||||||
let outline = ttf::glyph_by_id(face, g.gid);
|
let outline = ttf::glyph_by_id(face, g.gid);
|
||||||
let k = h / outline.cap * wf;
|
let k = h / outline.cap * wf;
|
||||||
let dy = g.dy * h / outline.cap;
|
let dy = g.dy * h / outline.cap;
|
||||||
let mut contours = ttf::place_glyph(&outline, x + g.dx * k, h, wf, shear);
|
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() {
|
for p in contours.iter_mut().flatten() {
|
||||||
*p += Vec2::new(dy * shear, dy);
|
*p += Vec2::new(dy * shear, dy);
|
||||||
}
|
}
|
||||||
if !contours.is_empty() {
|
out.push_glyph(contours, None);
|
||||||
out.glyphs.push(contours);
|
|
||||||
}
|
}
|
||||||
let adv = g.advance * k;
|
let adv = g.advance * k;
|
||||||
let (u, o) = flags(g.cluster);
|
let (u, o) = flags(g.cluster);
|
||||||
@@ -217,6 +218,59 @@ fn shape_run(face: &Face, cs: &[char], r: Range<usize>, rtl: bool) -> Vec<Glyph>
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
/// A single empty glyph with a positive advance, without relying on installed fonts.
|
||||||
|
fn empty_glyph_face() -> Face {
|
||||||
|
let mut head = vec![0_u8; 54];
|
||||||
|
head[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||||
|
head[18..20].copy_from_slice(&1000_u16.to_be_bytes());
|
||||||
|
let mut hhea = vec![0_u8; 36];
|
||||||
|
hhea[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||||
|
hhea[4..6].copy_from_slice(&800_i16.to_be_bytes());
|
||||||
|
hhea[6..8].copy_from_slice(&(-200_i16).to_be_bytes());
|
||||||
|
hhea[34..36].copy_from_slice(&1_u16.to_be_bytes());
|
||||||
|
let tables = [
|
||||||
|
(*b"head", head),
|
||||||
|
(*b"hhea", hhea),
|
||||||
|
(*b"hmtx", vec![1, 244, 0, 0]), // Advance 500, left side bearing 0.
|
||||||
|
(*b"maxp", vec![0, 0, 80, 0, 0, 1]), // Version 0.5, one glyph.
|
||||||
|
];
|
||||||
|
let mut bytes = vec![0_u8; 12 + tables.len() * 16];
|
||||||
|
bytes[..4].copy_from_slice(&0x0001_0000_u32.to_be_bytes());
|
||||||
|
bytes[4..6].copy_from_slice(&4_u16.to_be_bytes());
|
||||||
|
bytes[6..8].copy_from_slice(&64_u16.to_be_bytes());
|
||||||
|
bytes[8..10].copy_from_slice(&2_u16.to_be_bytes());
|
||||||
|
for (i, (tag, data)) in tables.into_iter().enumerate() {
|
||||||
|
while !bytes.len().is_multiple_of(4) {
|
||||||
|
bytes.push(0);
|
||||||
|
}
|
||||||
|
let record = 12 + i * 16;
|
||||||
|
let offset = u32::try_from(bytes.len()).unwrap();
|
||||||
|
let len = u32::try_from(data.len()).unwrap();
|
||||||
|
bytes[record..record + 4].copy_from_slice(&tag);
|
||||||
|
bytes[record + 8..record + 12].copy_from_slice(&offset.to_be_bytes());
|
||||||
|
bytes[record + 12..record + 16].copy_from_slice(&len.to_be_bytes());
|
||||||
|
bytes.extend(data);
|
||||||
|
}
|
||||||
|
Face::new(std::sync::Arc::new(bytes), 0).expect("synthetic font parses")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Empty complex glyphs retain spacing and underlines without adding geometry or text slots.
|
||||||
|
#[test]
|
||||||
|
fn empty_complex_glyph_keeps_advance_without_geometry() {
|
||||||
|
let face = empty_glyph_face();
|
||||||
|
let shaped = shape(Some(&face), " ", 1.0, 1.0, 0.0).expect("shapes empty glyph");
|
||||||
|
assert!(shaped.width > 0.0, "the font's advance is retained");
|
||||||
|
assert!(shaped.is_empty());
|
||||||
|
assert!(shaped.glyphs.is_empty());
|
||||||
|
assert!(shaped.text.is_empty());
|
||||||
|
|
||||||
|
let underlined = shape(Some(&face), "%%u ", 1.0, 1.0, 0.0).expect("shapes decorated empty glyph");
|
||||||
|
assert_eq!(underlined.width, shaped.width);
|
||||||
|
assert!(underlined.glyphs.is_empty());
|
||||||
|
assert!(underlined.text.is_empty());
|
||||||
|
assert!(!underlined.strokes.is_empty(), "decoration spans survive an empty outline");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
|
fn stroke_font_rtl_lines_keep_latin_and_digits_in_the_stroke_font() {
|
||||||
// With or without installed fonts: the Persian words are fallback glyphs or
|
// With or without installed fonts: the Persian words are fallback glyphs or
|
||||||
|
|||||||
@@ -334,7 +334,11 @@ pub struct TextGlyph {
|
|||||||
|
|
||||||
impl Shaped {
|
impl Shaped {
|
||||||
/// Add a glyph outline and, when it is a font glyph, its [`TextGlyph`].
|
/// 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>) {
|
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 {
|
if let Some(t) = text {
|
||||||
self.text.push(TextGlyph { outline: self.glyphs.len(), ..t });
|
self.text.push(TextGlyph { outline: self.glyphs.len(), ..t });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -501,6 +501,9 @@ struct Line {
|
|||||||
width: f64,
|
width: f64,
|
||||||
max_h: f64,
|
max_h: f64,
|
||||||
text: String,
|
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.
|
/// Index of the paragraph the line belongs to.
|
||||||
para: usize,
|
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);
|
finish(&mut lines, &mut line, fmt(last_fmt), para);
|
||||||
} else if !line.items.is_empty() || pending_space.is_some() {
|
} else if !line.items.is_empty() || pending_space.is_some() {
|
||||||
if let Some(f) = pending_space {
|
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.items.push(Item { seg: None, fmt: f, x: line.width, w: sw, at });
|
||||||
line.text.push(' ');
|
line.text.push(' ');
|
||||||
|
line.chars += 1;
|
||||||
}
|
}
|
||||||
line.width += sw;
|
line.width += sw;
|
||||||
}
|
}
|
||||||
@@ -590,8 +594,10 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
|||||||
for (sg, f) in segs {
|
for (sg, f) in segs {
|
||||||
let fw = seg_width(sg, fmt(*f));
|
let fw = seg_width(sg, fmt(*f));
|
||||||
line.max_h = line.max_h.max(fmt(*f).height);
|
line.max_h = line.max_h.max(fmt(*f).height);
|
||||||
let at = line.text.chars().count();
|
let at = line.chars;
|
||||||
line.text.push_str(&seg_plain(sg));
|
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.items.push(Item { seg: Some(sg.clone()), fmt: *f, x: line.width, w: fw, at });
|
||||||
line.width += fw;
|
line.width += fw;
|
||||||
last_fmt = *f;
|
last_fmt = *f;
|
||||||
@@ -679,6 +685,15 @@ pub fn layout_mtext_with(contents: &str, p: &MTextParams) -> MTextLayout {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn strips_formatting() {
|
fn strips_formatting() {
|
||||||
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
|
assert_eq!(plain_mtext("{\\fArial|b1;Bold}\\Pline 2"), "Bold\nline 2");
|
||||||
|
|||||||
+122
-17
@@ -107,6 +107,9 @@ const MAX_FIRST_WORD_FILES: usize = 24;
|
|||||||
const MAX_LOADED_BYTES: usize = 256 << 20;
|
const MAX_LOADED_BYTES: usize = 256 << 20;
|
||||||
const MAX_CACHED_NAMES: usize = 4096;
|
const MAX_CACHED_NAMES: usize = 4096;
|
||||||
const MAX_CACHED_CHARS: usize = 50_000;
|
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
|
/// Fonts known to cover many scripts (CJK first), tried first for missing glyphs. Matched as
|
||||||
/// substrings of the normalised file stem.
|
/// substrings of the normalised file stem.
|
||||||
@@ -595,6 +598,7 @@ pub fn fallback_for(c: char) -> Option<Face> {
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub(crate) struct GlyphOutline {
|
pub(crate) struct GlyphOutline {
|
||||||
pub(crate) contours: Arc<[Vec<Vec2>]>,
|
pub(crate) contours: Arc<[Vec<Vec2>]>,
|
||||||
|
points: usize,
|
||||||
pub(crate) advance: f64,
|
pub(crate) advance: f64,
|
||||||
pub(crate) cap: f64,
|
pub(crate) cap: f64,
|
||||||
/// The font maps the character (false: the missing-glyph shape).
|
/// 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
|
/// 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
|
/// 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.
|
/// 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> {
|
fn glyph_cache() -> &'static RwLock<GlyphCache> {
|
||||||
static C: OnceLock<RwLock<GlyphCache>> = OnceLock::new();
|
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 MAX_CACHED_GLYPHS: usize = 40_000;
|
||||||
const UPEM: f32 = 1000.0;
|
const UPEM: f32 = 1000.0;
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
struct Pen {
|
struct Pen {
|
||||||
contours: Vec<Vec<Vec2>>,
|
contours: Vec<Vec<Vec2>>,
|
||||||
cur: Vec<Vec2>,
|
cur: Vec<Vec2>,
|
||||||
|
points: usize,
|
||||||
|
truncated: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_OUTLINE_POINTS: usize = 65_536;
|
||||||
|
|
||||||
impl Pen {
|
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 {
|
fn pt(x: f32, y: f32) -> Vec2 {
|
||||||
Vec2::new(f64::from(x), f64::from(y))
|
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())
|
if let (Some(f), Some(l)) = (self.cur.first().copied(), self.cur.last().copied())
|
||||||
&& f != l
|
&& 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 {
|
} else {
|
||||||
self.cur.clear();
|
self.cur.clear();
|
||||||
}
|
}
|
||||||
@@ -650,10 +680,10 @@ impl Pen {
|
|||||||
impl OutlinePen for Pen {
|
impl OutlinePen for Pen {
|
||||||
fn move_to(&mut self, x: f32, y: f32) {
|
fn move_to(&mut self, x: f32, y: f32) {
|
||||||
self.flush();
|
self.flush();
|
||||||
self.cur.push(Self::pt(x, y));
|
self.push(Self::pt(x, y));
|
||||||
}
|
}
|
||||||
fn line_to(&mut self, x: f32, y: f32) {
|
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) {
|
fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) {
|
||||||
let a = self.cur.last().copied().unwrap_or_default();
|
let a = self.cur.last().copied().unwrap_or_default();
|
||||||
@@ -662,7 +692,7 @@ impl OutlinePen for Pen {
|
|||||||
for i in 1..=6 {
|
for i in 1..=6 {
|
||||||
let t = f64::from(i) / 6.0;
|
let t = f64::from(i) / 6.0;
|
||||||
let u = 1.0 - t;
|
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) {
|
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 {
|
for i in 1..=8 {
|
||||||
let t = f64::from(i) / 8.0;
|
let t = f64::from(i) / 8.0;
|
||||||
let u = 1.0 - t;
|
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) {
|
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> {
|
fn outline(face: &Face, k: GlyphKey) -> Arc<GlyphOutline> {
|
||||||
let key = (face.bytes.as_ptr() as usize, face.bytes.len(), face.index, k);
|
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();
|
return g.clone();
|
||||||
}
|
}
|
||||||
let Some(f) = face.font() else { return Arc::new(GlyphOutline { cap: 1.0, ..Default::default() }) };
|
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)),
|
GlyphKey::Id(id) => Some(GlyphId::new(id)),
|
||||||
};
|
};
|
||||||
let gid = mapped.unwrap_or_default();
|
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) {
|
if let Some(g) = f.outline_glyphs().get(gid) {
|
||||||
let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen);
|
let _ = g.draw(DrawSettings::unhinted(Size::new(UPEM), LocationRef::default()), &mut pen);
|
||||||
pen.flush();
|
pen.flush();
|
||||||
}
|
}
|
||||||
let advance = f64::from(f.glyph_metrics(Size::new(UPEM), LocationRef::default()).advance_width(gid).unwrap_or(UPEM * 0.5));
|
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 (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);
|
let mut cache = glyph_cache().write().unwrap_or_else(PoisonError::into_inner);
|
||||||
if cache.len() > MAX_CACHED_GLYPHS {
|
if cache.glyphs.len() >= MAX_CACHED_GLYPHS || cache.points.saturating_add(points) > MAX_SHAPED_POINTS {
|
||||||
cache.clear();
|
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
|
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`.
|
/// 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>> {
|
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)
|
place_glyph_at(g, Vec2::new(x, 0.0), h / g.cap, wf, shear)
|
||||||
@@ -814,17 +854,21 @@ pub fn shape(face: &Face, s: &str, height: f64, width_factor: f64, oblique: f64)
|
|||||||
let mut out = Shaped::default();
|
let mut out = Shaped::default();
|
||||||
let mut spans = Vec::new();
|
let mut spans = Vec::new();
|
||||||
let mut x = 0.0;
|
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) {
|
for (c, under, over) in crate::decode_controls(s) {
|
||||||
if crate::bidi::is_format_control(c) {
|
if crate::bidi::is_format_control(c) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let g = glyph_or_fallback(face, c);
|
let g = glyph_or_fallback(face, c);
|
||||||
if !c.is_whitespace() {
|
if !c.is_whitespace()
|
||||||
let contours = place_glyph(&g, x, h, wf, shear);
|
&& let Some(contours) = place_glyph_budgeted(&g, x, h, wf, shear, &mut points_left)
|
||||||
if !contours.is_empty() {
|
{
|
||||||
|
// 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));
|
out.push_glyph(contours, text_glyph(&g, c, x, h, wf, shear));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
let adv = g.advance * h / g.cap * wf;
|
let adv = g.advance * h / g.cap * wf;
|
||||||
spans.push((x, x + adv, under, over));
|
spans.push((x, x + adv, under, over));
|
||||||
x += adv;
|
x += adv;
|
||||||
@@ -878,3 +922,64 @@ mod family_tests {
|
|||||||
assert!(family_candidates(&many, "gosttypea", "gost").len() <= MAX_FAMILY_FILES + MAX_FIRST_WORD_FILES);
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
let theta = 4.0 * bulge.atan(); // signed included angle
|
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 chord = a.dist(b);
|
||||||
let r = chord / (2.0 * (theta / 2.0).sin().abs());
|
let r = chord / (2.0 * (theta / 2.0).sin().abs());
|
||||||
let mid = a.mid(b);
|
let mid = a.mid(b);
|
||||||
|
|||||||
@@ -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.
|
/// 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;
|
const CHUNK: usize = 16;
|
||||||
let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> {
|
let boxes = |s: &[(f64, Vec2)]| -> Vec<(usize, Bounds2)> {
|
||||||
(0..s.len().saturating_sub(1))
|
(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));
|
let (ba, bb) = (boxes(a), boxes(b));
|
||||||
for (ia, xa) in &ba {
|
for (ia, xa) in &ba {
|
||||||
for (ib, xb) in &bb {
|
for (ib, xb) in &bb {
|
||||||
|
let Some(left) = work.checked_sub(1) else { return };
|
||||||
|
*work = left;
|
||||||
if !xa.intersects(xb) {
|
if !xa.intersects(xb) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
for i in *ia..(*ia + CHUNK).min(a.len().saturating_sub(1)) {
|
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 };
|
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)) {
|
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 };
|
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) {
|
if let Some((u, v)) = seg_cross(a0, a1, b0, b1) {
|
||||||
out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v));
|
out.push((ta0 + (ta1 - ta0) * u, tb0 + (tb1 - tb0) * v));
|
||||||
@@ -255,16 +259,22 @@ 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 (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 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 out: Vec<CornerFit> = Vec::new();
|
||||||
|
let mut work = 16_000_000usize;
|
||||||
for (s1, s2) in signs {
|
for (s1, s2) in signs {
|
||||||
let (d1, d2) = (s1 * r, s2 * r);
|
let (d1, d2) = (s1 * r, s2 * r);
|
||||||
let (o1, o2) = (sample(c1, &r1, d1), sample(c2, &r2, d2));
|
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 a in &o1 {
|
||||||
for b in &o2 {
|
for b in &o2 {
|
||||||
crossings(a, b, &mut seeds);
|
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 {
|
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((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 (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);
|
let near_dup = |f: &CornerFit| f.center.near(center, tol * 1e3) && f.p1.near(p1, tol * 1e3) && f.p2.near(p2, tol * 1e3);
|
||||||
@@ -275,6 +285,11 @@ pub fn tangent_circles(c1: &TanCurve, c2: &TanCurve, r: f64, near: &[Vec2]) -> V
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if work == 0 {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
@@ -353,3 +368,18 @@ mod tests {
|
|||||||
assert!(tangent_circles(&bad, &s, 1.0, &[]).is_empty());
|
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 fillet::{CornerFit, TanCurve, tangent_circles};
|
||||||
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
pub use intersect::{circle_circle, intersect_ext, intersect_segments, line_circle, line_line, line_line_infinite};
|
||||||
pub use mat::{Mat3, Mat4};
|
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 spline_fit::{FitOptions, KnotParam};
|
||||||
pub use vec::{Vec2, Vec3};
|
pub use vec::{Vec2, Vec3};
|
||||||
|
|
||||||
|
|||||||
@@ -124,8 +124,15 @@ impl Spline {
|
|||||||
if !self.is_valid() {
|
if !self.is_valid() {
|
||||||
return Vec2::ZERO;
|
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 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 (lo, hi) = self.domain();
|
||||||
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
let t = if t.is_finite() { t.clamp(lo, hi) } else { lo };
|
||||||
let span = find_span(n, p, t, &self.knots);
|
let span = find_span(n, p, t, &self.knots);
|
||||||
@@ -470,9 +477,11 @@ impl Spline {
|
|||||||
out
|
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 {
|
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)
|
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 splits: Vec<Vec<f64>> = vec![vec![0.0, 1.0]; n];
|
||||||
let mut tested = std::collections::HashSet::new();
|
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 list in grid.values() {
|
||||||
for (k, &i) in list.iter().enumerate() {
|
for (k, &i) in list.iter().enumerate() {
|
||||||
for &j in list.iter().skip(k + 1) {
|
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))) {
|
if i == j || !tested.insert((i.min(j), i.max(j))) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use crate::spline_fit::FitOptions;
|
use crate::spline_fit::FitOptions;
|
||||||
use crate::{Bounds2, Vec2};
|
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.
|
/// A (possibly rational) B-spline curve, DXF SPLINE style.
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct Spline {
|
pub struct Spline {
|
||||||
@@ -57,7 +63,11 @@ impl Spline {
|
|||||||
|
|
||||||
pub fn is_valid(&self) -> bool {
|
pub fn is_valid(&self) -> bool {
|
||||||
let n = self.control.len();
|
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) {
|
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)
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Other writers use the parameters themselves as interior knots.
|
// Other writers use the parameters themselves as interior knots. Both `u` and
|
||||||
u.len() > 2 && u.iter().skip(1).take(u.len() - 2).all(|q| norm.iter().any(|k| close(*k, *q)))
|
// `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)
|
.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)));
|
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));
|
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]
|
#[test]
|
||||||
fn polyline_area_and_length() {
|
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);
|
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() {
|
if by_image.is_empty() {
|
||||||
return;
|
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 {
|
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() {
|
for b in d.blocks.values_mut() {
|
||||||
if b.entities.iter().any(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)) {
|
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> =
|
let hs: Vec<Handle> =
|
||||||
store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect();
|
store.iter().filter(|e| matches!(e.kind, EntityKind::Image(_)) && by_image.contains_key(&e.handle)).map(|e| e.handle).collect();
|
||||||
for h in hs {
|
for h in hs {
|
||||||
let Some(def) = by_image.get(&h) else { continue };
|
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| {
|
store.modify(h, |e| {
|
||||||
if let EntityKind::Image(i) = &mut e.kind {
|
if let EntityKind::Image(i) = &mut e.kind {
|
||||||
|
if copy_strings {
|
||||||
i.path = def.path.clone();
|
i.path = def.path.clone();
|
||||||
i.name = def.name.clone();
|
i.name = def.name.clone();
|
||||||
|
}
|
||||||
if let Some(p) = def.pixel_size {
|
if let Some(p) = def.pixel_size {
|
||||||
i.pixel_size = p;
|
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 p = Plan::default();
|
||||||
let mut index: HashMap<(String, String), usize> = HashMap::new();
|
let mut index: HashMap<(String, String), usize> = HashMap::new();
|
||||||
let mut used: HashSet<String> = HashSet::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 {
|
for e in entities {
|
||||||
let EntityKind::Image(i) = &e.kind else { continue };
|
let EntityKind::Image(i) = &e.kind else { continue };
|
||||||
if p.by_entity.contains_key(&e.handle) {
|
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.
|
// Dictionary keys are unique regardless of case.
|
||||||
let base = def_name(i);
|
let base = def_name(i);
|
||||||
let mut name = base.clone();
|
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)) {
|
while !used.insert(key(&name)) {
|
||||||
name = format!("{base}_{n}");
|
name = format!("{base}_{n}");
|
||||||
n = n.saturating_add(1);
|
n = n.saturating_add(1);
|
||||||
}
|
}
|
||||||
|
next_suffix.insert(key(&base), n);
|
||||||
p.defs.push(PlannedDef {
|
p.defs.push(PlannedDef {
|
||||||
handle: next(),
|
handle: next(),
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -51,8 +51,6 @@ const MAX_OBJECTS: usize = 1_000_000;
|
|||||||
const MAX_XDATA_GROUPS: usize = 16_384;
|
const MAX_XDATA_GROUPS: usize = 16_384;
|
||||||
/// Longest ownership chain followed.
|
/// Longest ownership chain followed.
|
||||||
const MAX_DEPTH: usize = 64;
|
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.
|
/// 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"];
|
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())))
|
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> {
|
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).
|
/// 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.
|
/// Parse one entity record. POLYLINE/INSERT followers are handled by the caller.
|
||||||
fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
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.
|
// The paper-space viewport our writer adds (dxf_write::paper_view) stays implicit.
|
||||||
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
|
if kind == "VIEWPORT" && crate::dxf_ext::is_paper_view(tags) {
|
||||||
return None;
|
return None;
|
||||||
@@ -480,7 +484,7 @@ fn entity(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
|||||||
}
|
}
|
||||||
k
|
k
|
||||||
}
|
}
|
||||||
"ACAD_TABLE" => EntityKind::Table(acad_table(tags)),
|
"ACAD_TABLE" => EntityKind::Table(acad_table(tags, table_cells_left)),
|
||||||
"VIEWPORT" => EntityKind::Viewport(Viewport {
|
"VIEWPORT" => EntityKind::Viewport(Viewport {
|
||||||
center: t.p(10),
|
center: t.p(10),
|
||||||
width: t.fd(40, 1.0),
|
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
|
/// 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.
|
/// 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;
|
const MAX_CELLS: usize = 1_000_000;
|
||||||
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0);
|
let start = tags.iter().position(|x| x.code == 100 && x.str() == "AcDbTable").unwrap_or(0);
|
||||||
let tail = tags.get(start..).unwrap_or(&[]);
|
let tail = tags.get(start..).unwrap_or(&[]);
|
||||||
@@ -540,6 +544,12 @@ fn acad_table(tags: &[Tag]) -> Table {
|
|||||||
rows = 0;
|
rows = 0;
|
||||||
cols = 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 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();
|
let mut row_heights: Vec<f64> = head.all_f(141).into_iter().take(rows).map(size).collect();
|
||||||
row_heights.resize(rows, 0.27);
|
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
|
/// 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`.
|
/// 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 out = Vec::new();
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while let Some((kind, tags)) = recs.get(i) {
|
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 owner = crate::dxf_ext::owner(tags);
|
||||||
let paper = t.i(67) == Some(1);
|
let paper = t.i(67) == Some(1);
|
||||||
let handle = t.s(5).and_then(|h| Handle::parse_hex(&h));
|
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()
|
if let Some((c, _)) = kindent.as_mut()
|
||||||
&& let Some(n) = entity_plot_style(tags, rx)
|
&& 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;
|
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 {
|
for (kind, tg) in recs {
|
||||||
let t = T(&tg);
|
let t = T(&tg);
|
||||||
match kind.as_str() {
|
match kind.as_str() {
|
||||||
"LAYER" => {
|
"LAYER" => {
|
||||||
let name = t.s(2).unwrap_or_default();
|
let name = t.s(2).unwrap_or_default();
|
||||||
if name.is_empty() {
|
if name.is_empty() || d.layers.len() >= MAX_OBJECTS {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let aci = t.i(62).unwrap_or(7);
|
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) {
|
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();
|
l.description = desc.str();
|
||||||
}
|
}
|
||||||
match d.layer_mut(&name) {
|
match layer_index.get(&name.to_ascii_lowercase()) {
|
||||||
Some(x) => *x = l,
|
Some(&i) => {
|
||||||
None => d.layers.push(l),
|
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" => {
|
"LTYPE" => {
|
||||||
let name = t.s(2).unwrap_or_default();
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let pattern = ltype_pattern(&tg, <ype_styles);
|
let pattern = ltype_pattern(&tg, <ype_styles);
|
||||||
@@ -1470,7 +1497,7 @@ fn tables(tags: &[Tag], d: &mut Drawing, rx: &mut Rx) {
|
|||||||
}
|
}
|
||||||
"STYLE" => {
|
"STYLE" => {
|
||||||
let name = t.s(2).unwrap_or_default();
|
let name = t.s(2).unwrap_or_default();
|
||||||
if name.is_empty() {
|
if name.is_empty() || d.text_styles.len() >= MAX_OBJECTS {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// 70 bit 4: vertical; 71 bits 2/4: backwards / upside down.
|
// 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" => {
|
"DIMSTYLE" => {
|
||||||
let name = t.s(2).unwrap_or_default();
|
let name = t.s(2).unwrap_or_default();
|
||||||
if name.is_empty() {
|
if name.is_empty() || d.dim_styles.len() >= MAX_OBJECTS {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Every variable with a DIMSTYLE group code; values the style can't take are
|
// 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.
|
/// Read a DXF file into a drawing.
|
||||||
pub fn read(bytes: &[u8]) -> Result<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 tags = cadcraft_dxf::parse(bytes).map_err(|e| IoError::Format(e.to_string()))?;
|
||||||
let secs = sections(&tags);
|
let secs = sections(&tags);
|
||||||
if secs.is_empty() {
|
if secs.is_empty() {
|
||||||
@@ -1705,7 +1736,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
|||||||
j += 1;
|
j += 1;
|
||||||
}
|
}
|
||||||
let body = recs.get(i + 1..j).unwrap_or(&[]);
|
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));
|
blocks.push((name, base, description, ents, xref));
|
||||||
i = j + 1;
|
i = j + 1;
|
||||||
} else {
|
} else {
|
||||||
@@ -1721,7 +1752,7 @@ pub fn read(bytes: &[u8]) -> Result<Drawing> {
|
|||||||
// A damaged file may split its entities over several ENTITIES sections (#865).
|
// A damaged file may split its entities over several ENTITIES sections (#865).
|
||||||
"ENTITIES" => {
|
"ENTITIES" => {
|
||||||
let mut n = 0;
|
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);
|
count_skipped(&mut skipped, "ENTITIES", n);
|
||||||
}
|
}
|
||||||
"OBJECTS" => {
|
"OBJECTS" => {
|
||||||
@@ -2248,6 +2279,7 @@ fn page_setup(t: &T) -> PageSetup {
|
|||||||
_ => "layout",
|
_ => "layout",
|
||||||
}
|
}
|
||||||
.into();
|
.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.plot_style_table = t.s(7).unwrap_or_default();
|
||||||
p
|
p
|
||||||
}
|
}
|
||||||
@@ -2309,3 +2341,36 @@ fn helix(tags: &[Tag]) -> Helix {
|
|||||||
pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
pub fn entity_from_groups(kind: &str, tags: &[Tag]) -> Option<(Common, EntityKind)> {
|
||||||
entity(&kind.to_ascii_uppercase(), tags)
|
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
|
/// 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.
|
/// 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 c = Common { layer: layer.into(), color: Color::ByBlock, ..Common::default() };
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for f in &g.fills {
|
for f in &g.fills {
|
||||||
@@ -2565,10 +2568,10 @@ fn tags_for(d: &Drawing, version: Version, target: Version) -> Vec<Tag> {
|
|||||||
(45, page.height_mm),
|
(45, page.height_mm),
|
||||||
(46, 0.0),
|
(46, 0.0),
|
||||||
(47, 0.0),
|
(47, 0.0),
|
||||||
(48, 0.0),
|
(48, page.plot_window[0]),
|
||||||
(49, 0.0),
|
(49, page.plot_window[1]),
|
||||||
(140, 0.0),
|
(140, page.plot_window[2]),
|
||||||
(141, 0.0),
|
(141, page.plot_window[3]),
|
||||||
(142, scale),
|
(142, scale),
|
||||||
(143, 1.0),
|
(143, 1.0),
|
||||||
] {
|
] {
|
||||||
|
|||||||
@@ -511,9 +511,12 @@ fn links(list: &cadcraft_render::DisplayList, map: &Map, clip_pt: &Bounds2, link
|
|||||||
if links.is_empty() {
|
if links.is_empty() {
|
||||||
return Vec::new();
|
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();
|
let mut areas: std::collections::HashMap<Handle, Bounds2> = std::collections::HashMap::new();
|
||||||
for p in &list.prims {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
let b = areas.entry(p.handle).or_insert(Bounds2::EMPTY);
|
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;
|
const MAX_TRANSFORM_DEPTH: usize = 64;
|
||||||
/// Flattened chords per elliptical arc under a non-uniform transform.
|
/// Flattened chords per elliptical arc under a non-uniform transform.
|
||||||
const MAX_ARC_CHORDS: usize = 10_000;
|
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).
|
/// Elements whose whole subtree is skipped (paint servers, text, raster images, metadata).
|
||||||
const SKIP: &[&[u8]] = &[
|
const SKIP: &[&[u8]] = &[
|
||||||
@@ -126,6 +129,10 @@ struct Parser {
|
|||||||
layers: HashSet<String>,
|
layers: HashSet<String>,
|
||||||
layer_color: u8,
|
layer_color: u8,
|
||||||
elements: usize,
|
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.
|
/// > 0 while inside a skipped subtree; counts the starts seen there.
|
||||||
skip: usize,
|
skip: usize,
|
||||||
/// What was left out, by kind ([`LeftOut`]).
|
/// What was left out, by kind ([`LeftOut`]).
|
||||||
@@ -173,6 +180,7 @@ impl Parser {
|
|||||||
layers: HashSet::from(["0".to_string()]),
|
layers: HashSet::from(["0".to_string()]),
|
||||||
layer_color: 1,
|
layer_color: 1,
|
||||||
elements: 0,
|
elements: 0,
|
||||||
|
verts_left: MAX_TOTAL_VERTS,
|
||||||
skip: 0,
|
skip: 0,
|
||||||
left_out: BTreeMap::new(),
|
left_out: BTreeMap::new(),
|
||||||
}
|
}
|
||||||
@@ -331,6 +339,14 @@ impl Parser {
|
|||||||
if self.elements > MAX_ELEMENTS {
|
if self.elements > MAX_ELEMENTS {
|
||||||
return Err(IoError::Format(format!("SVG: more than {MAX_ELEMENTS} 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() };
|
let common = Common { layer: self.layer.clone(), ..Common::default() };
|
||||||
self.drawing.add(&Space::Model, common, kind).map(|_| ()).map_err(parse_err)
|
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());
|
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]
|
#[test]
|
||||||
fn text_style_flags_roundtrip() {
|
fn text_style_flags_roundtrip() {
|
||||||
let mut d = Drawing::new_imperial();
|
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!(drag_grip(&mut d, h("30"), 2, GripPart::End, Vec2::new(130.0, 0.0)).is_err(), "not a reference");
|
||||||
assert_eq!(d, before);
|
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 }
|
cadcraft-io = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
# Secure randomness for the per-session control token (MIT OR Apache-2.0).
|
||||||
|
getrandom = "0.3"
|
||||||
|
|||||||
+102
-9
@@ -23,6 +23,14 @@ pub struct Remote {
|
|||||||
next_id: u64,
|
next_id: u64,
|
||||||
/// Open the app when nothing answers (see [`Remote::lazy`]).
|
/// Open the app when nothing answers (see [`Remote::lazy`]).
|
||||||
launch: bool,
|
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.
|
/// 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 {
|
impl Remote {
|
||||||
pub fn connect(addr: &str) -> std::io::Result<Self> {
|
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()?;
|
r.reconnect()?;
|
||||||
Ok(r)
|
Ok(r)
|
||||||
}
|
}
|
||||||
@@ -38,7 +46,20 @@ impl Remote {
|
|||||||
/// the server before the app is open. With `launch`, a call that finds nothing at `addr`
|
/// 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.
|
/// opens the app with that control port first (macOS) and waits for it.
|
||||||
pub fn lazy(addr: &str, launch: bool) -> Self {
|
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.
|
/// Connect, opening the app first when asked to and nothing answers.
|
||||||
fn reach(&mut self) -> std::io::Result<()> {
|
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());
|
let sent = writer.write_all(line.as_bytes()).and_then(|()| writer.write_all(b"\n")).and_then(|()| writer.flush());
|
||||||
if let Err(e) = sent {
|
if let Err(e) = sent {
|
||||||
self.conn = None;
|
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();
|
let mut reply = String::new();
|
||||||
match reader.read_line(&mut reply) {
|
match reader.read_line(&mut reply) {
|
||||||
@@ -162,24 +185,46 @@ impl Backend for Remote {
|
|||||||
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
fn call(&mut self, method: &str, params: Value) -> Result<Value, String> {
|
||||||
let id = self.next_id;
|
let id = self.next_id;
|
||||||
self.next_id += 1;
|
self.next_id += 1;
|
||||||
let line = json!({"id": id, "method": method, "params": params}).to_string();
|
let token = self.token().map(str::to_string);
|
||||||
let reply = match self.roundtrip(&line) {
|
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,
|
Ok(r) => r,
|
||||||
// Resend only when the request cannot have been applied. After a sent mutation with no reply the outcome is unknown, and
|
Err((e, sent)) => {
|
||||||
// resending could apply it twice (for example two circles from one call), so report that instead.
|
self.token = None;
|
||||||
Err((_, sent)) if !sent || is_read_only(method) => self.roundtrip(&line).map_err(|(e, _)| format!("CADCraft at {}: {e}", self.addr))?,
|
self.conn = None;
|
||||||
Err((e, _)) => {
|
// A mutation whose reply was lost must never be sent twice.
|
||||||
|
if sent && !is_read_only(method) {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
|
"CADCraft at {}: {e}; the reply was lost, so `{method}` may or may not have been applied. Inspect the drawing first",
|
||||||
self.addr
|
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}"))?;
|
let v: Value = serde_json::from_str(&reply).map_err(|e| format!("bad reply: {e}"))?;
|
||||||
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
if v.get("ok").and_then(Value::as_bool) == Some(true) {
|
||||||
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
Ok(v.get("result").cloned().unwrap_or(Value::Null))
|
||||||
} else {
|
} else {
|
||||||
let e = v.get("error").and_then(Value::as_str).unwrap_or("error");
|
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).
|
// A timeout says whether the request can still have run (#345).
|
||||||
Err(match v.get("state").and_then(Value::as_str) {
|
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"),
|
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
|
requests
|
||||||
});
|
});
|
||||||
let mut remote = Remote::connect(&addr).unwrap();
|
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();
|
let err = remote.call("engine.execute", json!({"command": "circle", "params": {}})).unwrap_err();
|
||||||
assert!(err.contains("may or may not have been applied"), "{err}");
|
assert!(err.contains("may or may not have been applied"), "{err}");
|
||||||
assert_eq!(server.join().unwrap(), 1);
|
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).
|
/// 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();
|
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)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
mod backend;
|
mod backend;
|
||||||
|
pub mod control_token;
|
||||||
mod server;
|
mod server;
|
||||||
mod tools;
|
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);
|
g.tessellate(r * e.ratio.min(1.0) * 2e-3, &mut pts);
|
||||||
}
|
}
|
||||||
EntityKind::Spline(s) => {
|
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;
|
return None;
|
||||||
}
|
}
|
||||||
pts = s.tessellate(tol_for(Bounds2::from_points(s.control.iter().copied())));
|
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 {
|
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 {
|
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() {
|
} else if d.text.trim().is_empty() {
|
||||||
String::new()
|
String::new()
|
||||||
} else {
|
} 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>> {
|
fn ext_line(origin: Vec2, foot: Vec2, st: &DimStyle, k: f64) -> Option<Vec<Vec2>> {
|
||||||
let v = foot - origin;
|
let v = foot - origin;
|
||||||
let len = v.len();
|
let len = v.len();
|
||||||
@@ -1322,3 +1359,21 @@ mod tests {
|
|||||||
assert!(g.lines.is_empty() && g.fills.is_empty(), "{:?}", g.lines);
|
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>>) {
|
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());
|
let mut unique: Vec<Vec<Vec2>> = Vec::with_capacity(loops.len());
|
||||||
for l in loops.drain(..) {
|
for l in loops.drain(..) {
|
||||||
if l.len() < 3 {
|
if l.len() < 3 {
|
||||||
continue;
|
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);
|
unique.push(l);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*loops = unique;
|
*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
|
/// 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
|
/// 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
|
/// 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
|
/// (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.
|
/// runs of a full build, so it never leaves out runs that a full build draws.
|
||||||
const MAX_RUNS: usize = 1_000_000;
|
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
|
/// 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).
|
/// out (a full-detail build of a pattern far too fine for its area).
|
||||||
const MAX_FAMILY_LINES: i64 = 20_000;
|
const MAX_FAMILY_LINES: i64 = 20_000;
|
||||||
@@ -351,7 +383,16 @@ pub(crate) fn pattern_lines_until(
|
|||||||
let scale = scale_of(h);
|
let scale = scale_of(h);
|
||||||
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
|
let px = if px.is_finite() && px > 0.0 { px } else { 0.0 };
|
||||||
let mut out = Vec::new();
|
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 {
|
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 ang = fam.angle.to_radians() + h.angle;
|
||||||
let origin = h.origin + Vec2::new(fam.origin.0, fam.origin.1).rotate(h.angle) * scale;
|
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;
|
let delta = Vec2::new(fam.delta.0, fam.delta.1).rotate(ang) * scale;
|
||||||
@@ -360,8 +401,8 @@ pub(crate) fn pattern_lines_until(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
|
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);
|
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
|
||||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -422,7 +463,7 @@ impl Family<'_> {
|
|||||||
self.origin + p.rotate(self.ang)
|
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 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());
|
let bb = Bounds2::from_points(local.iter().flatten().copied());
|
||||||
if bb.is_empty() {
|
if bb.is_empty() {
|
||||||
@@ -456,7 +497,7 @@ impl Family<'_> {
|
|||||||
let draws = self.dashes.iter().any(|d| *d >= 0.0);
|
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);
|
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 {
|
for k in k0..=k1 {
|
||||||
if out.len() >= MAX_RUNS || is_set(cancel) {
|
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let y = k as f64 * self.spacing;
|
let y = k as f64 * self.spacing;
|
||||||
@@ -484,21 +525,22 @@ impl Family<'_> {
|
|||||||
if solid_line {
|
if solid_line {
|
||||||
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
|
out.push(vec![self.to_world(Vec2::new(x0, y)), self.to_world(Vec2::new(x1, y))]);
|
||||||
} else {
|
} 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`.
|
/// 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 start_rep = ((x0 - offset) / pat_len).floor();
|
||||||
let mut x = offset + start_rep * pat_len;
|
let mut x = offset + start_rep * pat_len;
|
||||||
let mut guard = 0;
|
let mut guard = 0;
|
||||||
'outer: while x < x1 {
|
'outer: while x < x1 {
|
||||||
for d in self.dashes {
|
for d in self.dashes {
|
||||||
guard += 1;
|
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;
|
break 'outer;
|
||||||
}
|
}
|
||||||
let len = d.abs();
|
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");
|
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);
|
let eb = cadcraft_doc::entity_bounds(d, me, 0);
|
||||||
eb.is_empty() || eb.intersects(&win)
|
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 {
|
let mut sub = Builder {
|
||||||
list: DisplayList::default(),
|
list: DisplayList::default(),
|
||||||
opts: b.opts,
|
opts: b.opts,
|
||||||
plotting: b.plotting,
|
plotting: b.plotting,
|
||||||
styles: b.styles,
|
styles: b.styles,
|
||||||
named: None,
|
named: None,
|
||||||
shared_budget: None,
|
shared_budget: Some(&budget),
|
||||||
transparency: 0,
|
transparency: 0,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[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.
|
// As `build_parallel`: chunks built side by side, joined in drawing order.
|
||||||
use rayon::prelude::*;
|
use rayon::prelude::*;
|
||||||
let ents: Vec<&std::sync::Arc<Entity>> = d.model.iter().collect();
|
let ents: Vec<&std::sync::Arc<Entity>> = d.model.iter().collect();
|
||||||
let budget = std::sync::atomic::AtomicUsize::new(0);
|
|
||||||
let opts = b.opts;
|
let opts = b.opts;
|
||||||
let parts: Vec<DisplayList> = ents
|
let parts: Vec<DisplayList> = ents
|
||||||
.par_chunks(PARALLEL_CHUNK)
|
.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 out = Vec::new();
|
||||||
let mut pos = 0.0;
|
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() {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
if d >= 0.0 {
|
if d >= 0.0 {
|
||||||
@@ -110,6 +118,13 @@ pub fn ranges(len: f64, lt: &Linetype, scale: f64, min_pattern: f64) -> Option<V
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn dashes_split_line() {
|
fn dashes_split_line() {
|
||||||
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
|
let lt = Linetype::simple("DASHED", "", &[0.5, -0.25]);
|
||||||
|
|||||||
@@ -53,17 +53,30 @@ pub struct TableGeom {
|
|||||||
|
|
||||||
/// Which cells are covered by a merge (and so not drawn on their own).
|
/// Which cells are covered by a merge (and so not drawn on their own).
|
||||||
pub fn table_covered(t: &Table) -> Vec<Vec<bool>> {
|
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 rows = t.row_heights.len().min(10_000);
|
||||||
let cols = t.col_widths.len().min(10_000);
|
let cols = t.col_widths.len().min(10_000);
|
||||||
if rows.saturating_mul(cols) > MAX_LAYOUT_CELLS {
|
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];
|
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 (r, row) in t.cells.iter().enumerate().take(rows) {
|
||||||
for (c, cell) in row.iter().enumerate().take(cols) {
|
for (c, cell) in row.iter().enumerate().take(cols) {
|
||||||
if let Some((rs, cs)) = cell.merged {
|
if let Some((rs, cs)) = cell.merged {
|
||||||
for rr in r..(r + rs.max(1) as usize).min(rows) {
|
for rr in r..(r + rs.max(1) as usize).min(rows) {
|
||||||
for cc in c..(c + cs.max(1) as usize).min(cols) {
|
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)
|
if (rr, cc) != (r, c)
|
||||||
&& let Some(x) = cov.get_mut(rr).and_then(|v| v.get_mut(cc))
|
&& 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).
|
/// The table style a table uses (Standard defaults when missing).
|
||||||
@@ -134,8 +147,13 @@ 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
|
/// 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.
|
/// `origin`; merges are cut at the part's edge.
|
||||||
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
||||||
let cols = t.col_widths.len();
|
let cols = t.col_widths.len().min(10_000);
|
||||||
let mut cells: Vec<Vec<cadcraft_doc::TableCell>> = rows.iter().map(|r| t.cells.get(*r).cloned().unwrap_or_default()).collect();
|
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() {
|
for (i, row) in cells.iter_mut().enumerate() {
|
||||||
row.resize_with(cols, Default::default);
|
row.resize_with(cols, Default::default);
|
||||||
for cell in row.iter_mut() {
|
for cell in row.iter_mut() {
|
||||||
@@ -150,6 +168,7 @@ fn part_table(t: &Table, rows: &[usize], origin: Vec2) -> Table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Table {
|
Table {
|
||||||
insert: origin.to3(t.insert.z),
|
insert: origin.to3(t.insert.z),
|
||||||
col_widths: t.col_widths.clone(),
|
col_widths: t.col_widths.clone(),
|
||||||
@@ -180,7 +199,8 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
|||||||
if rows == 0 || cols == 0 {
|
if rows == 0 || cols == 0 {
|
||||||
return g;
|
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 (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)];
|
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) {
|
for s in corners.windows(2) {
|
||||||
@@ -209,7 +229,7 @@ fn layout(d: &Drawing, t: &Table) -> TableGeom {
|
|||||||
i
|
i
|
||||||
};
|
};
|
||||||
// Anchors and the anchor owning each grid cell.
|
// 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 anchors: Vec<Anchor> = Vec::new();
|
||||||
let mut owner = vec![usize::MAX; rows * cols];
|
let mut owner = vec![usize::MAX; rows * cols];
|
||||||
for r in 0..rows {
|
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 = { workspace = true }
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
log = { workspace = true }
|
log = { workspace = true }
|
||||||
|
chrono = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[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 {
|
fn utc(secs: u64) -> String {
|
||||||
let days = (secs / 86_400) as i64;
|
|
||||||
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
let (h, m) = ((secs % 86_400) / 3600, (secs % 3600) / 60);
|
||||||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
let (y, mo, d) = civil_from_days(secs / 86_400);
|
||||||
let z = days + 719_468;
|
// `{y:04}` rather than `%Y`, which writes a `+` before years past 9999.
|
||||||
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);
|
|
||||||
format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}")
|
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.
|
/// The Drawing Recovery window, while autosave files from a crash are waiting.
|
||||||
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
pub fn show(app: &mut CadApp, ctx: &egui::Context) {
|
||||||
if app.recovery.items.is_empty() {
|
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(0), "1970-01-01 00:00");
|
||||||
assert_eq!(super::utc(951_782_400 + 3_660), "2000-02-29 01:01");
|
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(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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
`--control` wins over the variable; a bare `--control` keeps the variable's port.
|
||||||
|
|
||||||
```json
|
```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"], ...}}
|
{"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
|
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.
|
to `control.methods`, which lists the methods the server answers.
|
||||||
|
|
||||||
|
|||||||
@@ -18,11 +18,16 @@ if [ -z "${APPLE_CERTIFICATE:-}" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
TMP="${RUNNER_TEMP:-${TMPDIR:-/tmp}}"
|
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"
|
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)}"
|
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"
|
printf '%s' "$APPLE_CERTIFICATE" | base64 --decode >"$CERT"
|
||||||
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
security create-keychain -p "$KC_PASS" "$KEYCHAIN"
|
||||||
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
security set-keychain-settings -lut 21600 "$KEYCHAIN"
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ publish = false
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = { workspace = true }
|
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.
|
/// Where the generated checklist goes.
|
||||||
pub const OUT: &str = "docs/parity-checklist.md";
|
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 {
|
fn today() -> String {
|
||||||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0);
|
date(std::time::SystemTime::now())
|
||||||
let (y, m, d) = civil_from_days((secs / 86_400) as i64);
|
}
|
||||||
|
|
||||||
|
/// `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}")
|
format!("{y:04}-{m:02}-{d:02}")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Days since 1970-01-01 → (year, month, day) in the proleptic Gregorian calendar.
|
/// Days since 1970-01-01 → proleptic Gregorian (year, month, day).
|
||||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
///
|
||||||
let z = z + 719_468;
|
/// `date` clamps a pre-1970 clock to the epoch, so `days` is an unsigned count that can run past
|
||||||
let era = z.div_euclid(146_097);
|
/// `NaiveDate`'s ±262143-year range. The Gregorian calendar repeats every 400 years (146_097
|
||||||
let doe = z.rem_euclid(146_097);
|
/// days), so chrono converts the day within one cycle and whole cycles go back into the year (an
|
||||||
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
|
/// `i128`, then saturated into `i64`), keeping chrono as the only calendar logic and reproducing
|
||||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
/// the old `civil_from_days` result exactly. 1970-01-01 is `num_days_from_ce` 719_163.
|
||||||
let mp = (5 * doy + 2) / 153;
|
fn civil_from_days(days: u64) -> (i64, u32, u32) {
|
||||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
use chrono::Datelike;
|
||||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
let (cycles, rest) = (days / 146_097, days % 146_097);
|
||||||
let y = yoe + era * 400 + i64::from(m <= 2);
|
// `rest + 719_163` is 719_163..865_259, always a valid CE day.
|
||||||
(y, m, d)
|
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> {
|
pub fn run(root: &Path) -> Result<(), String> {
|
||||||
@@ -116,9 +130,17 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn civil_dates() {
|
fn civil_dates() {
|
||||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
let day = |d: u64| date(std::time::UNIX_EPOCH + std::time::Duration::from_secs(d * 86_400));
|
||||||
assert_eq!(civil_from_days(11_016), (2000, 2, 29));
|
assert_eq!(day(0), "1970-01-01");
|
||||||
assert_eq!(civil_from_days(20_736), (2026, 10, 10));
|
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]
|
#[test]
|
||||||
|
|||||||
+58
-7
@@ -10,20 +10,41 @@ struct Counts {
|
|||||||
proptests: usize,
|
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 {
|
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||||
return;
|
return Ok(());
|
||||||
};
|
};
|
||||||
for e in rd.flatten() {
|
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 p = e.path();
|
||||||
let name = e.file_name();
|
let name = e.file_name();
|
||||||
let name = name.to_string_lossy();
|
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.
|
// Nested fuzz workspaces and build output are not part of the crate.
|
||||||
if name != "target" && name != "fuzz" && !name.starts_with('.') {
|
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)
|
&& let Ok(s) = std::fs::read_to_string(&p)
|
||||||
{
|
{
|
||||||
c.files += 1;
|
c.files += 1;
|
||||||
@@ -33,6 +54,7 @@ fn walk(dir: &Path, c: &mut Counts) {
|
|||||||
c.proptests += pt;
|
c.proptests += pt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
/// `#[test]` attributes and `proptest!` blocks (each block may hold many
|
||||||
@@ -71,7 +93,7 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut c = Counts::default();
|
let mut c = Counts::default();
|
||||||
walk(&dir, &mut c);
|
walk(&dir, &mut c)?;
|
||||||
let ex = if exact { exact_tests(&name) } else { None };
|
let ex = if exact { exact_tests(&name) } else { None };
|
||||||
rows.push((name, c, ex));
|
rows.push((name, c, ex));
|
||||||
}
|
}
|
||||||
@@ -108,11 +130,40 @@ pub fn run(_root: &Path, exact: bool) -> Result<(), String> {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::count_tests;
|
use super::{Counts, count_tests, walk};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn counts_attributes_and_proptest_blocks() {
|
fn counts_attributes_and_proptest_blocks() {
|
||||||
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
let src = "#[test]\nfn a() {}\n// #[test] commented\n #[test]\nfn b() {}\nproptest! {\n}\n";
|
||||||
assert_eq!(count_tests(src), (2, 1));
|
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