From 46a455db7c68f156a3839f03ef908ac196f0b91a Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 15 Jul 2026 06:20:58 +0200 Subject: [PATCH] =?UTF-8?q?release:=20v3.2.4=20=E2=80=94=20cap=20ALL=20fla?= =?UTF-8?q?t=20viz=20sections=20(final=20page-size=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After v3.2.3 the deployed self-analysis.html was still ~4.9 MB: capping program points alone wasn't enough — Diagnostics (~1.7 MB), Call graph (~1.7 MB), Trap checks, Functions, gaps/handle/float/pentagon each render one row per entry, and scry-on-scry produces thousands of each. scry-viz: a shared SECTION_ROW_CAP (100) + cap_note applied to every flat section; the call-graph Mermaid diagram is skipped for graphs > cap (a thousands-edge Mermaid source was itself ~1.7 MB). Trap checks show POTENTIAL-TRAP first so the cap never hides one. New comprehensive test blows up EVERY section to 4000 entries and asserts the page stays <1 MB (would have caught the earlier partial fixes). Full un-capped data remains in guidance.json. Version 3.2.3 → 3.2.4 + Cargo.lock. 25 viz tests, clippy + fmt clean. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 21 +++++ Cargo.lock | 30 +++---- Cargo.toml | 2 +- crates/scry-analyze-core/Cargo.toml | 18 ++-- crates/scry-analyze-core/src/lib.rs | 2 +- crates/scry-segment/Cargo.toml | 2 +- crates/scry-viz/Cargo.toml | 2 +- crates/scry-viz/src/lib.rs | 127 ++++++++++++++++++++++++---- 8 files changed, 158 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3a7ae..dab358e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,27 @@ Versioning: [SemVer 2.0](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.2.4] — 2026-07-15 + +Fix (final page-size pass): after v3.2.3 the deployed `self-analysis.html` was +still ~4.9 MB — capping *program points* alone wasn't enough. The other flat +sections (Diagnostics ~1.7 MB, Call graph ~1.7 MB, Trap checks, Functions, gaps, +handle/float/pentagon facts) render one row per entry and scry-on-scry produces +thousands of each. + +### Fixed — scry-viz + +- **Every flat list/table section is now capped** (`SECTION_ROW_CAP` = 100 rows + + a "showing N of M" note): Diagnostics, Call graph (edge table), Trap checks + (POTENTIAL-TRAPs shown first so the cap never hides one), Functions, gaps, + handle faults, float facts, relational guards. +- **The call-graph diagram is skipped for large graphs** — a Mermaid source of + thousands of edges was itself ~1.7 MB. +- **New comprehensive test** `all_flat_sections_capped_page_stays_small` blows up + *every* section to 4,000 entries and asserts the page stays <1 MB — the check + that would have caught the v3.2.2/v3.2.3 partial fixes. The actionable subset of + every section remains available un-capped in the `guidance.json` feed. + ## [3.2.3] — 2026-07-15 Fix: **the v3.2.2 program-points cap was per-function only**, so scry-on-scry's diff --git a/Cargo.lock b/Cargo.lock index 19f329a..e53014c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1834,7 +1834,7 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "scry-host-tests" -version = "3.2.3" +version = "3.2.4" dependencies = [ "anyhow", "jsonschema", @@ -1851,18 +1851,18 @@ dependencies = [ [[package]] name = "scry-sai-analyzer" -version = "3.2.3" +version = "3.2.4" dependencies = [ "scry-sai-core", ] [[package]] name = "scry-sai-bits" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-core" -version = "3.2.3" +version = "3.2.4" dependencies = [ "scry-sai-bits", "scry-sai-float", @@ -1880,19 +1880,19 @@ dependencies = [ [[package]] name = "scry-sai-float" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-handle" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-interval" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-lattice" -version = "3.2.3" +version = "3.2.4" dependencies = [ "bitflags", "scry-sai-octagon", @@ -1901,34 +1901,34 @@ dependencies = [ [[package]] name = "scry-sai-octagon" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-pentagon" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-poly" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-provenance" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-segment" -version = "3.2.3" +version = "3.2.4" dependencies = [ "scry-sai-interval", ] [[package]] name = "scry-sai-taint" -version = "3.2.3" +version = "3.2.4" [[package]] name = "scry-sai-viz" -version = "3.2.3" +version = "3.2.4" dependencies = [ "cpp_demangle 0.5.1", "rustc-demangle", diff --git a/Cargo.toml b/Cargo.toml index 7d386ec..8c9d7ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,7 +69,7 @@ default-members = [ # on crates.io matches the release artifacts. The crates.io publish workflow # asserts the pushed `v*` tag equals this version, so a release bump must move # both in lockstep (and the internal path-dep `version = "..."` fields below). -version = "3.2.3" +version = "3.2.4" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/pulseengine/scry" diff --git a/crates/scry-analyze-core/Cargo.toml b/crates/scry-analyze-core/Cargo.toml index e31bfac..f87bd3a 100644 --- a/crates/scry-analyze-core/Cargo.toml +++ b/crates/scry-analyze-core/Cargo.toml @@ -31,7 +31,7 @@ path = "src/lib.rs" # Path deps carry `version` so `cargo publish` rewrites them to the crates.io # coordinate (crates.io rejects path-only deps). The version equals the # workspace version and must be bumped in lockstep with it. -scry-sai-interval = { path = "../scry-interval", version = "3.2.3" } +scry-sai-interval = { path = "../scry-interval", version = "3.2.4" } # Step 2 (DD-012): the analyze body + helpers moved here. wasmparser parses # the input Wasm Core Model module; sha2 digests the module bytes for @@ -43,44 +43,44 @@ sha2 = { workspace = true } # Security-label (taint) lattice for the noninterference analysis (FEAT-009) # and the pure meld<->scry provenance boundary crate (FEAT-002 / DD-002). -scry-sai-taint = { path = "../scry-taint", version = "3.2.3" } -scry-sai-provenance = { path = "../scry-provenance", version = "3.2.3" } +scry-sai-taint = { path = "../scry-taint", version = "3.2.4" } +scry-sai-provenance = { path = "../scry-provenance", version = "3.2.4" } # Octagon relational domain (FEAT-016 slice-2b-ii): carried alongside the # intervals through the structured-CFG fixpoint so a loop counter bounded by a # VARIABLE relation (`i < n`) stays bounded where the interval domain alone # widens it to ⊤. Same pure `#![no_std]` dual-compile crate as scry-interval. -scry-sai-octagon = { path = "../scry-octagon", version = "3.2.3" } +scry-sai-octagon = { path = "../scry-octagon", version = "3.2.4" } # Known-bits × interval-guarded congruence reduced product (FEAT-037 / DD-017): # an additive bit/alignment/stride companion computed in a straight-line-sound # pass, surfaced library-only on `AnalysisResult.bit_facts`. Same pure # `#![no_std]` dual-compile crate as the other domains. -scry-sai-bits = { path = "../scry-bits", version = "3.2.3" } +scry-sai-bits = { path = "../scry-bits", version = "3.2.4" } # Pentagons weakly-relational domain (FEAT-044 / AC-014): intervals + strict # `x < y` facts, the cheap relational layer behind sound out-of-bounds-trap # detection (FEAT-046). An additive guard-recording pass surfaces proven # strict relations library-only on `AnalysisResult.pentagon_facts`. Same pure # `#![no_std]` dual-compile crate as the other domains. -scry-sai-pentagon = { path = "../scry-pentagon", version = "3.2.3" } +scry-sai-pentagon = { path = "../scry-pentagon", version = "3.2.4" } # IEEE-754 float-interval domain (FEAT-047 / AC-022): sound f32/f64 abstraction # with NaN/±inf tracking + round-to-nearest-aware widening. An additive # straight-line pass surfaces sound float intervals library-only on # `AnalysisResult.float_facts`. Same pure `#![no_std]` dual-compile crate. -scry-sai-float = { path = "../scry-float", version = "3.2.3" } +scry-sai-float = { path = "../scry-float", version = "3.2.4" } # Affine Component-Model handle-state lattice (FEAT-049 / MF-007): tracks # own/borrow resource-handle state to flag use-after-drop / double-drop. A # straight-line pass over the canonical-ABI `[resource-drop]` call sites # surfaces findings library-only on `AnalysisResult.handle_findings`. -scry-sai-handle = { path = "../scry-handle", version = "3.2.3" } +scry-sai-handle = { path = "../scry-handle", version = "3.2.4" } # FEAT-058: the linear-memory segmentation domain (content-sensitive memory). # The interpreter tracks per-offset interval content for i32 loads/stores # instead of degrading every load to ⊤. -scry-sai-segment = { path = "../scry-segment", version = "3.2.3" } +scry-sai-segment = { path = "../scry-segment", version = "3.2.4" } [dev-dependencies] # Test-only (the crate is otherwise dep-light + no_std): assemble the .wat diff --git a/crates/scry-analyze-core/src/lib.rs b/crates/scry-analyze-core/src/lib.rs index 17e49df..ec8dc18 100644 --- a/crates/scry-analyze-core/src/lib.rs +++ b/crates/scry-analyze-core/src/lib.rs @@ -856,7 +856,7 @@ mod domain { scry_taint::join(a, b) } } -const SCRY_VERSION: &str = "3.2.3"; +const SCRY_VERSION: &str = "3.2.4"; const INVARIANT_SCHEMA_URL: &str = "https://pulseengine.eu/scry-invariants/v1"; /// Default Wasm linear-memory page size (64 KiB). diff --git a/crates/scry-segment/Cargo.toml b/crates/scry-segment/Cargo.toml index ad41efd..80c8bc0 100644 --- a/crates/scry-segment/Cargo.toml +++ b/crates/scry-segment/Cargo.toml @@ -20,4 +20,4 @@ path = "src/lib.rs" # The per-segment content domain. Path dep carries `version` so `cargo publish` # rewrites it to the crates.io coordinate; the version equals the workspace # version and is bumped in lockstep. -scry-sai-interval = { path = "../scry-interval", version = "3.2.3" } +scry-sai-interval = { path = "../scry-interval", version = "3.2.4" } diff --git a/crates/scry-viz/Cargo.toml b/crates/scry-viz/Cargo.toml index e63b32b..59920d9 100644 --- a/crates/scry-viz/Cargo.toml +++ b/crates/scry-viz/Cargo.toml @@ -22,7 +22,7 @@ path = "src/main.rs" # The only dependency: the published analyzer library. scry-viz is a plain # `std` host tool, so it can read the `AnalysisResult` plain-Rust types and # render them — no WIT, no component, no wasmtime. -scry-sai-core = { path = "../scry-analyze-core", version = "3.2.3" } +scry-sai-core = { path = "../scry-analyze-core", version = "3.2.4" } # Assemble `.wat` inputs to module bytes (so the CLI accepts both .wat and # .wasm); host-only, same dep the test harness uses. wat = { workspace = true } diff --git a/crates/scry-viz/src/lib.rs b/crates/scry-viz/src/lib.rs index a90bc7c..422a3ac 100644 --- a/crates/scry-viz/src/lib.rs +++ b/crates/scry-viz/src/lib.rs @@ -44,6 +44,14 @@ const HERO_TITLE: &str = "scry — a sound static analyzer for WebAssembly"; /// points and a "… showing N of M" note. const POINTS_PER_FN_CAP: usize = 20; +/// Row cap for the flat list/table sections (Diagnostics, Call graph, Trap +/// checks, Functions, gaps, …). scry-on-scry produces thousands of rows in each +/// (Info diagnostics per bounds-check, 1349 call edges, thousands of trap +/// checks); un-capped they are megabytes of noise. Each section shows the first +/// `SECTION_ROW_CAP` rows and a "showing N of M" note; the actionable subset is +/// in the guidance.json feed. +const SECTION_ROW_CAP: usize = 100; + /// Cap on the number of FUNCTIONS rendered with a detailed per-point table. /// scry-on-scry has ~800 functions, so a per-function cap alone still yields a /// multi-MB dump (800 × 20 rows). All four persona reviews found the raw @@ -383,7 +391,8 @@ fn render_functions(s: &mut String, r: &AnalysisResult) { .collect(); indices.sort_unstable(); indices.dedup(); - for idx in indices { + let n_funcs = indices.len(); + for idx in indices.into_iter().take(SECTION_ROW_CAP) { let meta = fn_meta(r, idx); let summary = r.function_summaries.iter().find(|f| f.func_index == idx); let stack: Option<&FunctionStack> = @@ -428,7 +437,9 @@ fn render_functions(s: &mut String, r: &AnalysisResult) { yesno(recursive), ); } - s.push_str(""); + s.push_str(""); + cap_note(s, n_funcs, "functions"); + s.push_str(""); } fn render_call_graph(s: &mut String, r: &AnalysisResult) { @@ -441,7 +452,7 @@ fn render_call_graph(s: &mut String, r: &AnalysisResult) { "\ ", ); - for e in &r.call_graph { + for e in r.call_graph.iter().take(SECTION_ROW_CAP) { // FEAT-027: resolve caller + target indices to named links so an edge // reads `1 $compute → 2 $helper`, and each end jumps to its row. let targets = if e.resolved_targets.is_empty() { @@ -466,9 +477,14 @@ fn render_call_graph(s: &mut String, r: &AnalysisResult) { ); } s.push_str("
callerpckindresolved targetssoundness
"); - // FEAT-028: a call-graph DIAGRAM. Inline SVG (self-contained, zero-JS) for - // graphs small enough to lay out cleanly; the Mermaid source for any size. - render_callgraph_diagram(s, r); + cap_note(s, r.call_graph.len(), "call edges"); + // FEAT-028: a call-graph DIAGRAM. Inline SVG for graphs small enough to lay + // out cleanly; the Mermaid source otherwise. Skipped entirely for very large + // graphs (a Mermaid source of thousands of edges is itself megabytes of noise + // — the edge table above + guidance.json feed carry the data). + if r.call_graph.len() <= SECTION_ROW_CAP { + render_callgraph_diagram(s, r); + } s.push_str(""); } @@ -717,7 +733,7 @@ fn render_gaps(s: &mut String, r: &AnalysisResult) { "

{} site(s) where scry degraded a function to \u{22a4} (gave up).

"); + s.push_str(""); + cap_note(s, r.gaps.len(), "gaps"); + s.push_str(""); } /// FEAT-045: division/remainder trap classifications — scry's first runtime- @@ -759,7 +777,20 @@ fn render_trap_checks(s: &mut String, r: &AnalysisResult) { traps, r.trap_checks.len() - traps, ); - for t in &r.trap_checks { + // POTENTIAL-TRAPs first (the actionable proof obligations), then + // PROVEN-SAFE, capped — so the cap never hides a potential trap behind + // thousands of proven-safe rows. The tally above is exact; the full set is + // in the guidance.json feed. + let ordered = r + .trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::PotentialTrap) + .chain( + r.trap_checks + .iter() + .filter(|t| t.verdict == TrapVerdict::ProvenSafe), + ); + for t in ordered.take(SECTION_ROW_CAP) { let (cls, verdict) = match t.verdict { TrapVerdict::ProvenSafe => ("info", "PROVEN-SAFE"), TrapVerdict::PotentialTrap => ("err", "POTENTIAL-TRAP"), @@ -778,7 +809,9 @@ fn render_trap_checks(s: &mut String, r: &AnalysisResult) { esc(&t.op), ); } - s.push_str(""); + s.push_str(""); + cap_note(s, r.trap_checks.len(), "trap checks"); + s.push_str(""); } /// FEAT-059/060: the remediation Guidance panel — the actionable "what to do" @@ -895,7 +928,7 @@ fn render_handle_findings(s: &mut String, r: &AnalysisResult) { "

{} handle-lifetime fault(s).

"); + s.push_str(""); + cap_note(s, r.handle_findings.len(), "handle faults"); + s.push_str(""); } /// FEAT-047: sound float-interval facts for f32/f64 locals — the analyzer no @@ -926,7 +961,7 @@ fn render_float_facts(s: &mut String, r: &AnalysisResult) { "

{} sound float-interval fact(s).

"); + s.push_str(""); + cap_note(s, r.float_facts.len(), "float facts"); + s.push_str(""); } /// FEAT-044: proven Pentagons strict relations — the `index < length` guards @@ -958,7 +995,7 @@ fn render_pentagon_facts(s: &mut String, r: &AnalysisResult) { if region.

"); + s.push_str(""); + cap_note(s, r.pentagon_facts.len(), "relational guards"); + s.push_str(""); } fn render_diagnostics(s: &mut String, diags: &[Diagnostic]) { @@ -986,7 +1025,7 @@ fn render_diagnostics(s: &mut String, diags: &[Diagnostic]) { return; } s.push_str(""); + s.push_str(""); + cap_note(s, diags.len(), "diagnostics"); + s.push_str(""); +} + +/// If `total` exceeds [`SECTION_ROW_CAP`], append the "showing N of M" note that +/// every capped flat section shares. +fn cap_note(s: &mut String, total: usize, what: &str) { + if total > SECTION_ROW_CAP { + let _ = write!( + s, + "

… showing {SECTION_ROW_CAP} of {total} {what}.

", + ); + } } /// FEAT-030: taint (noninterference) findings. Rendered only when there ARE @@ -1984,6 +2036,45 @@ mod tests { ); } + #[test] + fn all_flat_sections_capped_page_stays_small() { + // The lesson from v3.2.2/v3.2.3: capping ONE section is not enough — the + // deployed self-analysis was still ~5 MB from Diagnostics / Call graph / + // Trap checks. This test blows up EVERY large flat section and asserts + // the whole page stays under 1 MB, so a future uncapped section fails + // here instead of on the deployed site. + let mut r = analyze_wat( + "(module (memory 1) (func $h) \ + (func (export \"run\") (param i32) (result i32) \ + i32.const 4 i32.const 0 i32.store \ + call $h \ + i32.const 10 local.get 0 i32.div_s))", + ); + fn blow(v: &mut Vec, n: usize) { + if let Some(first) = v.first().cloned() { + while v.len() < n { + v.push(first.clone()); + } + } + } + blow(&mut r.diagnostics, 4000); + blow(&mut r.trap_checks, 4000); + blow(&mut r.call_graph, 4000); + blow(&mut r.advisories, 4000); + blow(&mut r.gaps, 4000); + blow(&mut r.handle_findings, 4000); + blow(&mut r.float_facts, 4000); + blow(&mut r.pentagon_facts, 4000); + let html = render_html(&r, "big-all"); + assert!( + html.len() < 1_000_000, + "every flat section must be capped so the page stays <1 MB; was {} bytes", + html.len() + ); + // Cap notes prove the sections were actually large-but-capped. + assert!(html.contains("… showing 100 of"), "cap notes present"); + } + #[test] fn guidance_json_is_well_formed_and_carries_advisories() { // A div by an unknown divisor yields an UnprovenObligation advisory +