forked from 1b-agent/opencadstudio-upstream
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).
345 lines
16 KiB
Rust
345 lines
16 KiB
Rust
//! RED-TEAM: hostile inputs against the web autosave restore-name pipeline
|
|
//! (`src/io/web_autosave.rs` `recovered_name` / `newer_than_saved` /
|
|
//! `cap_evictions`) and the parse-worker extension gate it must satisfy
|
|
//! (`crates/ocs_web_worker/src/lib.rs`).
|
|
//!
|
|
//! `recovered_name` & co. are `pub(crate)` and OPFS is browser-only, so the
|
|
//! three pure helpers are copied VERBATIM below (marked) and their behaviour
|
|
//! pinned here; every claim that can be exercised through real shipped code
|
|
//! (`OpenCADStudio::io::load_bytes`, which derives the format from the name
|
|
//! exactly like the worker: `name.rsplit('.').next()`, src/io/mod.rs:722)
|
|
//! is tested against the real code.
|
|
#![cfg(not(target_arch = "wasm32"))]
|
|
|
|
const FIXTURE_DXF: &[u8] = include_bytes!("fixtures/embedded-image-print.dxf");
|
|
|
|
// ── Verbatim copies (pub(crate), invisible to integration tests) ───────────
|
|
|
|
/// Verbatim copy of src/io/web_autosave.rs `recovered_name` (incl. the
|
|
/// `RECOVERED_MARKER` const, inlined).
|
|
const RECOVERED_MARKER: &str = " (recovered)";
|
|
fn recovered_name(drawing: &str) -> String {
|
|
let (head, ext) = match drawing.rsplit_once('.') {
|
|
Some((head, ext)) if !ext.is_empty() => (head, ext.to_string()),
|
|
// No dot (or a trailing one): the whole name is the stem.
|
|
_ => (drawing, "dxf".to_string()),
|
|
};
|
|
// A hostile stored name must not smuggle path separators into the tab
|
|
// title / download name: keep only the final component of the head.
|
|
let head = head.rsplit(['/', '\\']).next().unwrap_or_default();
|
|
// When the split consumed everything (".dxf") fall back to the whole
|
|
// stored name, like `Path::file_stem`'s empty-stem fallback did.
|
|
let mut stem = if head.is_empty() {
|
|
drawing
|
|
} else {
|
|
head
|
|
};
|
|
// Idempotent: strip every trailing marker, then append exactly one.
|
|
while let Some(stripped) = stem.strip_suffix(RECOVERED_MARKER) {
|
|
stem = stripped;
|
|
}
|
|
format!("{stem}{RECOVERED_MARKER}.{ext}")
|
|
}
|
|
|
|
/// Verbatim copy of src/io/web_autosave.rs:52-57.
|
|
fn newer_than_saved(autosave_ms: f64, saved_ms: Option<f64>) -> bool {
|
|
match saved_ms {
|
|
Some(saved_ms) => autosave_ms > saved_ms,
|
|
None => true,
|
|
}
|
|
}
|
|
|
|
/// Verbatim copy of src/io/web_autosave.rs:39-43 + 71-84.
|
|
#[derive(Debug, Clone)]
|
|
struct WebAutosaveEntry {
|
|
name: String,
|
|
size: u64,
|
|
modified_ms: f64,
|
|
}
|
|
const SNAPSHOT_CAP: usize = 16;
|
|
fn cap_evictions(
|
|
mut entries: Vec<WebAutosaveEntry>,
|
|
cap: usize,
|
|
) -> (Vec<WebAutosaveEntry>, Vec<String>) {
|
|
entries.sort_by(|a, b| b.modified_ms.total_cmp(&a.modified_ms));
|
|
let overflow = entries.len().saturating_sub(cap);
|
|
let mut evicted: Vec<String> = entries
|
|
.split_off(entries.len() - overflow)
|
|
.into_iter()
|
|
.map(|entry| entry.name)
|
|
.collect();
|
|
evicted.reverse();
|
|
(entries, evicted)
|
|
}
|
|
|
|
/// Verbatim copy of the worker's extension gate
|
|
/// (crates/ocs_web_worker/src/lib.rs:44-52): the restore name must pass this
|
|
/// or the drawing can NEVER be reopened — a restore that always fails is a
|
|
/// data-loss trap (the snapshot is only deleted on a successful open).
|
|
fn worker_accepts(name: &str) -> bool {
|
|
let ext = name.rsplit('.').next().unwrap_or_default().to_lowercase();
|
|
matches!(ext.as_str(), "dwg" | "dxf")
|
|
}
|
|
|
|
fn entry(name: &str, modified_ms: f64) -> WebAutosaveEntry {
|
|
WebAutosaveEntry {
|
|
name: name.to_string(),
|
|
size: 16,
|
|
modified_ms,
|
|
}
|
|
}
|
|
|
|
/// Every recovered name must keep an extension the parse worker accepts —
|
|
/// otherwise the offered snapshot can never be restored, only discarded.
|
|
#[test]
|
|
fn recovered_names_that_the_parse_worker_refuses() {
|
|
// The extension is preserved verbatim: a ".sv$" name restores as ".sv$",
|
|
// which the web worker rejects ("Unsupported file format: .sv$",
|
|
// crates/ocs_web_worker/src/lib.rs:45) — while the NATIVE loader sniffs
|
|
// the real format for sv$/bak (src/io/mod.rs:723) and opens it anyway.
|
|
for input in ["plan.sv$", "plan.bak", "plan.dwf", "plan.dwgx", "pl.an"] {
|
|
let output = recovered_name(input);
|
|
assert!(
|
|
!worker_accepts(&output),
|
|
"recovered_name({input:?}) = {output:?} is refused by the parse worker"
|
|
);
|
|
}
|
|
// Real-code proof of the web/native divergence: native `load_bytes`
|
|
// derives the format from the name exactly like the worker, BUT sniffs
|
|
// sv$/bak — the same bytes+name the worker refuses open natively.
|
|
assert!(
|
|
OpenCADStudio::io::load_bytes("plan (recovered).sv$", FIXTURE_DXF.to_vec()).is_ok(),
|
|
"native loader sniffs sv$ and opens; only the web worker refuses"
|
|
);
|
|
// Non-sniffed unknown extensions are refused by BOTH loaders (the
|
|
// replica gate mirrors the worker; load_bytes is the real thing).
|
|
let err = OpenCADStudio::io::load_bytes("plan (recovered).dwf", FIXTURE_DXF.to_vec())
|
|
.expect_err("real loader must refuse .dwf");
|
|
assert!(err.contains("Unsupported file format"), "{err}");
|
|
}
|
|
|
|
/// FIXED (was BREAK, verified by reading + real-loader proof): a drawing
|
|
/// whose FILE NAME is exactly ".dxf" (a dot-file — accepted by BOTH the drop
|
|
/// filter and the worker, which split on the last dot) is serialized by
|
|
/// autosave as DXF (`source_is_dxf` falls through to
|
|
/// `dwg_source_version.is_none()` because `Path::extension(".dxf")` is
|
|
/// None). The old `Path::extension`-based `recovered_name` restored it as
|
|
/// ".dwg" — feeding DXF bytes to the DWG reader, so the restore could never
|
|
/// succeed and the snapshot was re-offered at every boot (it is deleted only
|
|
/// on a successful open). The extension split now follows the WORKER
|
|
/// convention (`name.rsplit('.').next()`), so ".dxf" restores as ".dxf".
|
|
#[test]
|
|
fn dotfile_dxf_restores_as_dxf_with_matching_bytes() {
|
|
// 1. The drop filter (replicated verbatim from src/io/web_drop.rs)
|
|
// accepts ".dxf": rsplit_once('.') yields ("", "dxf").
|
|
let dropped = ".dxf"
|
|
.rsplit_once('.')
|
|
.is_some_and(|(_, extension)| {
|
|
matches!(extension.to_ascii_lowercase().as_str(), "dwg" | "dxf")
|
|
});
|
|
assert!(dropped, "drop filter accepts the dot-file '.dxf'");
|
|
|
|
// 2. The worker gate (and the real loader) also parse ".dxf" as DXF.
|
|
assert!(worker_accepts(".dxf"));
|
|
let document = OpenCADStudio::io::load_bytes(".dxf", FIXTURE_DXF.to_vec())
|
|
.expect("real loader opens '.dxf' as DXF");
|
|
// ...and it is a DXF source, so `source_is_dxf` says DXF (the autosave
|
|
// serializes DXF bytes).
|
|
assert!(document.dwg_source_version.is_none());
|
|
|
|
// 3. Worker-convention split: ".dxf" keeps "dxf" as its extension — the
|
|
// empty head falls back to the whole stored name as the stem.
|
|
let restored = recovered_name(".dxf");
|
|
assert_eq!(restored, ".dxf (recovered).dxf");
|
|
|
|
// 4. Real loader: DXF bytes under the recovered name now go to the DXF
|
|
// reader and OPEN — the restore door is unjammed.
|
|
assert!(worker_accepts(&restored));
|
|
assert!(
|
|
OpenCADStudio::io::load_bytes(&restored, FIXTURE_DXF.to_vec()).is_ok(),
|
|
"DXF bytes under {restored:?} must parse"
|
|
);
|
|
// And the dot-file's DWG twin keeps its DWG extension the same way.
|
|
assert_eq!(recovered_name(".dwg"), ".dwg (recovered).dwg");
|
|
}
|
|
|
|
/// FIXED (was BREAK): the "(recovered)" suffix used to STACK — the restored
|
|
/// tab's `current_path` becomes the recovered name, the next autosave round
|
|
/// snapshots it under THAT name, and the next restore appended another
|
|
/// marker ("x (recovered) (recovered).dwg"), one suffix per crash cycle,
|
|
/// unbounded. `recovered_name` now strips every trailing marker before
|
|
/// appending exactly one, so the name is idempotent across any number of
|
|
/// crash cycles.
|
|
#[test]
|
|
fn recovered_marker_never_stacks_across_crash_cycles() {
|
|
// A single cycle: the marker is appended once, then stays at one.
|
|
let mut name = "plan.dwg".to_string();
|
|
for cycle in 1..=50 {
|
|
name = recovered_name(&name);
|
|
assert_eq!(
|
|
name,
|
|
"plan (recovered).dwg",
|
|
"cycle {cycle}: the recovered name must be idempotent"
|
|
);
|
|
assert!(worker_accepts(&name), "{name:?} must stay parseable");
|
|
}
|
|
// Legacy stacked names (written by the OLD behaviour before the fix)
|
|
// collapse back to a single marker.
|
|
assert_eq!(
|
|
recovered_name("plan (recovered) (recovered).dwg"),
|
|
"plan (recovered).dwg"
|
|
);
|
|
assert_eq!(
|
|
recovered_name(&format!("plan{} (recovered).dwg", " (recovered)".repeat(40))),
|
|
"plan (recovered).dwg"
|
|
);
|
|
// Extensionless stacked names de-stack too, then gain the DXF default.
|
|
assert_eq!(recovered_name("plan (recovered)"), "plan (recovered).dxf");
|
|
// An embedded (non-suffix) marker is left alone.
|
|
assert_eq!(
|
|
recovered_name("(recovered) plan.dwg"),
|
|
"(recovered) plan (recovered).dwg"
|
|
);
|
|
// The cycle still re-offers after a crash (no recent save of its own
|
|
// name) — that property is unchanged, only the name growth is fixed.
|
|
assert!(newer_than_saved(0.0, None));
|
|
}
|
|
|
|
/// Names that survive intact: no extension → DXF fallback (matching how
|
|
/// autosave serializes untitled tabs as DXF via `source_is_dxf` →
|
|
/// `dwg_source_version.is_none()`), multi-dot keeps the final extension,
|
|
/// unicode is passed through, path separators are stripped from the head so
|
|
/// the result can never escape the display-name namespace (OPFS keys are
|
|
/// FNV hashes, never the name).
|
|
#[test]
|
|
fn benign_hostile_names_round_trip_parseable() {
|
|
for (input, expected, dxf_bytes) in [
|
|
// Extensionless names restore as DXF — what autosave serializes
|
|
// untitled tabs as (was ".dwg" under the old Path::extension split).
|
|
("Drawing1", "Drawing1 (recovered).dxf", true),
|
|
("a.b.dxf", "a.b (recovered).dxf", true),
|
|
("", " (recovered).dxf", false),
|
|
("plán.dwg", "plán (recovered).dwg", false),
|
|
("図面 1.dwg", "図面 1 (recovered).dwg", false),
|
|
] {
|
|
let got = recovered_name(input);
|
|
assert_eq!(got, expected);
|
|
assert!(worker_accepts(&got), "{got:?} must stay parseable");
|
|
// Real loader opens the name whenever the bytes match the extension
|
|
// it implies (DXF fixture bytes for .dxf outcomes).
|
|
if dxf_bytes {
|
|
assert!(
|
|
OpenCADStudio::io::load_bytes(&got, FIXTURE_DXF.to_vec()).is_ok(),
|
|
"{got}"
|
|
);
|
|
}
|
|
}
|
|
// Windows path semantics: separators in the stored name never survive
|
|
// into the recovered name as separators.
|
|
for hostile in ["..\\evil.dxf", "../evil.dxf", "a/b\\c.dwg"] {
|
|
let got = recovered_name(hostile);
|
|
assert!(
|
|
!got.contains('/') && !got.contains('\\'),
|
|
"recovered name must not carry path separators: {got:?}"
|
|
);
|
|
}
|
|
// A 4096-char name: stem grows, hash key stays 16 hex chars (see
|
|
// web_name_hash_redteam.rs) — no length-based failure.
|
|
let long = "x".repeat(4096 - ".dwg".len());
|
|
let got = recovered_name(&format!("{long}.dwg"));
|
|
assert!(worker_accepts(&got));
|
|
assert_eq!(got.len(), long.len() + " (recovered).dwg".len());
|
|
}
|
|
|
|
// ── newer_than_saved ────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn newer_than_saved_hostile_timestamps() {
|
|
// Equal mtimes: same save round, not offered (existing behaviour).
|
|
assert!(!newer_than_saved(1000.0, Some(1000.0)));
|
|
// Negative mtimes compare numerically.
|
|
assert!(newer_than_saved(-5.0, Some(-10.0)));
|
|
assert!(!newer_than_saved(-10.0, Some(-5.0)));
|
|
// NaN autosave mtime: `NaN > x` is false — silently NOT offered. The
|
|
// snapshot survives (age never deletes), so worst case is a hidden
|
|
// snapshot, not a lost one. Deterministic, no poison.
|
|
assert!(!newer_than_saved(f64::NAN, Some(1000.0)));
|
|
// NaN saved mtime likewise hides the offer forever.
|
|
assert!(!newer_than_saved(1000.0, Some(f64::NAN)));
|
|
// Extremes behave numerically: +inf / f64::MAX are "newer" than anything
|
|
// finite; -inf is older than everything, including f64::MIN.
|
|
assert!(newer_than_saved(f64::MAX, Some(f64::MIN)));
|
|
assert!(newer_than_saved(f64::INFINITY, Some(f64::MAX)));
|
|
assert!(!newer_than_saved(f64::NEG_INFINITY, Some(f64::MIN)));
|
|
}
|
|
|
|
// ── cap_evictions ───────────────────────────────────────────────────────────
|
|
|
|
#[test]
|
|
fn cap_evictions_boundaries() {
|
|
// Empty: nothing kept, nothing evicted.
|
|
let (kept, evicted) = cap_evictions(Vec::new(), SNAPSHOT_CAP);
|
|
assert!(kept.is_empty() && evicted.is_empty());
|
|
// Exactly the cap: nothing evicted.
|
|
let entries: Vec<_> = (0..SNAPSHOT_CAP).rev().map(|i| entry(&format!("d{i}"), i as f64)).collect();
|
|
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
|
|
assert!(evicted.is_empty());
|
|
assert_eq!(kept.len(), SNAPSHOT_CAP);
|
|
assert_eq!(kept.first().unwrap().name, format!("d{}", SNAPSHOT_CAP - 1));
|
|
// Exactly cap+1: the single oldest is evicted, survivors newest-first.
|
|
let entries: Vec<_> = (0..=SNAPSHOT_CAP)
|
|
.map(|i| entry(&format!("d{i}"), i as f64))
|
|
.collect();
|
|
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
|
|
assert_eq!(evicted, ["d0"]);
|
|
assert_eq!(kept.last().unwrap().name, "d1");
|
|
assert_eq!(kept.first().unwrap().name, format!("d{SNAPSHOT_CAP}"));
|
|
// Unsorted input still yields newest-first offer order.
|
|
let (kept, _) = cap_evictions(
|
|
vec![entry("old", 1.0), entry("newest", 9.0), entry("mid", 5.0)],
|
|
SNAPSHOT_CAP,
|
|
);
|
|
assert_eq!(
|
|
kept.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
|
["newest", "mid", "old"]
|
|
);
|
|
// All-equal mtimes: the stable sort keeps index (push) order, `split_off`
|
|
// takes the tail, and the final `reverse()` flips it — so among equal
|
|
// mtimes the MOST RECENTLY INDEXED snapshot is deleted first. Arbitrary
|
|
// (ties carry no age signal) but deterministic.
|
|
let entries: Vec<_> = (0..SNAPSHOT_CAP + 2).map(|i| entry(&format!("e{i}"), 42.0)).collect();
|
|
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
|
|
assert_eq!(evicted, ["e17", "e16"]);
|
|
assert_eq!(kept.first().unwrap().name, "e0");
|
|
// Degenerate cap 0 evicts everything.
|
|
let (_, evicted) = cap_evictions(vec![entry("a", 1.0), entry("b", 2.0)], 0);
|
|
assert_eq!(evicted, ["a", "b"]); // oldest-first
|
|
}
|
|
|
|
/// NaN mtimes do NOT poison the choice into a panic or a wrong split — but
|
|
/// `total_cmp` ranks NaN ABOVE every finite mtime, so NaN entries sort as
|
|
/// "newest": they are always KEPT and cannot be evicted, while they push real
|
|
/// snapshots past the cap into eviction. `newer_than_saved(NaN, _)` then
|
|
/// refuses to offer them, so a NaN-mtime snapshot is undead: never offered,
|
|
/// never evicted, squatting one cap slot forever.
|
|
#[test]
|
|
fn cap_evictions_nan_mtimes_sort_as_newest_and_crowd_out_real_snapshots() {
|
|
let mut entries: Vec<_> = (0..SNAPSHOT_CAP)
|
|
.map(|i| entry(&format!("real{i}"), i as f64))
|
|
.collect();
|
|
entries.push(entry("nan", f64::NAN));
|
|
let (kept, evicted) = cap_evictions(entries, SNAPSHOT_CAP);
|
|
assert_eq!(kept.first().unwrap().name, "nan", "NaN sorts above +inf");
|
|
assert_eq!(evicted, ["real0"], "the oldest REAL snapshot pays for it");
|
|
assert!(!newer_than_saved(f64::NAN, Some(0.0)), "and is never offered");
|
|
// Mixed infinities stay ordered.
|
|
let (kept, _) = cap_evictions(
|
|
vec![entry("neg_inf", f64::NEG_INFINITY), entry("inf", f64::INFINITY), entry("zero", 0.0)],
|
|
SNAPSHOT_CAP,
|
|
);
|
|
assert_eq!(
|
|
kept.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(),
|
|
["inf", "zero", "neg_inf"]
|
|
);
|
|
}
|