forked from 1b-agent/opencadstudio-upstream
web: fix red-team breaks in restore, drop-open, and plot paths
Three attack agents (state-machine tracing, executable input fuzzing,
browser-API review) found two breaks and several degradations; all are
fixed here, with the fuzzer's 22 tests kept as regression suites.
Breaks:
- Untitled tabs (and dot-names like ".dxf") autosave as DXF, but the
restore name defaulted to .dwg — the worker dispatches its reader by
the name's extension, so the only copy of unsaved work could never
be restored and was re-offered forever. recovered_name now uses the
worker's rsplit('.') convention and defaults extensionless names to
dxf, matching new-document serialization.
- " (recovered)" stacked one suffix per crash-restore cycle; the
marker is now stripped idempotently before appending.
- Opening a file (Ctrl+O / OPEN / automation) while a web open was in
flight clobbered it: the in-flight open's bytes were lost and every
queued restore prompt stalled for the session. on_open_file now
refuses while an open is running (web only).
Hardening:
- Escape on the Recover Drawing modal defers instead of deleting the
only snapshot; the Discard button still removes it.
- A cap-rejected restore enqueue warns in-app and re-offers the next
snapshot; the drop queue cap rises to 32.
- Exiting mid-round no longer races snapshot removals (deferrals are
drained, the sweep skips while a round is in flight); closing a tab
with "Don't save" now also removes its web snapshot; the snapshot
key derivation is shared via web_autosave_key.
- The plot-preview overlay no longer leaks a full PDF copy per preview
(the Download closure lives in a replaceable registry).
- A corrupt autosave index.json fails the round/boot instead of
silently resetting every row; plot pages with non-finite or non-
positive sheet dimensions error instead of emitting a NaN MediaBox.
- The drop filter accepts dot-less "DWG"/"dxf" names the worker
parses (same rsplit convention on both gates).
This commit is contained in:
1 parent
64160fd68d
commit
3eab00bfbf
35 files changed
+1356
-76
No files matched your search
@@ -0,0 +1,297 @@
|
||||
//! RED-TEAM: the Model-branch sheet floor in `layout_plot_page_for`
|
||||
//! (src/app/update/file.rs:3765-3800, `MIN_MODEL_SHEET_MM = 10.0`) attacked
|
||||
//! through its REAL input: `Scene::model_space_extents`
|
||||
//! (src/scene/mod.rs:10925 `compute_model_space_extents`).
|
||||
//!
|
||||
//! The App method is `pub(super)` and unconstructible outside the app, so the
|
||||
//! floor arithmetic is replicated verbatim below and fed the REAL extents of
|
||||
//! hostile scenes (NaN / ±inf / f64-overflow / reversed-header / huge /
|
||||
//! exactly-10mm / 9.99mm drawings). The question: can a NaN or non-finite
|
||||
//! page ever reach `pdf_export`, where `width < MIN` silently compares false
|
||||
//! for NaN and the "normal" branch would emit a NaN-sized `Mm(paper_w)`?
|
||||
#![cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
use acadrust::entities::Line;
|
||||
use acadrust::types::Vector3;
|
||||
use acadrust::EntityType;
|
||||
use OpenCADStudio::scene::Scene;
|
||||
|
||||
fn line(x1: f64, y1: f64, x2: f64, y2: f64) -> EntityType {
|
||||
let mut l = Line::new();
|
||||
l.start = Vector3::new(x1, y1, 0.0);
|
||||
l.end = Vector3::new(x2, y2, 0.0);
|
||||
EntityType::Line(l)
|
||||
}
|
||||
|
||||
/// Verbatim copy of the Model-branch sheet arithmetic
|
||||
/// (src/app/update/file.rs:3765-3800), reduced to its inputs:
|
||||
/// `model_space_extents()` + the dialog sheet (default A4 landscape 297x210).
|
||||
const MIN_MODEL_SHEET_MM: f64 = 10.0;
|
||||
fn model_sheet(extents: Option<(f64, f64, f64, f64, f64, f64)>) -> (f64, f64, f64, f64) {
|
||||
let selected_sheet: (f64, f64) = (297.0, 210.0);
|
||||
let standard_sheet = {
|
||||
let (w, h) = selected_sheet;
|
||||
(w.min(h) >= MIN_MODEL_SHEET_MM).then_some((w, h))
|
||||
};
|
||||
if let Some((min_x, min_y, _, max_x, max_y, _)) = extents {
|
||||
let margin = 1.05_f64;
|
||||
let width = ((max_x - min_x) as f64 * margin).max(1.0);
|
||||
let height = ((max_y - min_y) as f64 * margin).max(1.0);
|
||||
if width < MIN_MODEL_SHEET_MM || height < MIN_MODEL_SHEET_MM {
|
||||
let (w, h) = standard_sheet.unwrap_or((297.0, 210.0));
|
||||
let page_w = width.max(w);
|
||||
let page_h = height.max(h);
|
||||
(
|
||||
page_w,
|
||||
page_h,
|
||||
(page_w - width) * 0.5 - min_x as f64,
|
||||
(page_h - height) * 0.5 - min_y as f64,
|
||||
)
|
||||
} else {
|
||||
let pad_x = (width - (max_x - min_x) as f64) * 0.5;
|
||||
let pad_y = (height - (max_y - min_y) as f64) * 0.5;
|
||||
(width, height, -(min_x as f64) + pad_x, -(min_y as f64) + pad_y)
|
||||
}
|
||||
} else {
|
||||
(297.0, 210.0, 0.0, 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn flat_extents(scene: &Scene) -> Option<(f64, f64, f64, f64, f64, f64)> {
|
||||
scene
|
||||
.model_space_extents()
|
||||
.map(|(min, max)| (min.x as f64, min.y as f64, min.z as f64, max.x as f64, max.y as f64, max.z as f64))
|
||||
}
|
||||
|
||||
/// Entities with NaN coordinates must not poison the sheet: the extents pass
|
||||
/// in `compute_model_space_extents` drops non-finite vertices
|
||||
/// (src/scene/mod.rs:10945 `(x as f32).is_finite()`), so NaN alone yields
|
||||
/// `None` (default 297x210 page) and NaN mixed with real geometry yields the
|
||||
/// finite bounds only. The `width < MIN` NaN trap in the floor branch is
|
||||
/// unreachable through this input.
|
||||
#[test]
|
||||
fn nan_entities_cannot_produce_a_nan_or_floored_wrongly_page() {
|
||||
// Only-NaN drawing: no usable extents at all.
|
||||
let mut scene = Scene::new();
|
||||
scene.add_entity(line(f64::NAN, f64::NAN, f64::NAN, f64::NAN));
|
||||
scene.add_entity(line(0.0, f64::NAN, 10.0, f64::NAN));
|
||||
let extents = flat_extents(&scene);
|
||||
assert!(
|
||||
extents.is_none(),
|
||||
"NaN-only geometry must yield None, got {extents:?}"
|
||||
);
|
||||
let (w, h, ox, oy) = model_sheet(extents);
|
||||
assert_eq!((w, h), (297.0, 210.0));
|
||||
assert_eq!((ox, oy), (0.0, 0.0));
|
||||
|
||||
// NaN contaminating an otherwise finite drawing.
|
||||
let mut scene = Scene::new();
|
||||
scene.add_entity(line(0.0, 0.0, 10.0, 10.0));
|
||||
scene.add_entity(line(f64::NAN, 5.0, f64::NAN, 5.0));
|
||||
let (w, h, ox, oy) = model_sheet(flat_extents(&scene));
|
||||
assert_eq!((w, h), (10.5, 10.5), "10mm * 1.05 margin, NaN entity ignored");
|
||||
assert!(w.is_finite() && h.is_finite() && ox.is_finite() && oy.is_finite());
|
||||
|
||||
// Every scene fed to the floor keeps the invariant the branch promises:
|
||||
// a real, finite page of at least MIN on both axes.
|
||||
let mut hostile = vec![
|
||||
Scene::new(), // empty drawing
|
||||
];
|
||||
let mut inf_scene = Scene::new();
|
||||
inf_scene.add_entity(line(f64::NEG_INFINITY, 0.0, f64::INFINITY, f64::INFINITY));
|
||||
hostile.push(inf_scene);
|
||||
let mut overflow_scene = Scene::new(); // finite f64, overflows f32
|
||||
overflow_scene.add_entity(line(-1e300, -1e300, 1e300, 1e300));
|
||||
hostile.push(overflow_scene);
|
||||
let mut huge_f32_scene = Scene::new(); // finite even after the f32 cast
|
||||
huge_f32_scene.add_entity(line(-3.0e38, -3.0e38, 3.0e38, 3.0e38));
|
||||
hostile.push(huge_f32_scene);
|
||||
for scene in &hostile {
|
||||
let extents = flat_extents(scene);
|
||||
if let Some((mnx, mny, _, mxx, mxy, _)) = extents {
|
||||
assert!(
|
||||
mnx.is_finite() && mny.is_finite() && mxx.is_finite() && mxy.is_finite(),
|
||||
"extents must be finite: {extents:?}"
|
||||
);
|
||||
assert!(mnx <= mxx && mny <= mxy, "min/max must be ordered: {extents:?}");
|
||||
}
|
||||
let (w, h, ox, oy) = model_sheet(extents);
|
||||
assert!(w.is_finite() && h.is_finite() && ox.is_finite() && oy.is_finite());
|
||||
assert!(w >= MIN_MODEL_SHEET_MM && h >= MIN_MODEL_SHEET_MM, "({w}, {h})");
|
||||
}
|
||||
}
|
||||
|
||||
/// A drawing whose only coordinates exceed f32 range (e.g. 1e300) is silently
|
||||
/// dropped from the extents (the `(x as f32).is_finite()` filter) — the plot
|
||||
/// falls back to the default sheet, so the drawing PLOTS EMPTY rather than
|
||||
/// erroring. Behavior note, not a crash.
|
||||
#[test]
|
||||
fn beyond_f32_range_geometry_is_silently_ignored_by_extents() {
|
||||
let mut scene = Scene::new();
|
||||
scene.add_entity(line(1e300, 1e300, 2e300, 2e300));
|
||||
assert!(
|
||||
flat_extents(&scene).is_none(),
|
||||
"1e300 coordinates overflow the f32 wire cache and vanish from extents"
|
||||
);
|
||||
// While geometry at f32::MAX-ish scale stays (absurd page, but finite).
|
||||
let mut scene = Scene::new();
|
||||
scene.add_entity(line(-3.0e38, 0.0, 3.0e38, 3.0e38));
|
||||
let (w, h, _, _) = model_sheet(flat_extents(&scene));
|
||||
assert!(w > 1.0e38 && h > 1.0e38, "({w}, {h})");
|
||||
}
|
||||
|
||||
/// The reversed-extents case (min > max) cannot come from
|
||||
/// `compute_model_space_extents` (min starts at +inf and only decreases) and
|
||||
/// the EXTMIN/EXTMAX header fallback rejects it with a strict `<`
|
||||
/// (src/scene/mod.rs:11005). A fresh document's header is reversed by
|
||||
/// DEFAULT (1e20 / -1e20 sentinels) and its model-space block record exists,
|
||||
/// so an empty drawing exercises the header path immediately.
|
||||
#[test]
|
||||
fn reversed_extents_are_unreachable_even_from_a_hostile_header() {
|
||||
// Real parsed document (has a *Model_Space block), all entities removed:
|
||||
// the header fallback is the only extents source left. One fresh scene
|
||||
// per case — `model_space_extents` caches by geometry_epoch, and header
|
||||
// mutation alone does not bump the epoch.
|
||||
let empty_scene = || {
|
||||
let mut scene = Scene::new();
|
||||
scene.document = OpenCADStudio::io::load_bytes(
|
||||
"embedded-image-print.dxf",
|
||||
include_bytes!("fixtures/embedded-image-print.dxf").to_vec(),
|
||||
)
|
||||
.expect("fixture");
|
||||
let handles: Vec<_> = scene.document.entities().map(|e| e.common().handle).collect();
|
||||
for handle in handles {
|
||||
scene.document.remove_entity(handle);
|
||||
}
|
||||
scene
|
||||
};
|
||||
// Reversed header: rejected.
|
||||
let mut scene = empty_scene();
|
||||
scene.document.header.model_space_extents_min = Vector3::new(100.0, 100.0, 0.0);
|
||||
scene.document.header.model_space_extents_max = Vector3::new(-100.0, -100.0, 0.0);
|
||||
assert!(
|
||||
flat_extents(&scene).is_none(),
|
||||
"reversed EXTMIN/EXTMAX must be rejected, not negated into a page"
|
||||
);
|
||||
// NaN header: also rejected by the strict `<` (NaN comparisons are false).
|
||||
let mut scene = empty_scene();
|
||||
scene.document.header.model_space_extents_min = Vector3::new(f64::NAN, f64::NAN, 0.0);
|
||||
scene.document.header.model_space_extents_max = Vector3::new(10.0, 10.0, 0.0);
|
||||
assert!(flat_extents(&scene).is_none(), "NaN EXTMIN must be rejected");
|
||||
// Ordered, sane header alone (no drawable geometry) feeds the plot
|
||||
// through the fallback path.
|
||||
let mut scene = empty_scene();
|
||||
scene.document.header.model_space_extents_min = Vector3::new(-100.0, -100.0, 0.0);
|
||||
scene.document.header.model_space_extents_max = Vector3::new(100.0, 100.0, 0.0);
|
||||
let extents = flat_extents(&scene)
|
||||
.expect("ordered sane header feeds the fallback");
|
||||
assert_eq!((extents.0, extents.1, extents.3, extents.4), (-100.0, -100.0, 100.0, 100.0));
|
||||
let (w, h, ox, oy) = model_sheet(Some(extents));
|
||||
// 200mm extents * 1.05 = 210mm page (normal branch, above the floor);
|
||||
// offset = -min + (210 - 200)/2 margin padding = 100 + 5.
|
||||
assert_eq!((w, h), (210.0, 210.0));
|
||||
assert!((ox - 105.0).abs() < 1e-9 && (oy - 105.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// The floor boundary: the floor compares the MARGIN-INCLUDED size
|
||||
/// (`extents * 1.05`), not the raw extents, so "exactly 10.0mm" and even
|
||||
/// 9.99mm drawings take the NORMAL branch (page = 10.5mm and 10.4895mm
|
||||
/// respectively) — the floor only catches extents below 10/1.05 ≈ 9.524mm.
|
||||
/// A 9.5mm x 10000mm profile IS floored, per-axis: 297mm wide, 10500mm tall,
|
||||
/// with the content centered by the offsets.
|
||||
#[test]
|
||||
fn floor_boundary_math_per_axis() {
|
||||
// Exactly 10.0mm extents: normal branch (10 * 1.05 = 10.5 >= 10).
|
||||
let (w, h, _, _) = model_sheet(Some((0.0, 0.0, 0.0, 10.0, 10.0, 0.0)));
|
||||
assert_eq!((w, h), (10.5, 10.5));
|
||||
// 9.99 x 10000: STILL the normal branch (9.99 * 1.05 = 10.4895 >= 10) —
|
||||
// the task's guess "(297, 10000+5%)" is off by the margin.
|
||||
let (w, h, _, _) = model_sheet(Some((0.0, 0.0, 0.0, 9.99, 10000.0, 0.0)));
|
||||
assert!((w - 10.4895).abs() < 1e-9);
|
||||
assert!((h - 10500.0).abs() < 1e-9);
|
||||
// 9.5 x 10000: floored per-axis — thin axis lifted to the standard sheet
|
||||
// width (297), long axis keeps its margin size, content centered.
|
||||
let (w, h, ox, oy) = model_sheet(Some((0.0, 0.0, 0.0, 9.5, 10000.0, 0.0)));
|
||||
assert_eq!(w, 297.0);
|
||||
assert!((h - 10500.0).abs() < 1e-9);
|
||||
// width = 9.5 * 1.05 = 9.975 → offset_x = (297 - 9.975)/2 - 0 = 143.5125
|
||||
assert!((ox - (297.0 - 9.975) * 0.5).abs() < 1e-9);
|
||||
// height = 10500 exactly fills the page → offset_y = -min_y = 0.
|
||||
assert!((oy - 0.0).abs() < 1e-9);
|
||||
// A dot (zero extents): width clamped to 1.0 by `.max(1.0)`, floored to
|
||||
// the full standard sheet, offsets center the dot's point.
|
||||
let (w, h, ox, oy) = model_sheet(Some((5.0, 7.0, 0.0, 5.0, 7.0, 0.0)));
|
||||
assert_eq!((w, h), (297.0, 210.0));
|
||||
assert!((ox - ((297.0 - 1.0) * 0.5 - 5.0)).abs() < 1e-9);
|
||||
assert!((oy - ((210.0 - 1.0) * 0.5 - 7.0)).abs() < 1e-9);
|
||||
}
|
||||
|
||||
/// Defense-in-depth check at the pdf_export layer: if a NaN page size ever
|
||||
/// DID reach it (future regression, or a non-Model branch), what happens?
|
||||
/// `PdfPageInput.paper_w = NaN` used to be serialized verbatim into a
|
||||
/// MediaBox containing the literal string "nan" — a nominally-valid PDF no
|
||||
/// conforming viewer can display (and which printpdf's own parser even
|
||||
/// accepts). The paper fields are now validated like the content
|
||||
/// coordinates (src/io/pdf_export.rs `append_pdf_page`): non-finite or
|
||||
/// non-positive dimensions are rejected with a plain-English error.
|
||||
#[test]
|
||||
fn pdf_export_layer_faced_with_a_nan_page_size() {
|
||||
use OpenCADStudio::io::pdf_export::{export_pdf, PdfPageInput, PdfPlotOptions};
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ocs-redteam-nan-page-{}.pdf",
|
||||
std::process::id()
|
||||
));
|
||||
for (paper_w, paper_h) in [
|
||||
(f64::NAN, f64::NAN),
|
||||
(f64::NAN, 297.0),
|
||||
(210.0, f64::INFINITY),
|
||||
(f64::NEG_INFINITY, 297.0),
|
||||
(0.0, 297.0),
|
||||
(210.0, 0.0),
|
||||
(-210.0, 297.0),
|
||||
] {
|
||||
let page = PdfPageInput {
|
||||
content: Default::default(),
|
||||
paper_w,
|
||||
paper_h,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: PdfPlotOptions::default(),
|
||||
plot_style: None,
|
||||
};
|
||||
let error = export_pdf(&page, &path)
|
||||
.expect_err("non-finite/zero sheet sizes must be rejected, not plotted");
|
||||
assert!(
|
||||
error.contains("plot sheet size is not valid"),
|
||||
"page ({paper_w}, {paper_h}): {error}"
|
||||
);
|
||||
assert!(
|
||||
!path.exists(),
|
||||
"no artifact may be written for a rejected sheet"
|
||||
);
|
||||
}
|
||||
// A sane sheet still exports normally.
|
||||
let page = PdfPageInput {
|
||||
content: Default::default(),
|
||||
paper_w: 210.0,
|
||||
paper_h: 297.0,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
rotation_deg: 0,
|
||||
scale: 1.0,
|
||||
clip: None,
|
||||
options: PdfPlotOptions::default(),
|
||||
plot_style: None,
|
||||
};
|
||||
export_pdf(&page, &path).expect("sane sheet exports");
|
||||
assert!(path.exists());
|
||||
let bytes = std::fs::read(&path).unwrap();
|
||||
assert!(
|
||||
!String::from_utf8_lossy(&bytes).to_lowercase().contains("nan"),
|
||||
"no NaN may leak into a written page"
|
||||
);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
Reference in new issue
Block a user