From a7557faa06daeaeeeb4a467669128f3574970845 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 19:45:57 +0000 Subject: [PATCH 01/33] Add multi-level test harness foundation (L0/L1/L2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A reusable, multi-level test architecture for the symbolic-executor, with the shadow-heap redesign as its first consumer. Levels are chosen at the lowest faithful altitude per behavioral case. Design: - docs/test-architecture.md — 4-level architecture, oracle rules, @PendingFeature red-first convention, seams, build order, per-case map. - docs/heap-redesign-{plan,tests,testing-setup}.md — the behavioral case matrix. Production seams (durable; the Phase-1 registry reimplements the same views): - SymbolicTraceHandler.isSymbolicContextLoss()/isReferenceSemanticChange() - JVMHeap.size()/values() + ShadowContext.heapSize()/heapLookup()/heapEntries() L0 (value/structure unit): BaseValueSpec (prover SAT/UNSAT oracles) + ObjectIdentitySpec (O-4). L1 (processor): executeBoundaryRecovery() fixture + HeapRecoveryV1Spec (V-1). L2 (forked agent, solver.mode=PRINT): AgentRun + TraceObservation + HeapRecoveryV1AgentSpec + ToLowerCaseTarget; opt-in `agentTest` Gradle task (excluded from `test`). Expected-red cases use @PendingFeature so the suite stays green and each red flips to a build failure once its fix lands. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/skills/swat-test/SKILL.md | 76 ++++++ docs/heap-redesign-plan.md | 72 ++++++ docs/heap-redesign-testing-setup.md | 83 +++++++ docs/heap-redesign-tests.md | 79 ++++++ docs/test-architecture.md | 228 ++++++++++++++++++ symbolic-executor/build.gradle | 15 ++ .../uzl/its/swat/symbolic/shadow/JVMHeap.java | 19 ++ .../swat/symbolic/shadow/ShadowContext.java | 27 +++ .../symbolic/trace/SymbolicTraceHandler.java | 20 ++ .../swat/symbolic/heap/BaseValueSpec.groovy | 62 +++++ .../heap/HeapRecoveryV1AgentSpec.groovy | 41 ++++ .../symbolic/heap/HeapRecoveryV1Spec.groovy | 57 +++++ .../symbolic/heap/ObjectIdentitySpec.groovy | 44 ++++ ...aseSymbolicInstructionProcessorSpec.groovy | 53 ++++ .../swat/testsupport/agent/AgentRun.groovy | 91 +++++++ .../testsupport/agent/TraceObservation.groovy | 37 +++ .../resources/targets/ToLowerCaseTarget.java | 25 ++ 17 files changed, 1029 insertions(+) create mode 100644 .claude/skills/swat-test/SKILL.md create mode 100644 docs/heap-redesign-plan.md create mode 100644 docs/heap-redesign-testing-setup.md create mode 100644 docs/heap-redesign-tests.md create mode 100644 docs/test-architecture.md create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BaseValueSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/AgentRun.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy create mode 100644 symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java diff --git a/.claude/skills/swat-test/SKILL.md b/.claude/skills/swat-test/SKILL.md new file mode 100644 index 0000000..d9b089f --- /dev/null +++ b/.claude/skills/swat-test/SKILL.md @@ -0,0 +1,76 @@ +--- +name: swat-test +description: Create and run tests for the SWAT symbolic-executor at the right abstraction level (L0 value-unit, L1 processor, L2 forked-agent). Use when adding a test for shadow values, the shadow interpreter, recovery/heap behavior, soundness flags, or a whole-program agent run — or when deciding which level a behavior belongs at. Covers the fixtures, the oracle rules, the @PendingFeature red convention, and the run commands. +--- + +# Testing SWAT (symbolic-executor) + +Full rationale: `docs/test-architecture.md`. This skill is the operational recipe. + +## Pick the level (lowest faithful altitude) + +| Level | Use when the behavior lives in… | Driver | +|---|---|---| +| **L0** value/structure unit | a `Value` method or shadow structure (`StringValue.IF_ACMPEQ`, `ObjectValue.equals`, `JVMHeap` key, copy-ctor, UF SMT-agreement) | construct objects directly | +| **L1** processor | how the shadow interpreter reacts to an instruction stream (lift, **invoke→recovery**, branches, fields) | drive `SymbolicInstructionProcessor` | +| **L2** forked-agent | the real instrumentation→trace contract (real identity hashes, this-return, soundness flags) | run a real target under the agent | +| L3 end-to-end | the SV-COMP verdict + downgrade rules | sv-comp driver (out of current scope) | + +If unsure between L1 and L2: L1 *fabricates* the instruction stream (incl. address collisions), so it pins interpreter logic, not the instrumentation contract — use L2 to anchor anything whose bug depends on real JVM identity/this-return. + +## Oracle rules (every level — non-negotiable) + +Assert ONLY on: `Value.concrete`, `isSymbolic()`, symbolic **variable names** +(`extractVariables(f).keySet()`), `IF_ACMPEQ`/`equals` booleans, soundness flags, `Frame` +contents, structured `TraceDTO` fields, or **SAT/UNSAT agreement** via a real prover. +**Never** assert on a formula's SMT sort/representation (that is why the ~1670 `de/uzl/its/value/**` +rows break en masse). + +## Expected-red convention (`@PendingFeature`) + +There is no `spock.lang.Tag` in this Spock version. For a behavior not yet implemented (red-first +TDD), annotate the feature `@PendingFeature(reason = "…")`: it runs and asserts the *desired* +behavior, is reported **pending (skipped), not a failure**, and **fails the build the moment it +starts passing** (your "fix landed" signal). Put currently-true preconditions first, or as a +*separate non-pending* feature, so an infra break is a real failure, not masked as pending. +Add `@See("docs/heap-redesign-tests.md")` to link the behavioral case. + +## How to add a test + +**L0** — extend `de.uzl.its.swat.symbolic.heap.BaseValueSpec` (gives `context`, `fmgr`, and the +`isValid(BooleanFormula)` / `isUnsatisfiable(BooleanFormula)` boolean oracles). Construct values +with `context`. Example: `ObjectIdentitySpec` (O-4). + +**L1** — extend `de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec`. Call +`setupTestContext(className, method)` first. For an unmodeled-call recovery, use the fixture: +```groovy +def result = executeBoundaryRecovery(receiver, owner, name, desc, concreteResult, resultAddress) +// result.recovered (Value), result.contextLoss, result.referenceSemanticChange +// resultAddress == receiver.address -> this-return; fresh address -> new-object return +``` +Example: `HeapRecoveryV1Spec` (V-1). + +**L2** — add a tiny target to `src/test/resources/targets/.java` using `@Symbolic` on a +**method parameter** (a local needs `-g` and may crash the annotation transformer). Name the spec +`*AgentSpec` and use the harness: +```groovy +TraceObservation obs = AgentRun.run("targets/.java", "") +// obs.symbolicContextLoss / symbolicPrecisionLoss / referenceSemanticChange +// obs.inputNames ; obs.anyBranchReferences(inputVar) +``` +`AgentRun` compiles against the agent jar, forks a JVM with `solver.mode=PRINT`, and parses the +TraceDTO. Example: `HeapRecoveryV1AgentSpec`. + +## Run + +```bash +# L0 + L1 (fast, in-process, CI lane). Scope to your specs to skip the legacy value tests. +./gradlew :symbolic-executor:test --tests "de.uzl.its.swat.symbolic.heap.*" +./gradlew :symbolic-executor:test --tests "de.uzl.its.swat.symbolic.processor.*" + +# L2 (forked agent; builds the jar first; *AgentSpec only; excluded from `test`) +./gradlew :symbolic-executor:agentTest +``` + +Build the agent jar (when needed) with `:symbolic-executor:copyJar` — **never** `spotlessApply` +(it reformats the whole module). CI runs `copyNativeLibs` then `test`. diff --git a/docs/heap-redesign-plan.md b/docs/heap-redesign-plan.md new file mode 100644 index 0000000..77f8977 --- /dev/null +++ b/docs/heap-redesign-plan.md @@ -0,0 +1,72 @@ +# Shadow Heap Redesign — High-Level Plan + +Status: design in progress (no code yet). This doc captures *what* we want, *in what order*, and *what to check along the way*. Failure-point enumeration and test design come next, in separate sections. + +## Purpose + +Redesign the shadow heap so it stops mis-tracking reference objects. The visible symptom is `String.toLowerCase()` returning `this` for no-op inputs, which causes the unmodeled result to be re-bound to its symbolic receiver's formula. A Phase-0 audit (below) showed this is one of a *cluster* of confirmed soundness bugs in the current identity-hash-keyed recovery cache. + +## Problem (Phase-0 audit, confirmed) + +The heap is `Map` used as a *recovery cache*, not a canonical store; frames hold direct `ObjectValue` references. Confirmed latent bugs: + +- **(a) Duplicate `ShadowObject` per concrete object** — `visitNEW` / `visitLDC_String` push without registering; canonical registration is incidental. **Kicker:** object `IF_ACMPEQ/NE` use `this == o2` on the *wrapper* (not the address), so a duplicate wrapper makes reference comparison **wrong** → canonical-heap is a *correctness* fix, not cleanup. +- **(b) Field-map desync on escape** — no havoc/invalidate exists; a tracked object mutated in unmodeled code keeps stale shadow fields. +- **(c) `identityHashCode` collision + no eviction** — 31-bit key, plain `HashMap`, never cleared → distinct objects merge; dead-hash reuse. +- **(d) Unregistered objects** — `NEW`/arrays/`LDC_String` register only incidentally. + +Performance: the GETVALUE/heap path is very hot (probe after every ref load/field-read/ref-returning-invoke); the common path does **no** heap lookup today. ⇒ Full frame-indirection is a **non-goal** (it would add a map lookup per access). No perf baseline exists in the repo. + +## Goals + +- **G1 — Canonical registry + cached pointers.** One `ShadowObject` per concrete identity via find-or-create at every introduce/recover site; a faithful, collision-free, GC-evicting key; register-on-create. Frames keep direct references as cached pointers (no per-access lookup). Fixes (a)(c)(d) + the `==` bug. +- **G_oob — Out-of-band change detection (no havoc in v1).** Detect when a tracked object's concrete state diverges from its shadow (e.g. mutated in unmodeled code) and flag it, **in all execution modes**. v1 detects only — no havoc/repair. Addresses (b). +- **G2 — Value-type boundary recovery (dealiasing model).** Each cell stores the *set* of formulas an identity has been associated with + the concrete. Recovery policy is swappable: **v1 = collapse** (single → use; multiple/none → concrete + context-loss flag); **v2 = branch over candidates** (needs explorer disjunction). Fixes the `toLowerCase` aliasing while preserving the legit single-φ round-trip. +- **G3 — Output-boundary de-interning.** De-intern value-typed method *returns* (not just literals) to shrink candidate-set sizes and remove this-return aliasing at instrumented call sites. +- **G4 — Purity whitelist + UF (feature).** + - **G4a** — a whitelist of stateless/deterministic library methods, used to (i) skip escape-havoc for pure calls and (ii) refine leak / context-loss detection. + - **G4b** — apply an (uninterpreted) function model for whitelisted pure methods at the boundary → a single precise, relational formula instead of concrete. + +**Removed from scope:** havoc as a recovery tier (boundary recovery is {modeled / UF-if-whitelisted / concrete} only); and havoc-on-escape (v1 *detects* out-of-band changes, does not havoc/repair); and branching over aliased φs (v1 realizes/records them but recovers concrete). + +### Methods-to-model policy + +Default for an unmodeled method = concrete + context-loss flag, **documented as missing — not modeled.** We model a method only when a test needs its deeper semantics. Committed methods-to-model list (grows only as deeper tests demand): +- `java.lang.String.(String)` — the copy constructor (test V-5): a content copy must keep the source's symbolic value. + +## Order of work + per-phase checks + +| Phase | Work | Checks before moving on | +|---|---|---| +| 0 ✓ | Audit current implicit handling | 4 bugs confirmed; perf characterized | +| 1 | **G1** canonical registry, faithful key, register-on-create | one wrapper per identity (assert); object `==` correctness; **establish perf baseline + measure delta**; existing suite green | +| 2 | **G2** cell stores φ-set; **v1 collapse** recovery | `toLowerCase` result no longer aliases receiver φ; single-φ round-trip still recovers; context-loss flagged on collapse; **log ambiguity frequency + candidates** (feeds v2 go/no-go) | +| 3 | **G_oob** out-of-band change detection (no havoc) | mutation in unmodeled code → divergence detected + flagged on next read, all modes; pure call → no false detection | +| 4 | **G4a** purity whitelist → wire into escape + leak/context-loss | pure calls don't over-flag / don't trigger escape-havoc; impure-with-symbolic arms contamination | +| 5 | **G3** output-boundary de-interning | candidate-set sizes shrink (measure); monitor `reference_semantic_change` cost | +| 6 | **G4b** UF application for whitelisted methods | whitelisted method recovers precise UF formula; UF-soundness tests (agreement / no-over-constraint / partiality) | +| v2 | explorer disjunction → branch-over-candidate recovery | decided by Phase-2 ambiguity measurements | + +## Cross-cutting checks + +- **Perf:** establish a per-instruction overhead baseline at Phase 1 (none exists); gate each phase on no material regression. +- **Soundness invariant:** every reachable shadow value's concrete component matches reality and its formula soundly abstracts it (over-approximate where flagged); context-loss is flagged whenever recovery is non-exact. +- **Faithful-key impl** (weak `IdentityHashMap` vs injected shadow-id field vs JVMTI tag): decided **within this PR**; default to weak `IdentityHashMap` unless perf dictates otherwise. + +## Notation (shared vocabulary) + +- `v = (c(v), φ(v))` — shadow value: concrete + formula (`φ = ⊥` ⇒ concrete-only). +- `id(r)` — concrete object identity (faithful key, not the 31-bit hash). +- `H : Id ⇀ Cell` — canonical heap. Cell content: stateful object = a `ShadowObject` (field map); value type = the φ-set + concrete. +- "structural" recovery = value still in stack/locals/field-map (exact, no heap); "boundary" = mirrored invoke of an unmodeled method; "re-entry" = a reference returns from unmodeled code as a placeholder. + +## Open questions + +- Faithful-key implementation choice (in-PR decision). +- De-intern aggressiveness vs the `reference_semantic_change` soundness cost. +- Whether/when to build v2 candidate-branching (informed by Phase-2 measurements). + +## Next + +1. Enumerate all failure points & possible failure points (the case taxonomy: object cases O1–O5, value cases V1–V7, escape/boundary cases M1–M9) and map each to a phase. +2. Design tests against the finalized notation (incl. the UF-soundness category). diff --git a/docs/heap-redesign-testing-setup.md b/docs/heap-redesign-testing-setup.md new file mode 100644 index 0000000..69e2a35 --- /dev/null +++ b/docs/heap-redesign-testing-setup.md @@ -0,0 +1,83 @@ +# Shadow Heap Redesign — Testing Setup Analysis + +Initial analysis of *how* to test the behavioral cases in `heap-redesign-tests.md` (the *what*). Produced as a starting point; the human will refine/develop tests from here. **Tests are pre-development (red-first/TDD): many SHOULD fail against current code — that is the expected signal that the fix is pending.** + +## Environment (verified) + +- Current branch is **`dev`**. `stats.json` (consolidated per-testcase stats) exists **only on `feat/svcomp-testcase-metadata`**, not `dev`. ⇒ For in-process tests, observe soundness flags directly off `SymbolicTrace`/`TraceDTO` rather than depending on `stats.json`. +- Z3 native libs present (`libs/java-library-path/`); `copyNativeLibs` already run. +- `sv-benchmarks` is checked out (R-1 candidates: `securibench/.../sanitizers/Sanitizers5.java`, `autostub/String_..._toLowerCase`). +- Tests = **Spock/Groovy only** (`symbolic-executor/src/test/groovy`, 15 specs). CI runs exactly `./gradlew copyNativeLibs` then `./gradlew test`. The `build` job uses `build -x test`. Avoid `spotless*`. +- Test JVM sets `exitOnError=false` (SWAT errors surface as catchable exceptions, not JVM halt) — `symbolic-executor/build.gradle:49-52`. + +## Robust model to emulate / anti-pattern to avoid + +- **Emulate:** the processor/shadow-state specs (`symbolic/processor/*Spec.groovy` + `BaseSymbolicInstructionProcessorSpec.groovy`). They drive the REAL `SymbolicInstructionProcessor.processInstruction()` over a constructed `ShadowContext` and assert on `Frame.operandStack`/`locals`/`ret`, `Value.concrete`, and symbolic VARIABLE NAMES via `extractVariables(formula).keySet()` (`InternalInvocationSpec.groovy:68`). `setupTestContext()` (`:54`) uniques method names per test — new specs MUST use it. +- **Avoid:** `de/uzl/its/value/**` (~1670 rows) assert on `.formula` via sort-specific managers (`bvmgr.equal`) → break en masse on representation churn. +- **Oracle rule:** assert on `Value.concrete`, `isSymbolic()`, variable *names*, boolean soundness flags, `Frame` contents, `IF_ACMPEQ`/`equals` booleans — NEVER on formula SMT sorts. + +## Where the bugs live (code under test) + +- Recovery cache `shadow/JVMHeap.java` — bare `HashMap`, only `put/get(hashCode)`, **no map/size/iteration getter, ZERO tests**. Via `ShadowContext.putToHeap/getFromHeap` (`:48-54`). +- **V-1 core bug path:** `SymbolicInstructionVisitor.visitGETVALUE_Object` (`:1265-1340`) — unmodeled result returns as `PlaceHolder`; `tmp = stack.getFromHeap(inst.address)` (`:1270`); if non-null, pushes the cached `Value` (`:1284`). `invokeToLowerCase` returns `PlaceHolder.instance` (`StringValue.java:1125`); `toLowerCase()` returns `this` for already-lowercase input ⇒ result shares receiver identity ⇒ recovers receiver's `StringValue` ⇒ aliases `s`. +- **`==` wrapper bug (O-4):** `ObjectValue.IF_ACMPEQ` uses `this == o2` on the wrapper (`~:153`); `equals()` compares `address == other.address && fields.length == other.fields.length` (`:131`). Duplicate wrappers ⇒ `IF_ACMPEQ` wrongly false; `equals` comparing only `fields.length` is a latent O-5 smell. +- **Soundness flags:** `trace/SymbolicTrace.java:26,29` booleans `symbolicContextLoss`/`referenceSemanticChange`; set via `SymbolicTraceHandler.recordSymbolicContextLoss()` (`:206`)/`recordReferenceSemanticChange()` (`:216`); mirrored on `TraceDTO.java:16,20`. +- **Flag-decision logic (F-1..F-4):** `invoke/InvocationHandler.invoke()` (`:38-121`) — `containsSymbolicArgument` (`:53-63`); records context-loss only when result is unmodeled placeholder AND a symbolic arg present (`:107-117`); missing invocations via `recordMissingInvocation` (`:99`). +- **E2E verdict:** scraped from STDOUT `[VERDICT ]` (`target_execution.py:116`). + +## Recommended architecture — three levels (stay on Spock for A/B) + +- **Level A — pure unit** (build `ObjectValue`/`StringValue`/heap directly). Cases: O-4, O-5, D-3, V-5, V-6, V-9, the canonical "one wrapper per identity" invariant. +- **Level B — processor-driven shadow-state spec** (the workhorse). Cases: V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1. Richest source of expected-reds. +- **Level C — whole-program E2E** (NOT in CI; STDOUT-scraping today). Cases: R-1, R-2, D-2, E-1's "all modes" clause. + +## New infrastructure to build (in order of blocking-ness) + +1. **Heap/registry inspection seam (blocks G1 tests).** `JVMHeap`/the new registry needs size/iteration/identity-count. Recommendation: define on the NEW registry API; write O-tests as expected-reds against it so they go green when Phase 1 lands. +2. **Boundary-recovery fixture (highest leverage).** Generalize `executeLiftInsnSeq` (`BaseSymbolicInstructionProcessorSpec.groovy:214`) to the object/recovery path: push a (symbolic) receiver, run unmodeled-invoke → `INVOKEMETHOD_END` → `GETVALUE_Object` for a given concrete result + address, return recovered `Value` + flag snapshot. Reused by V-1/V-2/V-3/V-4/O-2/O-3/F-3. +3. **Thin programmatic E2E harness (only for R/D-2/E-1-all-modes).** Run one target through agent+explorer, return `{verdict, contextLoss, referenceSemanticChange}` structured. Heaviest lift; possibly Python; not CI-gated. + +## Per-case mapping + expected status NOW + +| Case | Level | New infra | Status now | +|---|---|---|---| +| O-1 aliasing/field write | B | fixture | likely RED | +| O-2 re-entry same object | B | fixture | RED | +| O-3 born-in-unmodeled | B | heap getter + fixture | RED | +| O-4 ref-equality | A | — | **RED** (`this==o2`) | +| O-5 hash collision | A/B | heap getter | RED | +| O-6 identity reuse after death | B | heap getter (eviction) | RED | +| V-1 this-return no-alias (core) | B | fixture | **RED** | +| V-2 new-object consistency | B | fixture | RED | +| V-3 single-φ round-trip | B | fixture | possibly GREEN (verify) | +| V-4 conflicting φ → concrete | B | φ-set (Phase 2) | RED | +| V-5 `new String(String)` keeps φ | A | — | likely **GREEN** (`invokeInit` copies, `StringValue.java:218`) | +| V-6 interned literal reuse | A | — | verify (likely GREEN) | +| V-7 boxed analogue | B | fixture | RED | +| V-8 primitive context loss | B | fixture | partially GREEN | +| V-9 concrete grounding | A/B | — | GREEN | +| E-1 out-of-band detect (all modes) | C (+B) | E2E harness | RED | +| E-2 pure call no false flag | B | purity whitelist (Phase 4) | N/A yet | +| F-1 no symbolic → no flag | B | — | likely GREEN | +| F-2 symbolic → flag | B | — | likely GREEN | +| F-3 leak-then-retrieve | B | fixture | RED (flags at call, not retrieval) | +| F-4 pure symbolic no leak | B | purity whitelist | N/A yet | +| D-1 fresh identity on produced | A/B | — | RED (Phase 5) | +| D-2 de-intern verdict-neutral | C | E2E harness | needs E2E | +| D-3 ref-semantic-change only-when-warranted | A | — | verify | +| U-1..U-4 UF soundness | A/B | UF (Phase 6) | N/A yet | +| U-5 modeled-result precision | B | whitelist+UF | N/A yet | +| R-1 original failing case | C | E2E harness | **RED** | +| R-2 golden no-regression | C | E2E harness + golden set | baseline-dependent | + +Tag scheme: `@Tag` / naming `expected-red` vs `expected-green` + `phase-N`. Make expected-red tests fail on a SPECIFIC assertion (e.g. "result must not contain var X"), never on a setup exception — so expected-red is distinguishable from infra-broken. + +## Decisions for the human (ordered) + +1. Heap-inspection seam: add to legacy `JVMHeap` now, or define on the new registry API (recommended → write O-tests as expected-reds against it). +2. Build the boundary-recovery fixture before V-*/O-2/O-3/F-3 (shared by all). +3. E2E scope: build a structured harness vs defer Level C to a non-CI regression lane (recommended: keep CI to A/B). +4. Branch: observe flags in-process off `TraceDTO`/`SymbolicTrace` to avoid the `stats.json`/branch dependency (recommended). +5. Phase gating: author in-process expected-reds now (tagged by phase) vs defer until each phase begins (recommended: author now). + +Suggested start: heap/registry inspection seam → boundary-recovery fixture → V-1 as the first concrete expected-red. diff --git a/docs/heap-redesign-tests.md b/docs/heap-redesign-tests.md new file mode 100644 index 0000000..e026368 --- /dev/null +++ b/docs/heap-redesign-tests.md @@ -0,0 +1,79 @@ +# Shadow Heap Redesign — Test Specification (behavioral) + +Harness-agnostic. Each case is **a scenario + the property that must hold** — *what* we want to verify, not *how* to observe or run it. Setting up the harness/levels for these is a separate task. + +Notation: a *symbolic value* carries a formula over program inputs; a *concrete value* is the observed runtime value; "shadow object" = the symbolic model of a reference object; "flag" = a soundness flag (context-loss / reference-semantic-change); "unmodeled call" = a method SWAT has no model for; "boundary" = an instrumented call site invoking unmodeled code; "re-entry" = a reference returning from unmodeled code. v1/v2 mark staged expectations. + +**Policy — model only what a test needs.** The default for an unmodeled method is concrete + context-loss flag (V-1/V-8); we simply *document it as missing*. We model a method only when a test needs its deeper semantics — those go on an explicit **methods-to-model** list (in the plan doc). `new String(String)` (V-5) is the first committed entry. Most methods will stay unmodeled, and that is fine and expected. + +--- + +## G1 — object identity / canonical heap (stateful objects) + +- **O-1 Aliasing through locals/fields.** *Scenario:* one object reachable via two references; a field written through one. *Expect:* the write is visible through the other; exactly one shadow object represents the concrete object. +- **O-2 Re-entry recovers the same object.** *Scenario:* a tracked object passes into unmodeled code and the same concrete object returns. *Expect:* the recovered shadow object is the same instance (its field state preserved), not a fresh empty duplicate. +- **O-3 Object born in unmodeled code.** *Scenario:* unmodeled code returns a never-before-seen object. *Expect:* a fresh shadow object with unknown (not fabricated) fields; subsequent sightings of that same concrete object recover the same shadow object. +- **O-4 Reference-equality correctness.** *Scenario:* `==` between (i) two references to one object, (ii) two references to distinct objects — for objects born locally and objects re-entered from unmodeled code. *Expect:* (i) true, (ii) false, in all entry combinations. +- **O-5 Distinct objects sharing an identity hash.** *Scenario:* two distinct live objects with a colliding identity hash. *Expect:* distinct shadow objects, no field cross-contamination, `==` between them false. +- **O-6 Identity reuse after death.** *Scenario:* a tracked object becomes unreachable; a new object later reuses its identity hash. *Expect:* the new object does not recover the dead object's shadow. + +## G2 — value-typed recovery (String + boxed wrappers + primitives) + +- **V-1 `this`-return must not alias receiver (the core bug).** *Scenario:* symbolic string `s`, concretely already-lowercase, receiver of an unmodeled `toLowerCase()` that returns `this`. *Expect (v1):* the result does not carry `s`'s symbolic value (it does not depend on `s`); it is concrete; a context-loss flag is raised; its concrete value equals the real result. *(v2: may be a UF of `s` if whitelisted — see U-5.)* +- **V-2 New-object transform is consistent with V-1.** *Scenario:* same as V-1 but `s` is concretely upper-case, so the call returns a *new* object. *Expect (v1):* identical outcome to V-1 (concrete + flag, no aliasing). The this-return vs new-object distinction must not change the symbolic result. +- **V-3 Single-formula round-trip preserved.** *Scenario:* symbolic string `s` stored into unmodeled space and retrieved unchanged, only ever associated with one formula. *Expect:* recovery yields `s`'s formula (precision kept), not a collapse to concrete. +- **V-4 Conflicting formulas collapse to concrete.** *Scenario:* one identity becomes associated with two or more distinct formulas (aliased φs). *Expect:* the multiplicity is *realized/recorded* (the φs are retained, per the data model), but recovery sticks to concrete + flag — we do **not** branch over the formulas. (Branching is a possible future direction, explicitly out of scope now.) +- **V-5 Program-made copy keeps the value (committed model).** *Scenario:* `t = new String(s)` for symbolic `s`. *Expect:* `t` carries `s`'s formula (a content copy is the same value). The copy constructor is one we **commit to modeling** → it goes on the methods-to-model list. (Contrast with the general policy above: most unmodeled methods are documented-as-missing, not modeled.) +- **V-6 Interned-literal reuse.** *Scenario:* the same literal used at several sites. *Expect:* every occurrence carries the same constant formula; no spurious aliasing or conflict. +- **V-7 Boxed-primitive analogue.** *Scenario:* symbolic `Integer`/`Long`; unmodeled method on it, and reuse of an autobox-cached instance. *Expect:* same rules as strings — the box cache / a this-return never transfers a wrong formula. +- **V-8 Primitive context loss.** *Scenario:* unmodeled method returns an `int` derived from a symbolic input. *Expect (v1):* concrete + flag — neither falsely symbolic nor falsely treated as independent. +- **V-9 Concrete grounding (universal).** *Scenario:* any recovery, any case above. *Expect:* the recovered value's concrete component always equals the real runtime value. + +## G_oob — out-of-band change detection (no havoc in v1) + +- **E-1 Detect out-of-band mutation.** *Scenario:* a tracked mutable object is passed to unmodeled code that mutates one of its fields; the field is then read in instrumented code. *Expect (v1):* the system **detects** the divergence (the observed concrete value no longer matches the shadow's stored value) and flags it — **in all execution modes**. It does not silently trust the stale shadow. We detect, not havoc or repair. +- **E-2 Pure call triggers no false detection.** *Scenario:* a tracked object passed to a pure (whitelisted) unmodeled method (no mutation). *Expect:* no out-of-band change is detected; no false flag. + +## G4a — context-loss / leak flagging precision + +- **F-1 Clean state, no symbolic input → no flag.** *Scenario:* unmodeled call with no symbolic receiver/args, nothing symbolic previously leaked. *Expect:* no context-loss flag (the result is genuinely concrete). +- **F-2 Symbolic input → flag.** *Scenario:* unmodeled call with a symbolic argument whose result is used. *Expect:* context-loss flagged at the point the lost value is used. +- **F-3 Leak then retrieve.** *Scenario:* a symbolic value passed into unmodeled space via a call with no usable result (leak); later a different unmodeled call retrieves a value from that space. *Expect:* no flag at the leak; flag at the retrieval. If no retrieval ever occurs, no flag at all. +- **F-4 Pure symbolic call → no leak.** *Scenario:* a pure whitelisted call with a symbolic argument. *Expect:* no contamination is armed; unrelated later retrievals are not flagged on its account. + +## G3 — output-boundary de-interning + +- **D-1 Fresh identity on produced values.** *Scenario:* an unmodeled value-returning call (or literal) at an instrumented site. *Expect:* the produced value has a fresh identity distinct from the inputs, so a this-return cannot alias the receiver there; its candidate set stays a singleton. +- **D-2 De-interning is verdict-neutral.** *Scenario:* a program run with and without output de-interning. *Expect:* the same verdict (de-interning never changes the soundness of the result). +- **D-3 Reference-semantic-change only when warranted.** *Scenario:* `==` on de-interned values. *Expect:* the reference-semantic-change flag fires only when de-interned `==` actually diverges from real reference semantics, not otherwise. + +## G4b — uninterpreted-function modeling + soundness + +- **U-1 Agreement.** *Scenario:* a UF-modeled method on sampled concrete inputs in its axiomatized domain. *Expect:* the UF result equals the real method's result for every sample. +- **U-2 No over-constraint.** *Scenario:* any input the real method admits. *Expect:* the path condition plus the UF's axioms stays satisfiable for it — the UF never excludes a real behavior. +- **U-3 Partiality outside the domain.** *Scenario:* an input outside the axiomatized domain (e.g. non-ASCII for case folding). *Expect:* the UF is unconstrained there — a model may choose a value differing from the real one (over-approximation, not a false pin). +- **U-4 Relational consistency.** *Scenario:* the same method applied twice to equal symbolic inputs. *Expect:* equal results (determinism captured) — the property concrete recovery would lose. +- **U-5 Modeled-result precision.** *Scenario:* a whitelisted method with a symbolic input. *Expect:* the result depends symbolically on that input (carries the UF), not collapsed to concrete. + +## Whole-program regression + +- **R-1 Original failing case.** *Scenario:* the `toLowerCase` securibench valid-assert case. *Expect:* correct verdict with context loss flagged — never a confident wrong verdict. +- **R-2 No regressions.** *Scenario:* a set of golden whole-program cases. *Expect:* verdicts unchanged by the redesign. + +--- + +## Coverage map (case → goal/phase) + +| Goal / phase | Cases | +|---|---| +| G1 canonical heap | O-1 … O-6 | +| G2 value recovery | V-1 … V-9 | +| G_escape | E-1, E-2 | +| G4a flag policy | F-1 … F-4 | +| G3 de-interning | D-1 … D-3 | +| G4b UF + soundness | U-1 … U-5 | +| regression | R-1, R-2 | + +Resolved: **V-5** — model the copy constructor (added to the methods-to-model list); general policy = document-as-missing for the rest. **E-1** — detect out-of-band changes (no havoc), in all modes. **V-4** — realize/record the multiple φs but recover concrete; no branching. + +Living artifact: the **methods-to-model list** (plan doc) grows only as deeper tests demand specific semantics; the default stays document-as-missing. diff --git a/docs/test-architecture.md b/docs/test-architecture.md new file mode 100644 index 0000000..49bec43 --- /dev/null +++ b/docs/test-architecture.md @@ -0,0 +1,228 @@ +# SWAT Test Architecture — Multi-Level Setup + +A reusable test architecture for the `symbolic-executor` module. It defines **four +abstraction levels**, the **fixtures/seams** each needs, and the **oracle rules** that keep +tests robust against representation churn. Written as a foundation (not heap-redesign-specific); +the shadow-heap redesign is its first consumer (see the per-case map at the end and +[`heap-redesign-tests.md`](heap-redesign-tests.md)). + +## Why levels (the core insight) + +A behavioral case is *"a program + a property."* The same property can be checked at very +different altitudes, and **they are not interchangeable** — a bug lives in a specific contract, +and only a test at that contract's altitude actually exercises it. Picking the *lowest faithful* +altitude per case gives fast, non-flaky tests; picking too low makes the test circular, too high +makes it slow and coarse. + +``` +L0 value & shadow-structure unit in-JVM, no instrumentation fast, CI +L1 shadow-interpreter / processor in-JVM, synthetic instructions fast, CI +L2 real-instrumentation single-run forked JVM, real agent slower, opt-in CI +L3 end-to-end / verdict agent + Python explorer nightly / manual +``` + +## Oracle rules (apply at every level) + +The legacy `de/uzl/its/value/**` suite (~1670 rows) is brittle because it asserts on +`.formula` via sort-specific managers (`bvmgr.equal`), so it breaks en masse on representation +changes. **Never do that.** Assert only on: + +- `Value.concrete` (the observed runtime value); +- `Value.isSymbolic()`; +- symbolic **variable names** via `solverContext.getFormulaManager().extractVariables(f).keySet()`; +- boolean results of `IF_ACMPEQ` / `IF_ACMPNE` / `equals`; +- soundness **flags** (`symbolicContextLoss`, `referenceSemanticChange`, `symbolicPrecisionLoss`); +- `Frame.operandStack` / `locals` / `ret` contents; +- structured `TraceDTO` fields (L2/L3); +- for SMT/UF agreement only: feed the formula to a real `ProverEnvironment` and assert + **SAT/UNSAT agreement** with the concrete result — never inspect the formula's sort. + +**Expected-red mechanism (Spock `@PendingFeature`).** This Spock version (2.2-M1-groovy-4.0) has +**no `spock.lang.Tag`**. Mark each red case `@PendingFeature(reason = " not yet +implemented; …")`: the feature runs and asserts the *desired* behavior, is reported as **pending +(skipped), not a failure**, while red — and **forces a build failure the moment it starts +passing** (the unambiguous "fix landed, remove the annotation" signal). Level/phase/case live in +the package + class name + `@See("docs/heap-redesign-tests.md")`. + +**An expected-red test MUST fail on a specific assertion** (e.g. "result vars must not contain +`S_x`"), never on a setup exception — otherwise `@PendingFeature` would mask infra breakage as +"pending". Enforce this with the **precondition-first / guard-feature convention**: assert +currently-true preconditions first (or as a *separate non-pending* feature, e.g. O-4's "distinct +objects compare unequal"), so an infra break surfaces as a real failure while the pending feature +isolates only the not-yet-implemented behavior. + +--- + +## L0 — Value & Shadow-Structure Unit + +**Scope.** `Value` subclasses (`StringValue`, `IntValue`, boxed wrappers, `ObjectValue`, +arrays) and the shadow data structures (`JVMHeap`, `Frame`, `ShadowContext`). No instrumentation, +no instruction stream — construct objects and call methods directly. + +**Tests well.** Value semantics: `IF_ACMPEQ`/`equals`, `invokeMethod`/`invokeInit` (e.g. the +`new String(String)` copy ctor, `StringValue.java:217`), `MAKE_SYMBOLIC`, heap `put/get` and key +behavior, UF-defining-constraint SMT agreement. + +**Driver.** A shared `BaseValueSpec` (extract from the existing `StringValueTest.setup`): +`ThreadHandler.init()` → `addThreadContext(currentThread().id, "Test-Thread", -2)` → +`getSolverContext` → expose `fmgr/bmgr/smgr/...` and a fresh `ProverEnvironment`; `cleanup` +closes the prover/context and removes the thread context. For UF agreement, pull +UF-defining constraints from the trace +(`ThreadHandler.getSymbolicTraceHandler(id).getConstraints()`) into the prover — see +`StringValueTest.addUFConstraintsFromTrace`. + +**Oracle.** Per the rules above. Exemplar already in repo: `StringValueTest.groovy`. + +**Heap cases here:** O-4, O-5, D-3, V-5, V-6, V-9, the "one wrapper per identity" invariant. + +--- + +## L1 — Shadow-Interpreter / Processor (the workhorse) + +**Scope.** `SymbolicInstructionProcessor.processInstruction()` driven over a constructed +`ShadowContext`. Feed a synthetic `Instruction` sequence; assert on how the operand stack / +locals / frame / heap / trace evolve. + +**Tests well.** The reaction of the shadow interpreter to instruction sequences: lifting a +symbolic input, the **invoke → recovery** path (`INVOKEVIRTUAL`(unmodeled) → `INVOKEMETHOD_END` +→ `GETVALUE_Object`), branches, field/array ops. + +**Driver.** `BaseSymbolicInstructionProcessorSpec` (existing) provides `setupTestContext` +(unique method names per test — required), the `push*Operand` helpers, and `executeLiftInsnSeq` +(introduce a symbolic input). **New fixture to add — the boundary-recovery fixture** (highest +leverage; shared by V-1/V-2/V-3/V-4/O-2/O-3/F-3): + +``` +executeBoundaryRecovery(receiver, owner, name, desc, concreteResult, resultAddress) + // 1. register `receiver` on the heap at its address (stack.putToHeap) + // 2. push receiver as the invoke operand + // 3. process: INVOKEVIRTUAL(owner,name,desc) ; INVOKEMETHOD_END ; + // GETVALUE_Object(resultAddress, concreteResult, i) + // 4. return { recovered: peekOperand, contextLoss, referenceSemanticChange, heap snapshot } +``` + +This drives the *real* bug path: an unmodeled invoke returns `PlaceHolder` +(`InvocationHandler.invoke`, `StringValue.invokeToLowerCase:1126`), `INVOKEMETHOD_END` pushes it +(`SymbolicInstructionVisitor:3406`), and `visitGETVALUE_Object` (`:1265`) recovers from the +identity-keyed heap — the aliasing defect. + +**Honest caveat.** L1 *fabricates* the instruction stream, including the +`resultAddress == receiver.address` collision that a real `this`-return produces. So an L1 +recovery test pins the **interpreter's recovery logic**, not the instrumentation→processor +contract, and bakes in our assumption of the emitted sequence. Therefore: pair the flagship +recovery case (V-1) with an **L2 anchor** that gets the collision from the real JVM. + +**Seams required (see Cross-cutting).** Heap-inspection view on `ShadowContext`; flag getters on +`SymbolicTraceHandler`. + +**Oracle.** L0 rules + `Frame`/heap-view/flags. Exemplars: `InternalInvocationSpec`, +`INVOKEVIRTUALSpec`, `IADDSpec`. + +**Heap cases here:** V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1. + +--- + +## L2 — Real-Instrumentation / Single-Run (forked JVM, structured TraceDTO) + +**The faithful altitude for recovery/identity bugs.** The real ASM agent runs a real tiny target +program; identity hashes, the `this`-return, the GETVALUE sequence, and the soundness flags all +come from the real JVM + instrumenter — nothing is hand-fabricated. + +**Key enabler (verified).** `solver.mode=PRINT` makes `Intrinsics.terminate()` do +`System.out.println(getTraceDTO())` (`Intrinsics.java`, terminate() PRINT branch) — the full +`TraceDTO` JSON on stdout, **no Python explorer needed**. `LOCAL` mode (default) instead solves +in-JVM with Z3 via `LocalSolver.solve()`. `SolverMode = {LOCAL, HTTP, PRINT, NONE}`. + +**Driver — new `AgentRunFixture`:** + +1. Build the agent jar first: `./gradlew :symbolic-executor:copyJar` + → `symbolic-executor/lib/symbolic-executor.jar` (never `spotlessApply` — see + [the spotless note](#); build with `copyJar`). +2. Target programs live in `src/test/resources/targets/.java` (tiny, hand-written). + Designate symbolic inputs with `Verifier.nondetInt(long id)` / `nondetString(id)` / … + (SV_COMP transformer) — e.g. `String s = Verifier.nondetString(0);`. +3. Fork: `java -javaagent:symbolic-executor.jar -Dsolver.mode=PRINT + -Dconfig.path= -Dswat.input.= -cp
` + with a minimal `test.cfg` (`instrumentation.transformer=SV_COMP`, `solver.mode=PRINT`, + `exitOnError=false`). `swat.input.*` pins the concrete path so the run is deterministic. +4. Capture stdout, parse the TraceDTO JSON (Jackson) into a typed + `TraceObservation { inputs[], branches[], ufs[], symbolicContextLoss, + symbolicPrecisionLoss, referenceSemanticChange }`. + +**Why forked, not in-process:** the agent attaches at premain and `ThreadHandler`/`Config` are +process-global singletons set at startup; resetting them mid-JVM is hacky and fragile. A forked +JVM gives clean isolation per case. + +**Oracle (structured, no log-scrape).** TraceDTO fields + which input variables appear in branch +constraints. E.g. **V-1/R-1**: plant `if (r.equals("abc"))` on the `toLowerCase` result and assert +`symbolicContextLoss == true` **and** the branch over `r` does **not** reference the symbolic +input variable (so no confident wrong SAFE can be derived). + +**CI.** Slower + needs the jar; gate behind a separate Gradle task (`agentTest`), not the default +`test`. Tag `level-2`. + +**Heap cases here:** R-1 (flagship anchor), E-1 "all modes", plus an L2 mirror of V-1. + +--- + +## L3 — End-to-End / Verdict (explorer in the loop) + +**Scope.** Full pipeline: agent (`HTTP` mode) + Python explorer + iterative path exploration + +final SV-COMP verdict, including the downgrade rules (SAFE+contextLoss→UNKNOWN, etc. — see +[`heap-redesign` soundness notes]). Tests the *verdict*, not just the trace. + +**Driver.** The existing sv-comp driver / `targets/` harness. Structured per-testcase observation +(`{verdict, contextLoss, referenceSemanticChange}`) becomes clean once the **`stats.json`** +work lands (branch `feat/svcomp-testcase-metadata`, PR #27) — until then it is STDOUT/log +scraping (`[VERDICT ]`). + +**CI.** Not gated; nightly / manual regression lane. + +**Heap cases here:** R-1, R-2, D-2, E-1 "all modes". + +--- + +## Cross-cutting infrastructure to build + +Listed in dependency order (each is a durable seam, reused well beyond the heap redesign): + +1. **Flag-observation seam.** Add getters to `SymbolicTraceHandler`: + `isSymbolicContextLoss()`, `isReferenceSemanticChange()` (the `SymbolicTrace` fields are + package-private; `processor`-package specs can't see them). Trivial, used by L1 + every flag + assertion. +2. **Heap/registry inspection view.** Put a small read-only view on `ShadowContext` (every spec + already gets it via `visitor.getStack()`, so no new wiring): + `int heapSize()`, `int heapDistinctIdentities()`, `Value heapLookup(long id)`, + `Collection> heapEntries()`. Back it with the legacy `JVMHeap` **now** so O-tests + compile and run as real reds today (O-5: two distinct objects with a colliding hash → + `heapDistinctIdentities()` should be 2, legacy returns 1). Phase 1's canonical registry + implements the **same view** — assertions don't change, they flip red→green. (Chosen over raw + `JVMHeap` getters, which couple tests to a structure we're deleting, and over future-API-only + tests, which wouldn't compile and so give no running red signal.) +3. **L1 boundary-recovery fixture** (see L1). Highest leverage; shared by all recovery cases. +4. **L0 `BaseValueSpec`** — extract the `StringValueTest` setup so all value-semantics specs reuse + a prover + solver context. +5. **L2 `AgentRunFixture` + `TraceObservation`** (see L2). Forked-JVM + PRINT-mode + JSON parse. + +## Suggested build order + +1. Seams (1) + (2) + base specs (4) — unblocks everything, low risk. +2. L1 boundary-recovery fixture (3); land **V-1 as the first concrete `expected-red`** and **O-4** + (`this == o2` wrapper bug, already red). +3. Fan out the L0/L1 case matrix (tagged by phase). +4. L2 `AgentRunFixture`; add the V-1/R-1 L2 anchor. +5. L3 lane after the `stats.json` branch merges. + +## Per-case altitude map (heap redesign) + +| Level | Cases | +|---|---| +| L0 value/structure unit | O-4, O-5, D-3, V-5, V-6, V-9, "one wrapper / identity" | +| L1 processor (workhorse) | V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1 | +| L2 real-agent single-run | R-1 (anchor), V-1 (mirror), E-1 "all modes" | +| L3 end-to-end verdict | R-1, R-2, D-2, E-1 "all modes" | + +Framework: **Spock for L0/L1/L2-harness** (the suite is already Spock; `where:` tables fit the +case matrix). L3 stays in the Python sv-comp harness. + + diff --git a/symbolic-executor/build.gradle b/symbolic-executor/build.gradle index 0b7932b..f5efe30 100644 --- a/symbolic-executor/build.gradle +++ b/symbolic-executor/build.gradle @@ -50,6 +50,7 @@ test { systemProperty "java.library.path", "../libs/java-library-path" // Surface SWAT errors as exceptions instead of halting the test JVM systemProperty "exitOnError", "false" + exclude '**/*AgentSpec*' testLogging { // Show events for passed, skipped, and failed tests @@ -58,6 +59,20 @@ test { showStandardStreams = true } } +tasks.register('agentTest', Test) { + description = 'Level-2 forked-agent tests (real instrumentation). Not part of `test`.' + group = 'verification' + useJUnitPlatform() + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + filter { includeTestsMatching '*AgentSpec' } + dependsOn 'copyJar' + + testLogging { + events "passed", "failed", "skipped" + showStandardStreams = true + } +} jar { // duplicatesStrategy = DuplicatesStrategy.EXCLUDE diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java index b18efde..fb45218 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java @@ -5,6 +5,7 @@ import de.uzl.its.swat.symbolic.value.reference.lang.IntegerObjectValue; import de.uzl.its.swat.symbolic.value.reference.lang.StringValue; +import java.util.Collection; import java.util.HashMap; import java.util.Map; @@ -23,4 +24,22 @@ public void put(int hashCode, Value value) { public Value get(int hashCode) { return objects.get(hashCode); } + /** + * Number of registered cells. On the legacy identity-hash-keyed heap this also equals the + * number of distinct keys, so colliding identities are undercounted (a known defect). + * + * @return the number of entries currently held. + */ + public int size() { + return objects.size(); + } + + /** + * All registered shadow values, for "one wrapper per identity" inspection. + * + * @return the registered values. + */ + public Collection> values() { + return objects.values(); + } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java index 143343e..3756505 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java @@ -52,6 +52,33 @@ public void putToHeap(int hashCode, Value value) { public Value getFromHeap(int hashCode) { return heap.get(hashCode); } + /** + * Number of registered recovery-cache cells. See {@link JVMHeap#size()}. + * + * @return the number of entries currently on the heap. + */ + public int heapSize() { + return heap.size(); + } + + /** + * Looks up a registered shadow value by its identity key (address), without mutating state. + * + * @param address The identity key (object address / identity hash). + * @return The registered value, or null if none. + */ + public Value heapLookup(int address) { + return heap.get(address); + } + + /** + * All registered shadow values, for "one wrapper per identity" inspection. + * + * @return the registered values. + */ + public Collection> heapEntries() { + return heap.values(); + } /** * Pushes a new frame onto the stack and makes it the active frame. A new frame on this stack diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java index 6d99da5..6e0c691 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java @@ -217,4 +217,24 @@ public void recordReferenceSemanticChange() { logger.warn("Reference semantic change detected: user-de-interned strings compared via Objects.equals"); symbolicTrace.setReferenceSemanticChange(true); } + + /** + * Whether a symbolic context loss was recorded on this trace. Read-only accessor for the + * package-private trace flag; used by tests to observe soundness without parsing the TraceDTO. + * + * @return true if a symbolic context loss occurred. + */ + public boolean isSymbolicContextLoss() { + return symbolicTrace.isSymbolicContextLoss(); + } + + /** + * Whether a reference-semantic change was recorded on this trace. Read-only accessor for the + * package-private trace flag; used by tests to observe soundness without parsing the TraceDTO. + * + * @return true if a reference-semantic change occurred. + */ + public boolean isReferenceSemanticChange() { + return symbolicTrace.isReferenceSemanticChange(); + } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BaseValueSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BaseValueSpec.groovy new file mode 100644 index 0000000..7dc1f6d --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BaseValueSpec.groovy @@ -0,0 +1,62 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.thread.ThreadHandler +import org.sosy_lab.java_smt.api.BooleanFormula +import org.sosy_lab.java_smt.api.FormulaManager +import org.sosy_lab.java_smt.api.ProverEnvironment +import org.sosy_lab.java_smt.api.SolverContext +import spock.lang.Specification + +import static java.lang.Thread.currentThread + +/** + * Level L0 base: a clean Z3 {@link SolverContext} per test for constructing {@code Value} objects + * and shadow structures directly (no instrumentation, no instruction stream). Mirrors the proven + * {@code StringValueTest} setup. See docs/test-architecture.md (Level L0). + * + * Oracle helpers evaluate {@link BooleanFormula}s via SAT/UNSAT agreement with a real prover — + * never by inspecting the formula's representation. + */ +abstract class BaseValueSpec extends Specification { + + protected SolverContext context + protected FormulaManager fmgr + + def setup() { + ThreadHandler.init() + if (ThreadHandler.hasThreadContext(currentThread().id)) { + ThreadHandler.removeThreadContext(currentThread().id) + } + ThreadHandler.addThreadContext(currentThread().id, "Test-Thread", -2) + context = ThreadHandler.getSolverContext(currentThread().id) + fmgr = context.getFormulaManager() + } + + def cleanup() { + if (ThreadHandler.hasThreadContext(currentThread().id)) { + ThreadHandler.removeThreadContext(currentThread().id) + } + } + + /** True iff {@code f} is valid (always true): {@code not(f)} is unsatisfiable. */ + protected boolean isValid(BooleanFormula f) { + ProverEnvironment p = context.newProverEnvironment() + try { + p.addConstraint(fmgr.getBooleanFormulaManager().not(f)) + return p.isUnsat() + } finally { + p.close() + } + } + + /** True iff {@code f} is unsatisfiable (always false). */ + protected boolean isUnsatisfiable(BooleanFormula f) { + ProverEnvironment p = context.newProverEnvironment() + try { + p.addConstraint(f) + return p.isUnsat() + } finally { + p.close() + } + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy new file mode 100644 index 0000000..ace5767 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy @@ -0,0 +1,41 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.PendingFeature +import spock.lang.See +import spock.lang.Specification + +/** + * V-1 / R-1 at Level L2: the REAL agent runs a real {@code @Symbolic} toLowerCase program + * (ToLowerCaseTarget) and we observe the emitted TraceDTO. This is the faithful altitude — the + * identity hash, the this-return and the soundness flags come from the real JVM + instrumenter, not + * a fabricated instruction stream. + * + * Naming: {@code *AgentSpec} → run by the opt-in {@code agentTest} Gradle task, excluded from + * {@code test}. See docs/test-architecture.md (Level L2). + */ +class HeapRecoveryV1AgentSpec extends Specification { + + @See("docs/test-architecture.md") + def "L2 soundness anchor: real toLowerCase on a symbolic string flags context loss"() { + when: + TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") + + then: "the symbolic input is designated and the soundness backstop (context loss) fires" + obs.inputNames.any { it.startsWith("java/lang/String") } + obs.symbolicContextLoss + } + + @See("docs/heap-redesign-tests.md") + @PendingFeature(reason = "G2 not implemented; toLowerCase this-return aliases the receiver, so the branch on the result references the symbolic input") + def "V-1 (L2): a branch after unmodeled toLowerCase must not reference the symbolic input"() { + when: + TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") + String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } + + then: "post-G2 the result is concrete, so no branch constraint mentions the input (RED until G2)" + inputVar != null + !obs.anyBranchReferences(inputVar) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy new file mode 100644 index 0000000..49a1231 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy @@ -0,0 +1,57 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import org.sosy_lab.java_smt.api.Formula +import spock.lang.PendingFeature +import spock.lang.See + +/** + * V-1 — the core toLowerCase bug. Level L1 (boundary recovery), Phase 2 (G2). + * + * Scenario: a symbolic, already-lowercase String is the receiver of an unmodeled {@code toLowerCase()} + * that returns {@code this}. Expected: the result does NOT depend on the receiver's symbolic value; + * it is concrete; a context-loss flag is raised; its concrete value equals the real result. + * + * Today the identity-keyed recovery (visitGETVALUE_Object) re-binds the placeholder result to the + * receiver's StringValue, so the result aliases the receiver's symbolic formula — the RED assertion. + * The preconditions (context-loss flagged, concrete correct) already hold and guard infra breakage. + */ +class HeapRecoveryV1Spec extends BaseSymbolicInstructionProcessorSpec { + + @See("docs/heap-redesign-tests.md") + @PendingFeature(reason = "G2 value-type boundary recovery not yet implemented; result still aliases the receiver's formula") + def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { + given: "a symbolic, already-lowercase String receiver registered on the heap" + String testClassName = Util.formatClassName("de.uzl.its.swat.test.TestClass") + setupTestContext(testClassName, "main") + int receiverAddress = 0x1000 + String concrete = "abc" // already lowercase: the real toLowerCase() returns `this` + StringValue receiver = new StringValue(solverContext, concrete, receiverAddress) + receiver.MAKE_SYMBOLIC() + def receiverVars = solverContext.getFormulaManager() + .extractVariables(receiver.formula as Formula).keySet() + + and: "precondition: the receiver really is symbolic" + assert receiverVars.size() == 1 + + when: "toLowerCase() is invoked (unmodeled) and the this-return is recovered" + def result = executeBoundaryRecovery( + receiver, + Util.formatClassName("java.lang.String"), + "toLowerCase", + "()Ljava/lang/String;", + concrete, + receiverAddress) // this-return: result address == receiver address + + then: "preconditions that already hold today: context loss is flagged, concrete is correct" + result.contextLoss + result.recovered.concrete == concrete + + and: "the recovered value must NOT carry the receiver's symbolic formula (RED until G2)" + def recoveredVars = solverContext.getFormulaManager() + .extractVariables(result.recovered.formula as Formula).keySet() + recoveredVars.disjoint(receiverVars) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy new file mode 100644 index 0000000..a861089 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy @@ -0,0 +1,44 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue +import de.uzl.its.swat.symbolic.value.reference.ObjectValue +import spock.lang.PendingFeature +import spock.lang.See + +/** + * O-4 — reference-equality correctness. Level L0, Phase 1 (G1 canonical heap). + * + * Two shadow wrappers for the SAME concrete object (a duplicate registration) must compare + * reference-equal. {@code ObjectValue.IF_ACMPEQ} currently uses {@code this == o2} on the wrapper, + * so duplicate wrappers wrongly compare unequal — a correctness defect the canonical registry fixes. + * + * Convention: the "already holds" feature has no {@code @PendingFeature} and guards against + * infra breakage; the desired-but-unimplemented behavior is marked {@code @PendingFeature} (red). + */ +class ObjectIdentitySpec extends BaseValueSpec { + + private ObjectValue objectAt(int address) { + return new ObjectValue(context, "de/uzl/its/swat/test/Obj", new IntValue(context, 1), address) + } + + @See("docs/heap-redesign-tests.md") + def "O-4: two references to distinct objects compare reference-unequal"() { + given: + ObjectValue a = objectAt(0x2000) + ObjectValue c = objectAt(0x3000) + + expect: + isUnsatisfiable(a.IF_ACMPEQ(c)) + } + + @See("docs/heap-redesign-tests.md") + @PendingFeature(reason = "G1 canonical registry not yet implemented; IF_ACMPEQ uses this==o2, so duplicate wrappers for one identity compare unequal") + def "O-4: two wrappers for the same identity compare reference-equal"() { + given: "two distinct wrappers sharing one identity (the duplicate-registration bug)" + ObjectValue a = objectAt(0x2000) + ObjectValue b = objectAt(0x2000) + + expect: "reference equality must hold for the same identity (RED until G1)" + isValid(a.IF_ACMPEQ(b)) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy index c09af0b..418c115 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy @@ -7,8 +7,10 @@ import de.uzl.its.swat.instrument.GlobalStateForInstrumentation import de.uzl.its.swat.instrument.Intrinsics import de.uzl.its.swat.metadata.ClassDepot import de.uzl.its.swat.symbolic.SymbolicInstructionVisitor +import de.uzl.its.swat.symbolic.instruction.GETVALUE_Object import de.uzl.its.swat.symbolic.instruction.INVOKEMETHOD_END import de.uzl.its.swat.symbolic.instruction.INVOKESTATIC +import de.uzl.its.swat.symbolic.instruction.INVOKEVIRTUAL import de.uzl.its.swat.symbolic.instruction.Instruction import de.uzl.its.swat.symbolic.instruction.LDC_long import de.uzl.its.swat.symbolic.instruction.NOP @@ -273,4 +275,55 @@ abstract class BaseSymbolicInstructionProcessorSpec extends Specification { } return result } + + /** + * L1 boundary-recovery fixture. Drives the real unmodeled-invoke -> recovery path: + * + * register {@code receiver} on the heap at its address; push it as the invoke instance; then + * process INVOKEVIRTUAL(owner,name,desc) -> INVOKEMETHOD_END -> GETVALUE_Object(resultAddress, concreteResult). + * + * An unmodeled method returns a PlaceHolder (InvocationHandler), INVOKEMETHOD_END pushes it, and + * GETVALUE_Object reconciles it against the identity-keyed heap. Passing + * {@code resultAddress == receiver.address} reproduces a this-return (the toLowerCase aliasing + * defect); a fresh {@code resultAddress} reproduces a new-object return. + * + * NOTE (honest limit): this fixture *fabricates* the result/receiver address relationship that a + * real JVM would produce, so it pins the interpreter's recovery logic, not the + * instrumentation->processor contract. Pair flagship cases with an L2 agent run. + * + * @return a map [recovered: Value, contextLoss: boolean, referenceSemanticChange: boolean] + */ + def executeBoundaryRecovery(ObjectValue receiver, String owner, String name, String desc, + Object concreteResult, int resultAddress) { + SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) + ShadowContext context = visitor.getStack() + // Register the receiver so a placeholder result can be recovered from the heap by identity. + context.putToHeap(receiver.getAddress(), receiver) + // Push the receiver as the invoke instance (consumed by newStackFrame in INVOKEVIRTUAL). + pushOperand(receiver) + + List instructions = new ArrayList<>() + long invokeId = GlobalStateForInstrumentation.instance.incAndGetInvokeId() + // INVOKEVIRTUAL and its INVOKEMETHOD_END must share the invokeId so the open-invoke stack + // closes (closeOpenInvoke) and METHOD_END is visited despite the frame's class differing. + instructions.add(new INVOKEVIRTUAL(GlobalStateForInstrumentation.instance.incAndGetId(), invokeId, owner, name, desc)) + instructions.add(new INVOKEMETHOD_END(GlobalStateForInstrumentation.instance.incAndGetId(), invokeId)) + instructions.add(new GETVALUE_Object(GlobalStateForInstrumentation.instance.incAndGetId(), resultAddress, concreteResult, 0)) + instructions.add(new NOP(GlobalStateForInstrumentation.instance.incAndGetId())) + + // Drive: setCurrent(first); each processInstruction(next) visits the *current* and advances. + // The trailing NOP exists only to flush the GETVALUE_Object visit (execution is one behind). + ThreadHandler.setCurrentInstruction(threadId, instructions.remove(0)) + SymbolicInstructionProcessor processor = new SymbolicInstructionProcessor() + while (!instructions.isEmpty()) { + processor.processInstruction(instructions.remove(0)) + } + + def traceHandler = ThreadHandler.getSymbolicTraceHandler(threadId) + return [ + recovered : visitor.getStack().getActiveFrame().peek(), + contextLoss : traceHandler.isSymbolicContextLoss(), + referenceSemanticChange: traceHandler.isReferenceSemanticChange() + ] + } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/AgentRun.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/AgentRun.groovy new file mode 100644 index 0000000..0b1d345 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/AgentRun.groovy @@ -0,0 +1,91 @@ +package de.uzl.its.swat.testsupport.agent + +import com.fasterxml.jackson.databind.ObjectMapper + +import javax.tools.ToolProvider +import java.nio.file.Files + +/** + * Level-2 harness. Compiles a tiny {@code @Symbolic}-annotated target against the agent jar, runs it + * under the REAL SWAT agent in a forked JVM with {@code solver.mode=PRINT}, captures stdout, and + * parses the emitted {@code TraceDTO} JSON into a {@link TraceObservation}. No Python explorer. + * + * Every failure mode throws (missing jar, compile error, non-zero exit, no JSON), so an infra break + * surfaces as a real failure — never masked. See docs/test-architecture.md (Level L2). + */ +class AgentRun { + + /** + * @param targetResource classpath resource of the target source, e.g. "targets/ToLowerCaseTarget.java". + * @param mainClass the target's (default-package) main class name, e.g. "ToLowerCaseTarget". + */ + static TraceObservation run(String targetResource, String mainClass) { + // Gradle runs tests with the module dir as the working dir. + File moduleDir = new File(System.getProperty("user.dir")) + File agentJar = new File(moduleDir, "lib/symbolic-executor.jar") + File libs = new File(moduleDir, "../libs/java-library-path") + assert agentJar.exists(): + "Agent jar missing at ${agentJar} — run `./gradlew :symbolic-executor:copyJar` first " + + "(the agentTest task does this automatically)." + + // 1. Materialize + compile the target against the agent jar (which provides @Symbolic). + File work = Files.createTempDirectory("swat-l2-").toFile() + File outDir = new File(work, "classes") + outDir.mkdirs() + File src = new File(work, mainClass + ".java") + def res = AgentRun.class.classLoader.getResourceAsStream(targetResource) + assert res != null: "Target resource not found on test classpath: ${targetResource}" + src.text = res.text + + def compiler = ToolProvider.getSystemJavaCompiler() + assert compiler != null: "No system Java compiler — tests must run on a JDK, not a JRE." + int rc = compiler.run(null, System.out, System.err, + "-g", "-cp", agentJar.absolutePath, "-d", outDir.absolutePath, src.absolutePath) + assert rc == 0: "Failed to compile target ${targetResource}" + + // 2. Fork a JVM under the agent in PRINT mode (the TraceDTO JSON goes to stdout). + List cmd = [ + javaBin(), + "-Djava.library.path=${libs.absolutePath}".toString(), + "-Dsolver.mode=PRINT", + "-Dlogging.debug=false", + "-javaagent:${agentJar.absolutePath}".toString(), + "-cp", outDir.absolutePath, + mainClass + ] + Process proc = new ProcessBuilder(cmd).start() + String stdout = proc.inputStream.text + String stderr = proc.errorStream.text + int exit = proc.waitFor() + assert exit == 0: + "Agent run exited ${exit}.\n--- STDOUT tail ---\n${tail(stdout)}\n--- STDERR tail ---\n${tail(stderr)}" + + // 3. Extract the single top-level pretty-printed JSON object and parse it. + String json = extractTraceJson(stdout) + assert json != null: "No TraceDTO JSON found on stdout.\n--- STDOUT tail ---\n${tail(stdout)}" + return TraceObservation.parse(new ObjectMapper().readTree(json)) + } + + private static String javaBin() { + return new File(System.getProperty("java.home"), "bin/java").absolutePath + } + + /** + * The PRINT-mode TraceDTO is the only top-level (column-0) pretty-printed JSON object on stdout, + * so it spans from the single line that is "{" to the single line that is "}". + */ + private static String extractTraceJson(String stdout) { + List lines = stdout.readLines() + int start = lines.findIndexOf { it.trim() == "{" } + int end = lines.findLastIndexOf { it.trim() == "}" } + if (start < 0 || end < start) { + return null + } + return lines[start..end].join("\n") + } + + private static String tail(String s, int n = 40) { + List l = s.readLines() + return l.subList(Math.max(0, l.size() - n), l.size()).join("\n") + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy new file mode 100644 index 0000000..46f282f --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy @@ -0,0 +1,37 @@ +package de.uzl.its.swat.testsupport.agent + +import com.fasterxml.jackson.databind.JsonNode + +/** + * A structured view of a {@code TraceDTO} emitted by a Level-2 agent run (solver.mode=PRINT). + * Holds only what tests assert on: the soundness flags, the symbolic input names, and the + * (non-null) branch constraint strings. See docs/test-architecture.md (Level L2). + */ +class TraceObservation { + + boolean symbolicContextLoss + boolean symbolicPrecisionLoss + boolean referenceSemanticChange + List inputNames = [] + List branchConstraints = [] + + static TraceObservation parse(JsonNode root) { + TraceObservation o = new TraceObservation() + o.symbolicContextLoss = root.path("symbolicContextLoss").asBoolean() + o.symbolicPrecisionLoss = root.path("symbolicPrecisionLoss").asBoolean() + o.referenceSemanticChange = root.path("referenceSemanticChange").asBoolean() + root.path("inputs").each { o.inputNames << it.path("name").asText() } + root.path("trace").each { + JsonNode c = it.path("constraint") + if (c != null && c.isTextual()) { + o.branchConstraints << c.asText() + } + } + return o + } + + /** Whether any branch constraint mentions {@code token} (e.g. a symbolic input variable name). */ + boolean anyBranchReferences(String token) { + return branchConstraints.any { it.contains(token) } + } +} diff --git a/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java b/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java new file mode 100644 index 0000000..e0c7afc --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java @@ -0,0 +1,25 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 target for V-1 / R-1: a symbolic, concretely already-lowercase String is passed to an + * unmodeled {@code toLowerCase()} that returns {@code this}. Run under the real SWAT agent + * (ANNOTATION mode, the default), this exercises the identity-recovery / aliasing path. + * + * {@code @Symbolic} is on the parameter (the annotation transformer handles parameters cleanly; + * a local needs the {@code -g} debug table). Compiled against the agent jar, so it has no external + * dependency. See docs/test-architecture.md (Level L2). + */ +public class ToLowerCaseTarget { + + public static void main(String[] args) { + test("abc"); + } + + public static String test(@Symbolic String s) { + String r = s.toLowerCase(); + if (r.equals("abc")) { // branch on the (currently aliased) result + return "eq"; + } + return "ne"; + } +} From b19df477e0edf97a1664dd371b7a70620a8703b7 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 19:50:37 +0000 Subject: [PATCH 02/33] Fan out heap-redesign case matrix (O/V/F at L0/L1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the next behavioral cases against the harness: - O-5 (L0): colliding identity hashes — refs unequal (green) + heap must not merge distinct colliding objects (pending, G1). - V-2 (L1): new-object transform return does not alias the receiver (green); paired with V-1 in ValueRecoverySpec (renamed from HeapRecoveryV1Spec). - V-5/V-6/V-9 (L0, ValueSemanticsSpec): copy-ctor keeps phi, interned-literal reuse, concrete grounding (all green). - F-1/F-2 (L1, FlagPolicySpec): context-loss flag fires iff symbolic data flows into the unmodeled call (green). Stats: 33 tests, 29 passed, 4 pending (@PendingFeature: O-4, O-5, V-1 L1, V-1 L2), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swat/symbolic/heap/FlagPolicySpec.groovy | 44 ++++++++++++ .../symbolic/heap/HeapRecoveryV1Spec.groovy | 57 --------------- .../symbolic/heap/ObjectIdentitySpec.groovy | 28 ++++++++ .../symbolic/heap/ValueRecoverySpec.groovy | 70 +++++++++++++++++++ .../symbolic/heap/ValueSemanticsSpec.groovy | 60 ++++++++++++++++ 5 files changed, 202 insertions(+), 57 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy delete mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy new file mode 100644 index 0000000..e2415b3 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy @@ -0,0 +1,44 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import spock.lang.See + +/** + * Context-loss flag policy at Level L1: the flag fires iff symbolic data flowed into the unmodeled + * call. F-1 (concrete receiver → no flag) and F-2 (symbolic receiver → flag) document the currently + * correct behavior and guard it. See docs/heap-redesign-tests.md. + */ +class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { + + private static final String STRING = "java/lang/String" + private static final String TO_LOWER = "()Ljava/lang/String;" + + @See("docs/heap-redesign-tests.md") + def "F-1: an unmodeled call with a concrete receiver raises no context-loss flag"() { + given: + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) // concrete (not made symbolic) + + when: + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc", 0x1000) + + then: "no symbolic data flowed into the black box, so no context loss" + !result.contextLoss + } + + @See("docs/heap-redesign-tests.md") + def "F-2: an unmodeled call with a symbolic receiver raises a context-loss flag"() { + given: + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) + receiver.MAKE_SYMBOLIC() + + when: + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc", 0x1000) + + then: "symbolic data reached the unmodeled method, so context loss is flagged" + result.contextLoss + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy deleted file mode 100644 index 49a1231..0000000 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1Spec.groovy +++ /dev/null @@ -1,57 +0,0 @@ -package de.uzl.its.swat.symbolic.heap - -import de.uzl.its.swat.common.Util -import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec -import de.uzl.its.swat.symbolic.value.reference.lang.StringValue -import org.sosy_lab.java_smt.api.Formula -import spock.lang.PendingFeature -import spock.lang.See - -/** - * V-1 — the core toLowerCase bug. Level L1 (boundary recovery), Phase 2 (G2). - * - * Scenario: a symbolic, already-lowercase String is the receiver of an unmodeled {@code toLowerCase()} - * that returns {@code this}. Expected: the result does NOT depend on the receiver's symbolic value; - * it is concrete; a context-loss flag is raised; its concrete value equals the real result. - * - * Today the identity-keyed recovery (visitGETVALUE_Object) re-binds the placeholder result to the - * receiver's StringValue, so the result aliases the receiver's symbolic formula — the RED assertion. - * The preconditions (context-loss flagged, concrete correct) already hold and guard infra breakage. - */ -class HeapRecoveryV1Spec extends BaseSymbolicInstructionProcessorSpec { - - @See("docs/heap-redesign-tests.md") - @PendingFeature(reason = "G2 value-type boundary recovery not yet implemented; result still aliases the receiver's formula") - def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { - given: "a symbolic, already-lowercase String receiver registered on the heap" - String testClassName = Util.formatClassName("de.uzl.its.swat.test.TestClass") - setupTestContext(testClassName, "main") - int receiverAddress = 0x1000 - String concrete = "abc" // already lowercase: the real toLowerCase() returns `this` - StringValue receiver = new StringValue(solverContext, concrete, receiverAddress) - receiver.MAKE_SYMBOLIC() - def receiverVars = solverContext.getFormulaManager() - .extractVariables(receiver.formula as Formula).keySet() - - and: "precondition: the receiver really is symbolic" - assert receiverVars.size() == 1 - - when: "toLowerCase() is invoked (unmodeled) and the this-return is recovered" - def result = executeBoundaryRecovery( - receiver, - Util.formatClassName("java.lang.String"), - "toLowerCase", - "()Ljava/lang/String;", - concrete, - receiverAddress) // this-return: result address == receiver address - - then: "preconditions that already hold today: context loss is flagged, concrete is correct" - result.contextLoss - result.recovered.concrete == concrete - - and: "the recovered value must NOT carry the receiver's symbolic formula (RED until G2)" - def recoveredVars = solverContext.getFormulaManager() - .extractVariables(result.recovered.formula as Formula).keySet() - recoveredVars.disjoint(receiverVars) - } -} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy index a861089..3c07412 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy @@ -1,5 +1,6 @@ package de.uzl.its.swat.symbolic.heap +import de.uzl.its.swat.symbolic.shadow.ShadowContext import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.symbolic.value.reference.ObjectValue import spock.lang.PendingFeature @@ -41,4 +42,31 @@ class ObjectIdentitySpec extends BaseValueSpec { expect: "reference equality must hold for the same identity (RED until G1)" isValid(a.IF_ACMPEQ(b)) } + + @See("docs/heap-redesign-tests.md") + def "O-5: distinct objects with a colliding identity hash compare reference-unequal"() { + given: "two distinct objects whose identity hash (address) collides" + ObjectValue a = objectAt(0x5000) + ObjectValue b = objectAt(0x5000) + + expect: "they are still distinct references" + isUnsatisfiable(a.IF_ACMPEQ(b)) + } + + @See("docs/heap-redesign-tests.md") + @PendingFeature(reason = "G1 faithful key not implemented; the identity-hash-keyed heap merges distinct objects that share an identity hash") + def "O-5: the heap stores colliding-hash objects without merging them"() { + given: "two distinct objects whose identity hash (address) collides" + int collidingHash = 0x5000 + ObjectValue a = objectAt(collidingHash) + ObjectValue b = objectAt(collidingHash) + + when: "both are registered on the recovery heap" + ShadowContext shadow = new ShadowContext() + shadow.putToHeap(collidingHash, a) + shadow.putToHeap(collidingHash, b) + + then: "both survive distinctly (RED until G1: the hash key collapses them to one)" + shadow.heapSize() == 2 + } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy new file mode 100644 index 0000000..b82f15f --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -0,0 +1,70 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import org.sosy_lab.java_smt.api.Formula +import spock.lang.PendingFeature +import spock.lang.See + +/** + * Value-typed boundary recovery at Level L1 (the workhorse fixture). An unmodeled value-returning + * call must not let its result alias the receiver's symbolic value — whether the call returns + * {@code this} (V-1) or a fresh object (V-2). The only difference between the two below is the + * result's address, which isolates the identity-keyed recovery as the defect. See + * docs/heap-redesign-tests.md. + */ +class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { + + private static final String STRING = "java/lang/String" + private static final String TO_LOWER = "()Ljava/lang/String;" + + private Set varsOf(value) { + return solverContext.getFormulaManager().extractVariables(value.formula as Formula).keySet() + } + + @See("docs/heap-redesign-tests.md") + @PendingFeature(reason = "G2 value-type boundary recovery not yet implemented; result still aliases the receiver's formula") + def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { + given: "a symbolic, already-lowercase String receiver registered on the heap" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + int receiverAddress = 0x1000 + String concrete = "abc" // already lowercase: the real toLowerCase() returns `this` + StringValue receiver = new StringValue(solverContext, concrete, receiverAddress) + receiver.MAKE_SYMBOLIC() + def receiverVars = varsOf(receiver) + + and: "precondition: the receiver really is symbolic" + assert receiverVars.size() == 1 + + when: "toLowerCase() is invoked (unmodeled) and the this-return is recovered" + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, + concrete, receiverAddress) // this-return: result address == receiver address + + then: "preconditions that hold today: context loss flagged, concrete correct" + result.contextLoss + result.recovered.concrete == concrete + + and: "the recovered value must NOT carry the receiver's symbolic formula (RED until G2)" + varsOf(result.recovered).disjoint(receiverVars) + } + + @See("docs/heap-redesign-tests.md") + def "V-2: a new-object transform return does not alias the receiver (consistent with V-1)"() { + given: "a symbolic, concretely upper-case String receiver (toLowerCase returns a NEW object)" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + int receiverAddress = 0x1000 + StringValue receiver = new StringValue(solverContext, "ABC", receiverAddress) + receiver.MAKE_SYMBOLIC() + def receiverVars = varsOf(receiver) + + when: "the result returns at a fresh address (a distinct object), not the receiver's" + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, + "abc", 0x2000) // fresh address => new object, not a this-return + + then: "context loss is flagged, the concrete is the real result, and there is no aliasing" + result.contextLoss + result.recovered.concrete == "abc" + varsOf(result.recovered).disjoint(receiverVars) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy new file mode 100644 index 0000000..a345b24 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy @@ -0,0 +1,60 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.symbolic.value.Value +import de.uzl.its.swat.symbolic.value.reference.ObjectValue +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import org.objectweb.asm.Type +import org.sosy_lab.java_smt.api.Formula +import spock.lang.See + +/** + * Value-typed semantics at Level L0 — String identity/value rules that hold by construction + * (no instrumentation). V-5 (copy ctor), V-6 (interned-literal reuse), V-9 (concrete grounding). + * These document currently-correct behavior and guard against regression. See + * docs/heap-redesign-tests.md. + */ +class ValueSemanticsSpec extends BaseValueSpec { + + private Set varsOf(Value v) { + return fmgr.extractVariables(v.formula as Formula).keySet() + } + + @See("docs/heap-redesign-tests.md") + def "V-5: new String(s) keeps the source's symbolic formula (committed copy-ctor model)"() { + given: "a symbolic source string and a fresh target" + StringValue s = new StringValue(context, "abc", ObjectValue.ADDRESS_UNKNOWN) + s.MAKE_SYMBOLIC() + StringValue t = new StringValue(context, "abc", ObjectValue.ADDRESS_UNKNOWN) + + when: "t = new String(s) via the copy constructor" + Type[] desc = [Type.getType("Ljava/lang/String;")] as Type[] + t.invokeMethod("", desc, [s] as Value[]) + + then: "the content copy carries the same symbolic value" + !varsOf(s).isEmpty() + varsOf(t) == varsOf(s) + } + + @See("docs/heap-redesign-tests.md") + def "V-6: reusing the same literal yields the same constant formula with no spurious vars"() { + given: + StringValue a = new StringValue(context, "lit", ObjectValue.ADDRESS_UNKNOWN) + StringValue b = new StringValue(context, "lit", ObjectValue.ADDRESS_UNKNOWN) + + expect: "both are constants (no free variables) and compare value-equal" + varsOf(a).isEmpty() + varsOf(b).isEmpty() + isValid(a.IF_ACMPEQ(b)) + } + + @See("docs/heap-redesign-tests.md") + def "V-9: making a value symbolic preserves its concrete grounding"() { + given: + StringValue s = new StringValue(context, "seed", ObjectValue.ADDRESS_UNKNOWN) + s.MAKE_SYMBOLIC() + + expect: + s.concrete == "seed" + !varsOf(s).isEmpty() + } +} From 95dfaa76c906caa1b8b41aced97fc53617840e75 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 19:53:52 +0000 Subject: [PATCH 03/33] Add implementation handoff for the heap redesign MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-contained brief for the agent implementing G1/G2/etc.: the test contract (@PendingFeature reds as acceptance criteria), the red→goal mapping, bug locations, the invariants the harness depends on, run commands, and gotchas. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/heap-redesign-implementation-handoff.md | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 docs/heap-redesign-implementation-handoff.md diff --git a/docs/heap-redesign-implementation-handoff.md b/docs/heap-redesign-implementation-handoff.md new file mode 100644 index 0000000..b328514 --- /dev/null +++ b/docs/heap-redesign-implementation-handoff.md @@ -0,0 +1,142 @@ +# Shadow Heap Redesign — Implementation Handoff + +You are implementing the shadow-heap redesign (G1, G2, G_oob, G3, G4). The **tests are already +written** and act as your acceptance criteria: a small set is intentionally *red* (failing) and +encodes the behavior you must produce. This doc tells you what exists, what to make pass, where the +bugs live, and the invariants you must not break. + +Branch: **`fix/heap-design`** (off `dev`). Two commits: `a7557fa` (harness foundation), +`b19df47` (case-matrix fan-out). + +Read first: `docs/heap-redesign-plan.md` (the goals G1–G4 + phase order), +`docs/heap-redesign-tests.md` (behavioral cases), `docs/test-architecture.md` (the test levels). + +## The test contract (how to work) + +- Tests live in `symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/` (L0/L1) and + `.../testsupport/agent/` + `.../heap/*AgentSpec` (L2). +- **Red cases are marked `@PendingFeature(reason=…)`.** They run and assert the *desired* behavior; + while failing they report as *skipped* (the suite stays green). **When your change makes one + pass, `@PendingFeature` turns it into a BUILD FAILURE** that says "remove the annotation" — that + is your signal the fix landed. Then delete the `@PendingFeature` line and commit. +- **Green tests are regression guards — they must stay green.** +- Current status: **33 tests, 29 passed, 4 pending, 0 failed.** + +Run: +```bash +# L0 + L1 (fast, in-process). Scope to these packages — the legacy de/uzl/its/value/** suite +# (~1670 rows) fails for unrelated reasons; do NOT run the whole `test` task. +./gradlew :symbolic-executor:test --tests "de.uzl.its.swat.symbolic.heap.*" \ + --tests "de.uzl.its.swat.symbolic.processor.*" +# L2 (forked real agent; builds the jar first) +./gradlew :symbolic-executor:agentTest +``` +Build the agent jar with `:symbolic-executor:copyJar`. **Never run `spotlessApply`/`spotlessCheck`** +— it reformats the whole module and buries your diff; CI does not gate on it. + +## What to implement, mapped to the red tests + +### Phase 1 — G1: canonical registry + faithful key + register-on-create +Makes these flip green: +- **O-4** (`ObjectIdentitySpec`, L0): two shadow wrappers for one identity must compare + reference-equal. Today `ObjectValue.IF_ACMPEQ` is `bfm.makeBoolean(this == o2)` on the *wrapper* + (`ObjectValue.java:152`), so duplicate wrappers compare unequal. Fix = one canonical wrapper per + identity (or compare by identity/address), so `IF_ACMPEQ` is correct. **This is a correctness fix** + (non-String `IF_ACMPEQ` drives real `==` semantics), not cleanup. +- **O-5 "heap stores colliding-hash objects without merging"** (`ObjectIdentitySpec`, L0): + two distinct objects sharing an identity hash must not collapse. Today `JVMHeap` is + `Map`, so a hash collision overwrites. Fix = a faithful, + collision-free, reference-keyed registry (weak `IdentityHashMap` is the default choice). + ⚠ **This test currently drives the legacy `ShadowContext.putToHeap(int)` API.** When you change + the registration API to find-or-create by faithful key, **update O-5's `when:` block** to register + via the new API — the *assertion* (`heapSize() == 2`) is the stable contract and must hold. + +Also covered by G1 but **not yet tested** (write these as you build the re-entry path): O-1 +(aliasing through fields), O-2 (re-entry recovers the same object), O-3 (object born in unmodeled +code), O-6 (identity reuse after death/eviction). The L1 boundary fixture (below) is the starting +point for O-2/O-3. + +### Phase 2 — G2: value-type boundary recovery (collapse policy v1) +Makes these flip green: +- **V-1** (`ValueRecoverySpec`, L1): a symbolic, already-lowercase String receiver of an unmodeled + `toLowerCase()` returns `this`; the recovered result must **not** carry the receiver's symbolic + formula (assert: recovered vars disjoint from receiver vars), must be concrete, and context loss + must be flagged (the flag already fires today). Root cause: `SymbolicInstructionVisitor` + `visitGETVALUE_Object` (~`:1265`) reconciles the `PlaceHolder` result by + `getFromHeap(inst.address)` and pushes the receiver's `StringValue` back — identity-recovery of a + value type. Fix (per plan §G2 v1 "collapse"): at a **mirrored invoke boundary of an unmodeled + value-returning method**, do NOT identity-recover for value types (`Util.deInternedClasses` = + String + 6 boxed wrappers); produce the concretized value + context-loss flag. +- **V-1 (L2)** (`HeapRecoveryV1AgentSpec`): the same scenario run through the REAL agent + (`ToLowerCaseTarget`); assert the branch on the result does not reference the symbolic input + variable. This is the faithful end-to-end check — it must flip together with the L1 V-1. + +Already green and must stay green: **V-2** (new-object transform doesn't alias — same fixture, fresh +result address), **V-5** (`new String(String)` copy ctor keeps φ — keep `StringValue.invokeInit` +copying the formula, `StringValue.java:217`), **V-6** (interned-literal reuse), **V-9** (concrete +grounding), **F-1/F-2** (context-loss flag fires iff symbolic data flows in). + +Not yet tested (Phase-2+): V-3 (single-φ round-trip — needs a field round-trip fixture, distinct +from the invoke fixture), V-4 (conflicting-φ set → realize but recover concrete), V-7 (boxed +analogue), V-8 (primitive context loss — needs a primitive-return fixture). + +### Later phases (no tests yet — add them when the mechanism exists) +G_oob out-of-band change detection (E-1, needs an L2 mutating target); G4a purity whitelist +(E-2, F-4); G3 output de-interning (D-1/D-2/D-3); G4b UF + soundness (U-1…U-5). + +## Invariants you must preserve (the harness depends on them) + +1. **Heap inspection view.** `ShadowContext.heapSize()/heapLookup(int)/heapEntries()` and + `JVMHeap.size()/values()` are the read-only seam the O-tests assert against. Your new + registry **must keep implementing these** (same semantics: `heapEntries()` = one entry per + canonical identity; `heapSize()` counts distinct identities). Tests flip red→green without + changing their assertions. +2. **Soundness flags + getters.** `SymbolicTraceHandler.isSymbolicContextLoss()` / + `isReferenceSemanticChange()` and the `TraceDTO` booleans (`symbolicContextLoss`, + `symbolicPrecisionLoss`, `referenceSemanticChange`) are read by L1 and L2 tests. Keep them. +3. **L1 fixture.** `BaseSymbolicInstructionProcessorSpec.executeBoundaryRecovery(receiver, owner, + name, desc, concreteResult, resultAddress)` drives INVOKEVIRTUAL→INVOKEMETHOD_END→GETVALUE_Object. + `resultAddress == receiver.address` = this-return; fresh address = new object. If you change the + recovery API/addresses, keep this fixture working (or update it in lockstep). +4. **L2 channel.** `solver.mode=PRINT` prints the `TraceDTO` JSON to stdout (`Intrinsics.terminate()` + PRINT branch). `AgentRun` parses it. Don't break PRINT mode. + +## Key code locations (audit-confirmed; verify line numbers, they drift) + +- Recovery cache: `symbolic/shadow/JVMHeap.java`, `symbolic/shadow/ShadowContext.java` + (`putToHeap`/`getFromHeap`). +- The V-1 recovery path: `symbolic/SymbolicInstructionVisitor.java` `visitGETVALUE_Object` + (~`:1242`–`1340`); the heap miss-create at ~`:1289`. +- The `==` bug: `symbolic/value/reference/ObjectValue.java` `IF_ACMPEQ` (`:152`), `equals` + (`:126` — note it NPEs when `fields == null`; tighten if you touch it). +- Unmodeled stub: `symbolic/value/reference/lang/StringValue.java` `invokeToLowerCase` (`:1125`, + returns `PlaceHolder.instance`); copy ctor `invokeInit` (`:217`). +- Flag decision: `symbolic/invoke/InvocationHandler.java` `invoke` (`:38`–`121`) — records context + loss when result is a `PlaceHolder` and a symbolic arg/receiver is present. +- Run lifecycle / solver modes / PRINT: `instrument/Intrinsics.java` `terminate()`; + `config/Config.java` `solver.mode` (default LOCAL). + +## Gotchas + +- The **legacy `de/uzl/its/value/**` suite (~1670 tests) fails** for unrelated API-migration reasons + — scope your runs to the `heap`/`processor` packages. +- `@Symbolic` on a **local variable** crashes the annotation transformer + (`AnnotationTransformer.transform`, `NoThreadContextException`) without `-g`; L2 targets use it on + a **parameter**. (Candidate hardening if you touch the transformer — not required.) +- We deliberately did **not** merge `feat/svcomp-testcase-metadata` (stats.json) — it's an L3 + enabler; L2 reads the `TraceDTO` directly. +- `@PendingFeature` masks *any* failure as pending; that's why each red spec asserts currently-true + preconditions first (or as a separate non-pending feature) so an infra break is a real failure. + Keep that convention when you add red tests. + +## Suggested order + +1. **G1** → make O-4 + O-5 green (canonical registry, faithful key, fix `IF_ACMPEQ`, update O-5's + registration call). Watch the two O reds flip; remove their `@PendingFeature`. +2. **G2** → make V-1 (L1) + V-1 (L2) green (collapse policy at the value-type invoke boundary). + Remove their `@PendingFeature`. Keep V-2/V-5/V-6/V-9/F-1/F-2 green. +3. Build the re-entry fixture and add O-1/O-2/O-3/O-6; then proceed through G_oob/G4a/G3/G4b, + adding the corresponding tests (E/F/D/U) as each mechanism lands. + +The `swat-test` skill (`.claude/skills/swat-test/SKILL.md`) has the per-level recipe for adding the +remaining tests. From 67505c4b1b3401cd4d6e9811a98b81f7cc736c8c Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 21:11:29 +0000 Subject: [PATCH 04/33] feat(heap): G1 - canonical reference-keyed shadow registry Re-key the shadow heap from System.identityHashCode (int) to the concrete object reference, via a Guava MapMaker().weakKeys() identity map. Reference identity is collision-free and gives one canonical shadow per concrete object, fixing the duplicate-wrapper / hash-collision / unregistered-object bugs and making ObjectValue.IF_ACMPEQ (this==o2) correct. - JVMHeap/ShadowContext: Object-keyed put/get/heapLookup; weak identity map. - visitGETVALUE_Object: heap calls keyed by inst.val (concrete ref); the int address retained only for NULL/de-intern/delegation/debug. - LDC_Object: thread the concrete constant and key recovery on it. - Declare Guava explicitly (was a transitive leak). - L1 fixture redesigned: this-return vs new-object via the concrete result object, not a fabricated int address. Acceptance: O-4/O-5 green (L0); V-2/F-1/F-2/V-5/V-6/V-9 green; V-1 stays pending (that is G2); L2 anchor green through the real agent. Reviewed by three independent agents (style/refactoring/correctness). Known residual: String keys self-pin (StringValue holds its key), so they do not evict - documented, no worse than the previous never-evicting map. Design + review: docs/heap-redesign-g1-design.md Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g1-design.md | 80 +++++++++++++++++++ settings.gradle | 1 + symbolic-executor/build.gradle | 1 + .../symbolic/SymbolicInstructionVisitor.java | 22 ++--- .../swat/symbolic/instruction/LDC_Object.java | 13 ++- .../AbstractInstructionProcessor.java | 2 +- .../uzl/its/swat/symbolic/shadow/JVMHeap.java | 44 +++++++--- .../swat/symbolic/shadow/ShadowContext.java | 16 ++-- .../swat/symbolic/heap/FlagPolicySpec.groovy | 4 +- .../symbolic/heap/ObjectIdentitySpec.groovy | 57 +++++++------ .../symbolic/heap/ValueRecoverySpec.groovy | 10 +-- ...aseSymbolicInstructionProcessorSpec.groovy | 25 +++--- 12 files changed, 196 insertions(+), 79 deletions(-) create mode 100644 docs/heap-redesign-g1-design.md diff --git a/docs/heap-redesign-g1-design.md b/docs/heap-redesign-g1-design.md new file mode 100644 index 0000000..7b87747 --- /dev/null +++ b/docs/heap-redesign-g1-design.md @@ -0,0 +1,80 @@ +# G1 — Canonical Registry + Faithful Key: Problem & Proposed Solution + +Phase 1 of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (converged after 1 independent review round — see "Review round 1 — resolutions" below). Acceptance green at all levels: O-4 + O-5 green (L0), V-2/F-1/F-2 stay green, V-1 stays pending (correct — that's G2), L2 anchor green through the real agent. No half-migrated API (int heap key fully replaced by the concrete-reference key; `int address` retained only for NULL/de-intern/delegation/debug). Implementation summary at the end of this doc. + +## Problem (recap, grounded) + +The shadow heap is a *recovery cache* — `JVMHeap` = `HashMap` keyed by `System.identityHashCode` (`shadow/JVMHeap.java`), consulted only when a reference re-enters from unmodeled code as a `PlaceHolder` (`SymbolicInstructionVisitor.visitGETVALUE_Object` `getFromHeap(inst.address)`; also `visitLDC_Object`, delegation). Frames hold *direct* `ObjectValue` references, so in-flow aliasing works by shared pointers; the heap is the fallback to re-correlate a bare reference with its shadow. + +Four confirmed bugs (audit): + +- **(a) duplicate shadow per concrete object** — `visitNEW`/`visitLDC_String` create+push without registering; canonical registration is incidental (only if a `GETVALUE_Object` later fires while the object is stack-top). Distinct wrappers for one object → divergent field maps. +- **(c) collision + no eviction** — 31-bit `identityHashCode` key in a never-evicted `HashMap`: distinct live objects collide (second `put` overwrites); dead-object hashes get reused. +- **(d) unregistered objects** — as (a): `NEW`/arrays/`LDC_String` register only incidentally. +- **`==` correctness** — `ObjectValue.IF_ACMPEQ` is `bfm.makeBoolean(this == o2)` (`ObjectValue.java:153`), i.e. *wrapper* Java-identity. This is correct **iff** there is exactly one wrapper per concrete object — which (a)/(d) violate. So duplicate wrappers make real `==` semantics wrong. + +Root cause: the correlation key (`identityHashCode`) is not faithful (collisions; not unique), and there is no canonicalization (find-or-create) guaranteeing one shadow per concrete object. + +## Proposed solution + +**Make the heap a canonical, faithful-key registry; keep frames holding direct references (cached pointers); canonicalize at the boundary sites only.** No full per-access indirection (the GETVALUE path is hot; full indirection would add a lookup per object touch — non-goal per the plan). + +1. **Faithful key = the concrete object reference.** Replace the int-`identityHashCode`-keyed map with an identity map keyed by the actual concrete `Object` (reference equality, not hash). Use a weak-keyed identity map so dead objects evict — Guava `new MapMaker().weakKeys().makeMap()` (Guava already a dependency; `weakKeys()` switches the map to `==` key comparison and weak references). This fixes (c): distinct objects never collide (reference identity); dead entries are GC'd (eviction → fixes the O-6 reuse hazard). + +2. **Canonicalize (find-or-create) at every introduce/recover site.** One shadow per concrete object: + - `visitGETVALUE_Object` already carries the concrete object as `inst.val` (`AbstractInstructionProcessor.GETVALUE_Object` passes `v`). Recovery becomes `registry.get(inst.val)`; on miss, create the shadow and `registry.put(inst.val, shadow)`. The existing `ADDRESS_UNKNOWN` adopt-peek branch becomes the canonical register-on-first-sight for `NEW`/array results (they already flow through a `GETVALUE_Object`). + - **`visitLDC_Object` currently drops the object** — `AbstractInstructionProcessor.LDC(long,Object)` builds `new LDC_Object(iid, identityHashCode(c))` (`:35`). Thread the object through (`LDC_Object(iid, identityHashCode, c)`) so it can canonicalize by reference like the others. (`LDC_String` already carries the string.) + - delegation path (`visitGETVALUE_Object` `:1374`): key by the delegated object reference too. + +3. **`register-on-create`.** `NEW`/`*NEWARRAY`/`LDC_String`/`LDC_Object` results are canonicalized at their immediately-following `GETVALUE_Object` (which has the concrete ref). No object is left unregistered before it can re-enter. (Fixes (a)/(d).) + +4. **Keep `IF_ACMPEQ = this == o2` unchanged.** Once canonicalization guarantees one wrapper per concrete object, wrapper-identity *is* concrete-identity, so `this == o2` is correct **and collision-free** (it never consults the 31-bit hash). We deliberately do **not** switch `IF_ACMPEQ` to compare `address`, because `address` stays `identityHashCode` and address-comparison would re-introduce collisions. + +5. **`address` keeps its current meaning** (`identityHashCode`, with `0`=null, `-1`=unknown) — used only for the NULL check (`IFNULL`/`IFNONNULL`), de-interned-string compare (`ObjectsInvocation`), and debug labels. It is **not** the equality key. (Optional later cleanup: give each canonical shadow a unique id and retire `address`-as-hash; out of scope for G1.) + +6. **Inspection seam unchanged in contract.** `ShadowContext.heapSize()/heapLookup/heapEntries` and `JVMHeap.size()/values()` keep their semantics (one entry per canonical identity; `heapSize()` = distinct identities). The registry implements the same view, so the O-tests' assertions are stable. + +This fixes (a), (c), (d), and the `==` bug. It does **not** fix V-1 (value-type this-return aliasing) — under a faithful key, `registry.get(this-returned-object)` still returns the receiver's shadow *because it is genuinely the same object*; G2's value-type policy is what stops identity-recovery for immutables. G1 before G2 is correct ordering. + +## Acceptance tests — and a contradiction to resolve + +The intended acceptance reds are **O-4** ("two wrappers for one identity compare equal") and **O-5-heap** ("colliding-hash objects don't merge → `heapSize()==2`"). As currently written (L0, `ObjectIdentitySpec`), they construct `ObjectValue`s **directly** at a chosen `address`, with no concrete object behind them. That creates a real contradiction: + +- **O-4-same** (red): `a=objectAt(0x2000)`, `b=objectAt(0x2000)` → asserts `IF_ACMPEQ` **valid** (equal). +- **O-5-distinct** (green guard): `a=objectAt(0x5000)`, `b=objectAt(0x5000)` → asserts `IF_ACMPEQ` **unsatisfiable** (unequal). + +These are the *same construction* (two distinct wrappers sharing an address) with **opposite** expectations. No `IF_ACMPEQ` — wrapper-based or address-based — can satisfy both: wrapper-identity makes both unequal (O-4 fails, current state), address-equality makes both equal (O-5 breaks). The L0 manual construction cannot encode the real distinction ("same concrete object" vs "distinct objects, colliding hash"), because that distinction lives in the concrete reference, which L0 doesn't have. + +**Proposed resolution:** the canonicalization behavior must be exercised *through the registry by concrete reference*, not by manual wrapper construction: + +- **O-4** → obtain the two references by canonicalizing the **same** concrete object twice (`registry.findOrCreate(obj)` → returns the *same* wrapper) → `IF_ACMPEQ` true. (Keep `this==o2`.) +- **O-5** → canonicalize **two distinct** concrete objects (distinct refs, even with a colliding `identityHashCode`) → two distinct wrappers, `heapSize()==2`, and `IF_ACMPEQ` false. + +This makes O-4 and O-5 consistent and tests the actual fix. The truest home for these is L1/L2 (real re-entry of real objects); at minimum the L0 specs must be rewritten to drive the registry's find-or-create with concrete references rather than fabricated wrappers at fixed addresses. (The handoff already anticipated rewriting O-5's `when:` block; this extends that to O-4 and explains why.) + +## Risks / open questions for review + +- **Weak identity map semantics & perf.** Guava `weakKeys()` map at introduce/recover (not per-access). Is the added lookup acceptable on the hot GETVALUE path? Any concern with weak-ref GC timing causing a live-but-evicted miss (then a fresh shadow is created — sound but loses field state)? +- **Threading the concrete ref to `LDC_Object`** (and delegation): correct, and are there other heap-consult sites that only have the int? +- **`address` left as `identityHashCode`**: any remaining code that *relies* on `address` for identity/equality (beyond NULL/de-intern/debug) that would still be collision-exposed? +- **NULL handling** under reference keying: `inst.val == null` ⇒ NULL value (don't register). Confirmed sufficient? +- **Is keeping `this==o2` (vs a unique-id) the right call for G1**, deferring the unique-id cleanup — or does leaving `address`=hash leave a latent trap? +- **Test-rework correctness:** is the O-4/O-5 resolution above sound, and does it actually exercise canonicalization rather than tautology? + +## Review round 1 — resolutions + +Accepted from review: + +- **[B1] Declare Guava explicitly.** Guava is currently only a *transitive* leak (build.gradle:35-47 declares no guava; `ObjectValue.java:3` imports `ImmutableSet` only because java-smt/z3 jars drag it in). Add an explicit `implementation` on guava (catalog entry) as a prerequisite before using `MapMaker`; this also de-risks the existing `ImmutableSet`. +- **[B2] Don't tautologize the L0 tests; move behavioral acceptance to L1.** Resolution refined: **keep `IF_ACMPEQ = this == o2`** unchanged, so the existing green L0 guards stay green and unchanged — "O-4 distinct objects" (different addresses) and "O-5 distinct objects, colliding hash" (two distinct wrappers → `this==o2` false → unequal). The two **red** behavioral cases move to **L1** (real `GETVALUE_Object` recovery by reference): O-4 = re-enter the *same* concrete object twice → registry returns the *same* wrapper → `this==o2` true; O-5 = re-enter two *distinct* concrete objects (even colliding hash) → distinct wrappers → `heapSize()==2` and unequal. At L0 keep only a *non-circular structural* assertion (e.g. distinct keys ⇒ distinct entries that does not pre-assume find-or-create); do not rewrite the green guards into registry-tautologies (would lose their infra-break protection). The contradiction the doc named is real (O-4-same `0x2000/0x2000` expect-equal vs O-5-distinct `0x5000/0x5000` expect-unequal are the same construction with opposite expectations), and "keep `this==o2` + move the reds to L1" is what dissolves it — manual same-address wrappers can't encode same-object vs distinct-colliding. +- **[C1] Weaken the `==`-correctness claim to the accurate version.** Not "canonicalization guarantees one wrapper everywhere." Accurate: for plain reference objects `this==o2` is correct because **either** both operands are in-flow shared pointers (`visitIF_ACMPEQ` pops them straight off the stack, no registry), **or** a recovered operand was canonicalized at the boundary. Strings don't use this path at all (`StringValue.IF_ACMPEQ:81` uses formula equality). **Residual gap (documented, acceptable for G1):** a `NEW`/array result that escapes to unmodeled code *before its first `GETVALUE_Object`* re-enters unregistered → a second wrapper. Registration happens at the first `GETVALUE_Object` (where the concrete ref exists; the ref is not available at `visitNEW`), so "register-on-create" = "register at first GETVALUE." This narrows but does not fully close bug (a); note it. +- **[C2] `LDC_Object` threading is feasible and a bonus fix.** The instrumentation already pushes the constant for the dispatch (`InstructionMethodAdapter.visitLdcInsn`), so `LDC(long,Object)` receives the real object — threading it is pure plumbing. Bonus: today an LDC'd object becomes a bare addressed `ObjectValue` with `null` concrete (`visitLDC_Object` → `createObjectValue(null, inst.c)`); threading the ref fixes that too. Caveat: guard the `Class`/`MethodHandle`/`MethodType` constant case (reflection branch in `ValueFactory`). +- **[C4] Finish the delegation path** (`visitGETVALUE_Object` ~:1374): key by the delegated object reference too. Note (pre-existing, not G1's to fix): delegation detection uses mutable flags on the shared static processor singleton. +- **[C5] `address`-as-`identityHashCode` collision is NOT debug-only.** `ObjectsInvocation.invokeEquals` decides the `referenceSemanticChange` flag via `getAddress() != getAddress()` — a real soundness signal keyed on the 31-bit hash, so collisions can under-flag it. G1 leaves this (de-interning is G3); record it as a known residual to retire when `address` becomes a unique id. +- **Nits:** `equals()` NPEs when `fields==null` (latent, no callers — add a guard if touched); `getConcrete()` returns `address` for `ObjectValue` (no identity-comparison callers — inert); `visitIF_ACMPEQ` records the branch twice (pre-existing — flag to test authors so oracle counts aren't confused). + +Pushed back (reviewer error to confirm): + +- **[C3] Weak-key eviction does NOT cause live-object precision loss.** A `weakKeys()` entry is cleared only when the key (the concrete object) is **unreachable from GC roots** — i.e. dead. While the program holds the object anywhere (reachable), the weak key is retained regardless of whether the shadow side can "see" it; GC reachability is a property of the program's strong refs, not the shadow's visibility. So there is **no** "live-but-evicted" hazard: eviction happens exactly when the object can never re-enter again (which is precisely O-6's desired behavior). Therefore weak `IdentityHashMap`/`MapMaker().weakKeys()` is **sound and precise for live objects AND fixes O-6** — no collision/precision trade, contrary to the round-1 note. (Injected-id/JVMTI remain future options, but are not needed to avoid an eviction problem that doesn't exist.) Reviewer to confirm this reasoning. + +Confirmed by review (no change): G1 correctly does NOT fix V-1 (a `this`-returned genuinely-same object recovering the receiver's shadow is correct *identity* behavior; G2's value-type policy is what stops it). Reference-keying does not disturb V-1/V-2/F-1/F-2 or the de-interned-string formula-equality path. One heap per thread (per-visitor `ShadowContext`), so the registry needs no extra locking. diff --git a/settings.gradle b/settings.gradle index defcd23..786fe1c 100644 --- a/settings.gradle +++ b/settings.gradle @@ -74,6 +74,7 @@ dependencyResolutionManagement { library('logback-core', 'ch.qos.logback:logback-core:1.5.3') library('slf4j-api', 'org.slf4j:slf4j-api:2.0.13') library('cfg-extractor', 'de.uzl.its:cfg-extractor:1.0-SNAPSHOT') + library('guava', 'com.google.guava:guava:33.4.8-jre') bundle('asm', ['asm-core', 'asm-commons', 'asm-util', 'asm-tree']) bundle('logging', ['logback-core', 'logback-classic', 'slf4j-api']) diff --git a/symbolic-executor/build.gradle b/symbolic-executor/build.gradle index f5efe30..e7f0f18 100644 --- a/symbolic-executor/build.gradle +++ b/symbolic-executor/build.gradle @@ -38,6 +38,7 @@ dependencies { implementation libs.bundles.asm implementation libs.jackson.databind implementation libs.java.smt + implementation libs.guava implementation rootProject.fileTree(dir: 'libs/java-library-path', include: ['*.jar']) // loads com.microsoft.z3 and java-smt diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 7ab9a02..d5a3b0d 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1267,7 +1267,7 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc // remove the placeholder value stack.popOperand(); // try to get object - tmp = stack.getFromHeap(inst.address); + tmp = stack.getFromHeap(inst.val); // check if the object was created earlier and then reuse it if (tmp != null) { if (isSymbolic) { @@ -1291,7 +1291,7 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc tmp.MAKE_SYMBOLIC(); } stack.pushOperand(tmp); - stack.putToHeap(inst.address, tmp); // save the object for future use + stack.putToHeap(inst.val, tmp); // save the object for future use (keyed by concrete ref) if (placeHolder.origin == PlaceHolder.ValueOrigin.GETFIELD) { ObjectValue ref = placeHolder.referenceValue; GETFIELD gfInst = (GETFIELD) placeHolder.inst; @@ -1323,20 +1323,20 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc stack.popOperand(); StringValue val = ValueFactory.createStringValue(s, inst.address); stack.pushOperand(val); - stack.putToHeap(inst.address, val); + stack.putToHeap(inst.val, val); } else { (peek.asObjectValue()).setAddress(inst.address); - stack.putToHeap(inst.address, peek); + stack.putToHeap(inst.val, peek); } } else { (peek.asObjectValue()).setAddress(inst.address); - stack.putToHeap(inst.address, peek); + stack.putToHeap(inst.val, peek); } } else { // Need to obtain the Object address (peek.asObjectValue()).setAddress(inst.address); - stack.putToHeap(inst.address, peek); + stack.putToHeap(inst.val, peek); } } else if ((peek.asObjectValue()).getAddress() == ADDRESS_NULL) { SWATAssert.check(inst.val == null, @@ -1371,7 +1371,7 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc stack.popOperand(); // Fetch the delegated object from the heap (e.g., IntReader) - Object heapObj = stack.getFromHeap(inst.address); + Object heapObj = stack.getFromHeap(inst.val); if (heapObj instanceof Value) { Value delegatedObject = (Value) heapObj; // Push the delegated object onto the stack @@ -1382,7 +1382,7 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc try { Value delegatedObject = de.uzl.its.swat.symbolic.value.ValueFactory.createObjectValue(inst.val, inst.address); stack.pushOperand(delegatedObject); - stack.putToHeap(inst.address, delegatedObject); + stack.putToHeap(inst.val, delegatedObject); logger.debug("Created new delegated object from instruction: {}", delegatedObject); } catch (Exception e) { logger.error("Failed to create delegated object from instruction value", e); @@ -2706,14 +2706,14 @@ public void visitLDC_String(LDC_String inst) throws SymbolicInstructionException */ public void visitLDC_Object(LDC_Object inst) throws SymbolicInstructionException{ try{ - Value tmp = stack.getFromHeap(inst.c); + Value tmp = stack.getFromHeap(inst.object); if (tmp != null) { stack.pushOperand(tmp); - } else if (inst.c == 0) { + } else if (inst.object == null) { stack.pushOperand(ValueFactory.createNULLValue()); } else { stack.pushOperand(tmp = ValueFactory.createObjectValue(null, inst.c)); - stack.putToHeap(inst.c, tmp); + stack.putToHeap(inst.object, tmp); } checkAndSetException(inst); } catch (Throwable t) { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/instruction/LDC_Object.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/instruction/LDC_Object.java index 1b51459..99eef8c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/instruction/LDC_Object.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/instruction/LDC_Object.java @@ -10,18 +10,23 @@ */ public class LDC_Object extends Instruction { - // The address of the object that's loaded (?) + // The identity hash of the loaded object (kept for address, NULL, and debug) public int c; + // The loaded constant object (the canonical-registry key, by reference identity) + public Object object; + /** - * Creates a new LDC_long instruction. + * Creates a new LDC_Object instruction. * * @param iid instruction id. - * @param c the address of the object that's loaded + * @param c the identity hash of the loaded object + * @param object the loaded constant object (the registry key) */ - public LDC_Object(long iid, int c) { + public LDC_Object(long iid, int c, Object object) { super(iid); this.c = c; + this.object = object; } /** diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/processor/AbstractInstructionProcessor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/processor/AbstractInstructionProcessor.java index 848d95f..3375ddb 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/processor/AbstractInstructionProcessor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/processor/AbstractInstructionProcessor.java @@ -32,7 +32,7 @@ public void LDC(long iid, String c) { } public void LDC(long iid, Object c) { - processInstruction(new LDC_Object(iid, System.identityHashCode(c))); + processInstruction(new LDC_Object(iid, System.identityHashCode(c), c)); } public void IINC(long iid, int var, int increment) { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java index fb45218..923b1d5 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java @@ -1,32 +1,52 @@ package de.uzl.its.swat.symbolic.shadow; +import com.google.common.collect.MapMaker; import de.uzl.its.swat.symbolic.value.Value; -import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue; -import de.uzl.its.swat.symbolic.value.reference.lang.IntegerObjectValue; -import de.uzl.its.swat.symbolic.value.reference.lang.StringValue; import java.util.Collection; -import java.util.HashMap; import java.util.Map; +/** + * Canonical shadow registry: maps a concrete object reference to its shadow {@link Value}. + * + *

The key is the concrete object itself, compared by reference identity ({@code ==}), not by + * {@code System.identityHashCode}. This is collision-free (distinct objects are distinct keys even + * when their identity hash collides) and gives exactly one shadow per concrete object. + * + *

Keys are held weakly ({@link MapMaker#weakKeys()}). For plain objects and boxed primitives the + * shadow value holds no strong reference to its concrete key, so an entry is evicted once the + * concrete object becomes unreachable. NOTE: a {@code StringValue} stores its own concrete + * {@code String}, which is also the key, so String-keyed entries are self-pinned and do not evict + * until the thread's context is discarded - no worse than the previous never-evicting map, and + * reference keying still removes the identity-hash collision/reuse hazard. (A unique-id key would + * also evict Strings; deferred.) + */ public class JVMHeap { - private final Map> objects; + private final Map> objects; public JVMHeap() { - objects = new HashMap<>(); + // weakKeys() => identity (==) key comparison + weak references. + objects = new MapMaker().weakKeys().makeMap(); } - public void put(int hashCode, Value value) { - objects.put(hashCode, value); + public void put(Object ref, Value value) { + if (ref == null) { + return; + } + objects.put(ref, value); } - public Value get(int hashCode) { - return objects.get(hashCode); + public Value get(Object ref) { + if (ref == null) { + return null; + } + return objects.get(ref); } + /** - * Number of registered cells. On the legacy identity-hash-keyed heap this also equals the - * number of distinct keys, so colliding identities are undercounted (a known defect). + * Number of registered cells. With reference keying this is the number of distinct concrete + * objects currently held (collision-free). * * @return the number of entries currently held. */ diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java index 3756505..b722d83 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java @@ -45,12 +45,12 @@ public ShadowContext() { // lambdaFrameStore = new HashMap<>(); } - public void putToHeap(int hashCode, Value value) { - heap.put(hashCode, value); + public void putToHeap(Object ref, Value value) { + heap.put(ref, value); } - public Value getFromHeap(int hashCode) { - return heap.get(hashCode); + public Value getFromHeap(Object ref) { + return heap.get(ref); } /** * Number of registered recovery-cache cells. See {@link JVMHeap#size()}. @@ -62,13 +62,13 @@ public int heapSize() { } /** - * Looks up a registered shadow value by its identity key (address), without mutating state. + * Looks up a registered shadow value by its concrete object reference, without mutating state. * - * @param address The identity key (object address / identity hash). + * @param ref The concrete object (identity key). * @return The registered value, or null if none. */ - public Value heapLookup(int address) { - return heap.get(address); + public Value heapLookup(Object ref) { + return heap.get(ref); } /** diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy index e2415b3..3c29acc 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy @@ -22,7 +22,7 @@ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { StringValue receiver = new StringValue(solverContext, "abc", 0x1000) // concrete (not made symbolic) when: - def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc", 0x1000) + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc") then: "no symbolic data flowed into the black box, so no context loss" !result.contextLoss @@ -36,7 +36,7 @@ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { receiver.MAKE_SYMBOLIC() when: - def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc", 0x1000) + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, "abc") then: "symbolic data reached the unmodeled method, so context loss is flagged" result.contextLoss diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy index 3c07412..11c219c 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy @@ -3,18 +3,17 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.symbolic.shadow.ShadowContext import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.symbolic.value.reference.ObjectValue -import spock.lang.PendingFeature import spock.lang.See /** - * O-4 — reference-equality correctness. Level L0, Phase 1 (G1 canonical heap). + * O-4 / O-5 — object reference-equality and the canonical registry. Level L0, Phase 1 (G1). * - * Two shadow wrappers for the SAME concrete object (a duplicate registration) must compare - * reference-equal. {@code ObjectValue.IF_ACMPEQ} currently uses {@code this == o2} on the wrapper, - * so duplicate wrappers wrongly compare unequal — a correctness defect the canonical registry fixes. - * - * Convention: the "already holds" feature has no {@code @PendingFeature} and guards against - * infra breakage; the desired-but-unimplemented behavior is marked {@code @PendingFeature} (red). + * The G1 registry keys the heap by the concrete object reference (identity), so it returns one + * canonical wrapper per concrete object and keeps distinct objects distinct even when their identity + * hashes collide. {@code ObjectValue.IF_ACMPEQ} stays {@code this == o2}, which is correct under that + * one-wrapper-per-identity guarantee. These specs assert both: same object → same wrapper → equal + * (O-4); distinct objects → distinct wrappers / two entries → unequal (O-5). The faithful end-to-end + * recovery is additionally anchored at L2 (see HeapRecoveryV1AgentSpec). */ class ObjectIdentitySpec extends BaseValueSpec { @@ -33,14 +32,20 @@ class ObjectIdentitySpec extends BaseValueSpec { } @See("docs/heap-redesign-tests.md") - @PendingFeature(reason = "G1 canonical registry not yet implemented; IF_ACMPEQ uses this==o2, so duplicate wrappers for one identity compare unequal") - def "O-4: two wrappers for the same identity compare reference-equal"() { - given: "two distinct wrappers sharing one identity (the duplicate-registration bug)" - ObjectValue a = objectAt(0x2000) - ObjectValue b = objectAt(0x2000) + def "O-4: the same concrete object recovers the same canonical wrapper (reference-equal)"() { + given: "a shadow registered under a concrete object" + Object obj = new Object() + ObjectValue shadow = objectAt(0x2000) + ShadowContext ctx = new ShadowContext() + ctx.putToHeap(obj, shadow) + + when: "the same concrete object is looked up twice" + def a = ctx.getFromHeap(obj) + def b = ctx.getFromHeap(obj) - expect: "reference equality must hold for the same identity (RED until G1)" - isValid(a.IF_ACMPEQ(b)) + then: "both recover the same wrapper, so they compare reference-equal" + a.is(b) + isValid((a as ObjectValue).IF_ACMPEQ(b as ObjectValue)) } @See("docs/heap-redesign-tests.md") @@ -54,19 +59,21 @@ class ObjectIdentitySpec extends BaseValueSpec { } @See("docs/heap-redesign-tests.md") - @PendingFeature(reason = "G1 faithful key not implemented; the identity-hash-keyed heap merges distinct objects that share an identity hash") - def "O-5: the heap stores colliding-hash objects without merging them"() { - given: "two distinct objects whose identity hash (address) collides" - int collidingHash = 0x5000 - ObjectValue a = objectAt(collidingHash) - ObjectValue b = objectAt(collidingHash) + def "O-5: distinct concrete objects are stored without merging (reference keying)"() { + given: "two distinct concrete objects, with shadows that happen to share an address" + Object o1 = new Object() + Object o2 = new Object() + ObjectValue a = objectAt(0x5000) + ObjectValue b = objectAt(0x5000) - when: "both are registered on the recovery heap" + when: "both are registered under their concrete references" ShadowContext shadow = new ShadowContext() - shadow.putToHeap(collidingHash, a) - shadow.putToHeap(collidingHash, b) + shadow.putToHeap(o1, a) + shadow.putToHeap(o2, b) - then: "both survive distinctly (RED until G1: the hash key collapses them to one)" + then: "the reference-keyed registry keeps distinct objects as distinct entries" + // (Structural contract of reference keying; the behavioral collision/recovery coverage is at + // L1 (ValueRecoverySpec) and the L2 anchor.) shadow.heapSize() == 2 } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy index b82f15f..8314339 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -9,10 +9,10 @@ import spock.lang.See /** * Value-typed boundary recovery at Level L1 (the workhorse fixture). An unmodeled value-returning - * call must not let its result alias the receiver's symbolic value — whether the call returns + * call must not let its result alias the receiver's symbolic value - whether the call returns * {@code this} (V-1) or a fresh object (V-2). The only difference between the two below is the - * result's address, which isolates the identity-keyed recovery as the defect. See - * docs/heap-redesign-tests.md. + * result's object identity (the receiver's own object for a this-return vs a distinct object), + * which isolates the reference-keyed recovery as the defect. See docs/heap-redesign-tests.md. */ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { @@ -39,7 +39,7 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { when: "toLowerCase() is invoked (unmodeled) and the this-return is recovered" def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, - concrete, receiverAddress) // this-return: result address == receiver address + concrete) // this-return: the result object IS the receiver's concrete object then: "preconditions that hold today: context loss flagged, concrete correct" result.contextLoss @@ -60,7 +60,7 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { when: "the result returns at a fresh address (a distinct object), not the receiver's" def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, - "abc", 0x2000) // fresh address => new object, not a this-return + new String("abc")) // distinct object => new-object return, not a this-return then: "context loss is flagged, the concrete is the real result, and there is no aliasing" result.contextLoss diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy index 418c115..ea51ee6 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy @@ -279,26 +279,29 @@ abstract class BaseSymbolicInstructionProcessorSpec extends Specification { /** * L1 boundary-recovery fixture. Drives the real unmodeled-invoke -> recovery path: * - * register {@code receiver} on the heap at its address; push it as the invoke instance; then - * process INVOKEVIRTUAL(owner,name,desc) -> INVOKEMETHOD_END -> GETVALUE_Object(resultAddress, concreteResult). + * register {@code receiver} under its concrete object; push it as the invoke instance; then + * process INVOKEVIRTUAL(owner,name,desc) -> INVOKEMETHOD_END -> GETVALUE_Object(resultObject). * * An unmodeled method returns a PlaceHolder (InvocationHandler), INVOKEMETHOD_END pushes it, and - * GETVALUE_Object reconciles it against the identity-keyed heap. Passing - * {@code resultAddress == receiver.address} reproduces a this-return (the toLowerCase aliasing - * defect); a fresh {@code resultAddress} reproduces a new-object return. + * GETVALUE_Object reconciles it against the reference-keyed heap. Passing {@code resultObject} + * identical to the receiver's concrete object reproduces a this-return (the toLowerCase aliasing + * defect); a distinct {@code resultObject} reproduces a new-object return. * - * NOTE (honest limit): this fixture *fabricates* the result/receiver address relationship that a + * NOTE (honest limit): this fixture *fabricates* the result/receiver identity relationship that a * real JVM would produce, so it pins the interpreter's recovery logic, not the - * instrumentation->processor contract. Pair flagship cases with an L2 agent run. + * instrumentation->processor contract. Pair flagship cases with an L2 agent run. It also relies + * on a value-type {@code getConcrete()} (a StringValue returns its String, so registration keys on + * that String); a plain ObjectValue receiver would key on its address and never recover. * * @return a map [recovered: Value, contextLoss: boolean, referenceSemanticChange: boolean] */ def executeBoundaryRecovery(ObjectValue receiver, String owner, String name, String desc, - Object concreteResult, int resultAddress) { + Object resultObject) { SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) ShadowContext context = visitor.getStack() - // Register the receiver so a placeholder result can be recovered from the heap by identity. - context.putToHeap(receiver.getAddress(), receiver) + // Register the receiver under its concrete object, so a this-return (resultObject identical to + // the receiver's concrete) is recovered by reference identity; a distinct resultObject misses. + context.putToHeap(receiver.getConcrete(), receiver) // Push the receiver as the invoke instance (consumed by newStackFrame in INVOKEVIRTUAL). pushOperand(receiver) @@ -308,7 +311,7 @@ abstract class BaseSymbolicInstructionProcessorSpec extends Specification { // closes (closeOpenInvoke) and METHOD_END is visited despite the frame's class differing. instructions.add(new INVOKEVIRTUAL(GlobalStateForInstrumentation.instance.incAndGetId(), invokeId, owner, name, desc)) instructions.add(new INVOKEMETHOD_END(GlobalStateForInstrumentation.instance.incAndGetId(), invokeId)) - instructions.add(new GETVALUE_Object(GlobalStateForInstrumentation.instance.incAndGetId(), resultAddress, concreteResult, 0)) + instructions.add(new GETVALUE_Object(GlobalStateForInstrumentation.instance.incAndGetId(), System.identityHashCode(resultObject), resultObject, 0)) instructions.add(new NOP(GlobalStateForInstrumentation.instance.incAndGetId())) // Drive: setCurrent(first); each processInstruction(next) visits the *current* and advances. From e84b46e4a83b4fcda6aed75fadcd1f5957d0c69d Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 21:50:54 +0000 Subject: [PATCH 05/33] feat(heap): G2 - concretize unmodeled value-typed returns (no identity recovery) After G1 the heap is reference-keyed, but an unmodeled value-returning method whose result IS the receiver (e.g. String.toLowerCase() returning `this`) still re-binds the result to the receiver's symbolic formula via getFromHeap. Fix: at the unmodeled-invoke boundary, stop identity-recovering value types. - PlaceHolder: new ValueOrigin.UNMODELED_RETURN. - InvocationHandler: tag every unmodeled placeholder return with it, strictly after the context-loss recording (PlaceHolder uses identity equality). - Util.isValueType(Object): String + all 8 boxed wrappers. - visitGETVALUE_Object: for an UNMODELED_RETURN placeholder whose concrete result is a value type, concretize via createObjectValue (constant formula, no variables) and skip the heap entirely; non-value results fall through to G1 recovery. Not overwriting the heap preserves V-3 (a genuine symbolic-string round-trip still recovers its formula). Acceptance: V-1 green at L1 (ValueRecoverySpec) and L2 (HeapRecoveryV1AgentSpec, real agent); V-2/F-1/F-2/V-5/V-6/V-9/O-4/O-5 stay green; 0 pending. Converged after 1 design review + 3 independent post-impl reviews (style/refactoring/ correctness). Residual: result-round-trip re-aliasing is deferred to G3 (only identity-splitting closes it); it cannot cause a wrong verdict (VIOLATION is replay-witnessed; SAFE is downgraded by context loss). Design + review: docs/heap-redesign-g2-design.md Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g2-design.md | 72 +++++++++++++++++++ .../java/de/uzl/its/swat/common/Util.java | 18 +++++ .../symbolic/SymbolicInstructionVisitor.java | 16 +++++ .../symbolic/invoke/InvocationHandler.java | 8 +++ .../its/swat/symbolic/value/PlaceHolder.java | 5 +- .../heap/HeapRecoveryV1AgentSpec.groovy | 2 - .../symbolic/heap/ValueRecoverySpec.groovy | 2 - 7 files changed, 118 insertions(+), 5 deletions(-) create mode 100644 docs/heap-redesign-g2-design.md diff --git a/docs/heap-redesign-g2-design.md b/docs/heap-redesign-g2-design.md new file mode 100644 index 0000000..352e0f7 --- /dev/null +++ b/docs/heap-redesign-g2-design.md @@ -0,0 +1,72 @@ +# G2 — Value-type boundary recovery (collapse v1): Problem & Proposed Solution + +Phase 2 of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (converged after 1 design-review round + 3 independent post-impl reviews for style/refactoring/correctness). V-1 green at L1 **and** L2 (real agent); V-2/F-1/F-2/V-5/V-6/V-9/O-4/O-5 stay green; 0 pending across heap+processor+agentTest. The design-review resolutions are at the bottom; B2 was settled by reading the explorer (VIOLATION is replay-witnessed, so no downgrade flag is needed). Builds on G1 (`67505c4`). + +## Problem (recap, grounded) + +After G1 the heap is keyed by the concrete object reference. That makes G1's object cases correct — but it does **not** fix the value-type aliasing bug (V-1), and *cannot*, because the aliasing is semantically faithful at the identity level: + +`String.toLowerCase()` on an already-lowercase receiver returns `this`. So the unmodeled call's result *is the receiver object*. Flow today (post-G1): + +1. `visitINVOKEVIRTUAL` (toLowerCase, unmodeled) → `InvocationHandler.invoke(...)` returns `PlaceHolder.instance` and records context loss (symbolic receiver) — `SymbolicInstructionVisitor.java:2319+`, `InvocationHandler.java:76-122`. +2. `visitINVOKEMETHOD_END` pushes that placeholder onto the caller stack (`:3406-3413`). +3. `visitGETVALUE_Object` sees the placeholder; `inst.val` is the concrete result = **the receiver object** (this-return); `getFromHeap(inst.val)` returns the **receiver's symbolic `StringValue`** → the result aliases the receiver's formula (`:1265-1294`). + +So a value produced by an unmodeled method is re-bound to its receiver's symbolic value. The receiver is a real symbolic input; the result is a different logical value we have no model for — but identity-recovery makes them one. (`invokeToLowerCase` is a `PlaceHolder` stub, `StringValue.java:1125`.) + +The right behavior (plan §G2, "collapse v1"): at a **mirrored invoke boundary of an unmodeled, value-returning method**, do **not** identity-recover for value types; produce a concretized (non-symbolic) result and keep the already-recorded context-loss flag. + +## Proposed solution (collapse v1) + +**Tag the unmodeled value-typed return, and concretize it at recovery instead of identity-recovering.** + +1. **New placeholder origin** `PlaceHolder.ValueOrigin.UNMODELED_RETURN` (alongside UNSPECIFIED/DATABASE/GETFIELD/GETSTATIC). +2. **Tag at the single chokepoint — `InvocationHandler.invoke`.** After the existing missing-invocation / context-loss recording runs (which must still observe the plain `PlaceHolder.instance`), if the result is an unmodeled placeholder **and** the method's return type is a value type (`Type.getReturnType(desc)` ∈ `Util.deInternedClasses` = String + 6 boxed wrappers), return `new PlaceHolder(ValueOrigin.UNMODELED_RETURN, …)` instead of `PlaceHolder.instance`. All 5 invoke sites route through here, so this is the only tag site. +3. **Concretize at recovery — `visitGETVALUE_Object`.** When the placeholder's origin is `UNMODELED_RETURN`: skip `getFromHeap` entirely; build a concretized value from `inst.val` via `ValueFactory.createObjectValue(inst.val, inst.address)` (for a String this yields a `StringValue` whose formula is the **constant** `makeString(bytes)` — non-symbolic, so it carries no variables) and push it. Context loss is already flagged (step 2's path is unchanged). +4. **Do NOT overwrite the receiver's heap entry.** Leave `heap[receiverObject] = receiver` intact. This preserves **V-3** (a genuinely symbolic string stored and re-fetched *unchanged* still recovers its formula). The cost: see residual below. + +Net: the result no longer carries the receiver's formula (V-1 fixed); it's concrete + context-loss-flagged; V-2/V-3/F-1/F-2 are untouched. + +## Why this shape + +- **Tag, not type-sniff-at-recovery.** `UNSPECIFIED` placeholders arise from several sources (generic pushes, etc.); only the unmodeled-invoke return should change behavior. A dedicated origin is surgical and mirrors how GETFIELD/GETSTATIC already tag their placeholders (`:1191-1193`, `:1222-1224`). +- **Tag after the context-loss logic** so `InvocationHandler`'s `retValue.equals(PlaceHolder.instance)` check (`:110`) still fires and records the flag; only the *returned* value is swapped. +- **Concretize = createObjectValue with a constant formula** — the existing factory path already produces a non-symbolic value; no new "concretize" primitive needed. + +## Scope / non-goals + +- **Value types only** (`deInternedClasses`). Unmodeled returns of other reference types keep G1 behavior (identity-recover or create fresh). +- **No UF** (G4b). A pure whitelisted method would later return `UF_m(inputs)` instead of concrete; deferred. +- **No φ-set / branching** (v2). Recovery yields a single concrete value, not a candidate set. +- **No output de-interning** (G3). + +## Known residual (documented, deferred to G3) + +Because we don't overwrite the heap, the *result* of a this-return is concretized on the stack but the shared object still maps to the receiver. If that result then round-trips through unmodeled memory and re-enters via `getFromHeap`, it re-aliases to the receiver — the V-1-via-round-trip variant. This is the fundamental overloaded-identity problem (result and receiver are one object); only **splitting the identity (G3 output de-interning)** closes it. Overwriting the heap here would close the result's round-trip but break V-3's (the receiver's round-trip), so v1 keeps V-3 and defers the result-round-trip to G3. + +## Acceptance tests + +Flip green: **V-1 (L1)** `ValueRecoverySpec` (recovered result's vars disjoint from receiver's; concrete correct; context loss flagged) and **V-1 (L2)** `HeapRecoveryV1AgentSpec` (branch on the real `toLowerCase` result does not reference the symbolic input). Remove their `@PendingFeature`. + +Stay green: V-2 (new-object — already non-aliasing), F-1/F-2 (flag policy — `InvocationHandler` context-loss path unchanged), V-5/V-6/V-9, O-4/O-5, processor specs, L2 anchor. + +## Risks / open questions for review + +- **Tag placement vs the context-loss check.** Is returning a tagged placeholder *after* the recording correct, and does anything else compare the return against `PlaceHolder.instance` by identity (`==`) or `equals` and break when it's a tagged instance? (e.g. callers of `InvocationHandler.invoke`, `setReturnValue`, METHOD_END.) +- **Return-type detection.** Is `Type.getReturnType(desc).getClassName()` ∈ `deInternedClasses` the right test? `deInternedClasses` is a private `Set` of `Class.getName()` — need a small accessor (`Util.isValueTypeName(String)` or similar). Boxed return descriptors (`Ljava/lang/Integer;` etc.) map correctly? +- **Concretize correctness.** Does `createObjectValue(inst.val, inst.address)` for a boxed wrapper (Integer/Long/…) also yield a non-symbolic value (no leaked variables)? Any path where `inst.val` is null here? +- **The "don't overwrite" choice** vs V-3: is preserving the receiver entry the right call, and is the result-round-trip residual genuinely G3's to close (not a v1 soundness hole that bites SV-COMP verdicts — note context loss already downgrades the verdict)? +- **Interaction with G1 registration.** After G1, the receiver was registered under its concrete object during its own GETVALUE. Confirm the UNMODELED_RETURN path doesn't need to (and doesn't) touch that entry. +- **Does any modeled method ever return `PlaceHolder.instance`** for a value-typed return (so it'd be wrongly tagged)? (Modeled String ops return real values; verify the stub set.) + +## Review round 1 — resolutions + +Accepted from review: + +- **[B1 + C3, unified] Gate the *concretize* on the concrete `inst.val` type at recovery, not the declared return type at tag time.** Tag **all** unmodeled invoke returns as `UNMODELED_RETURN` (no return-type gating in `InvocationHandler`). In `visitGETVALUE_Object`, the `UNMODELED_RETURN` branch concretizes **iff `Util.isValueType(inst.val)`**, else falls back to the existing G1 recovery (`getFromHeap`/create). This closes both the **Float/Double gap** (`deInternedClasses` is String + 6 wrappers and *excludes* Float/Double — the "6 boxed wrappers" framing was wrong; Java has 8) and the **declared-vs-concrete mismatch** (a method declared `CharSequence`/`Object`/`Number` but concretely returning a String/box now still concretizes) in one move. Non-value-typed unmodeled object returns are unchanged from G1. +- **New `public Util.isValueType(Object)`** = `String || Number || Boolean || Character` (String + all 8 boxed wrappers); `null` ⇒ false. Independent of `deInternedClasses` (which stays as the de-intern/`==` concern and deliberately omits uncached Float/Double — don't widen it and change `==` semantics). +- **[#1 ordering] The tag swap goes strictly after `InvocationHandler.java:108`** (the `equals(PlaceHolder.instance)` context-loss check), with a code comment so a later refactor can't move it ahead and silently kill the flag. `PlaceHolder` has no `equals` override (Object identity), and `Frame.setRet`/the `symbolicInstance`/`i==0` checks don't disturb a tagged instance — verified by review. +- **[#5] Gate is `equals(PlaceHolder.instance)` specifically** (not "any PlaceHolder"): `PlaceHolder.symbolicInstance` (symbolic non-String receivers) and all modeled real results are never tagged. Review confirmed `placeholder ⇒ unmodeled` is safe across the String/boxed stub set. +- **[nits]** Guard `inst.val == null` in the concretize branch (→ fall back); add `UNMODELED_RETURN` to the enum (toString readability optional). + +[B2] **downgrade-safety claim corrected.** I overstated it: context loss downgrades **SAFE→UNKNOWN only** (`SVCompDriver.py:304`); VIOLATION is downgraded only by `reference_semantic_change` (`:316`). The base V-1 case is fully sound. The residual (a concretized this-return result round-trips through unmodeled memory, re-enters, re-aliases, then drives a branch) is narrow and closed by G3 (output de-interning splits the identity). Open: in a concolic engine a reported VIOLATION should be a *concretely witnessed* real input replayed to the error — if so, the residual can only change *which inputs we try*, never manufacture a false VIOLATION; SAFE is the only verdict at risk and context loss already downgrades it. To verify during implementation: confirm SWAT's VIOLATION is replay-witnessed; if it is **not**, arm a downgrade (`reference_semantic_change` or equivalent) on the `UNMODELED_RETURN` concretize path. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java index 4c2e490..c880b75 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java @@ -429,4 +429,22 @@ private static boolean isDeInternedClass(Class clazz) { return deInternedClasses.contains(clazz.getName()); } + /** + * Whether a concrete object is an immutable value type (String / boxed primitive). + * Used by recovery to concretize an unmodeled value-returning method's result instead of + * identity-recovering it (G2). Independent of {@link #deInternedClasses} (the de-intern / + * reference-equality concern, which omits the uncached Float/Double): this covers String and all + * eight boxed wrappers ({@link Number} = Byte/Short/Integer/Long/Float/Double, plus Boolean and + * Character). + * + * @param o the concrete object (may be null) + * @return true if {@code o} is a value type + */ + public static boolean isValueType(Object o) { + return o instanceof String + || o instanceof Number + || o instanceof Boolean + || o instanceof Character; + } + } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index d5a3b0d..6f93d68 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -6,6 +6,7 @@ import ch.qos.logback.classic.Logger; import de.uzl.its.swat.common.ErrorHandler; +import de.uzl.its.swat.common.Util; import de.uzl.its.swat.common.exceptions.*; import de.uzl.its.swat.common.logging.GlobalLogger; import de.uzl.its.swat.coverage.BranchCoverage; @@ -1266,6 +1267,21 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc // remove the placeholder value stack.popOperand(); + + // G2: the result of an unmodeled value-returning method must NOT be identity-recovered + // (that would re-bind the receiver's symbolic value, e.g. toLowerCase() returning + // `this`). Concretize the value type instead, and do NOT consult or mutate the heap, so + // the receiver's own entry (and its V-3 round-trip) is preserved. Context loss was + // already flagged in InvocationHandler. Non-value results fall through to G1 recovery. + if (placeHolder.origin == PlaceHolder.ValueOrigin.UNMODELED_RETURN + && Util.isValueType(inst.val)) { + tmp = ValueFactory.createObjectValue(inst.val, inst.address); + Logger shadowStateLogger = ThreadHandler.getShadowStateLogger(currentThread().getId()); + shadowStateLogger.info("Concretized unmodeled value-typed result (no identity recovery): {}", tmp); + stack.pushOperand(tmp); + return; + } + // try to get object tmp = stack.getFromHeap(inst.val); // check if the object was created earlier and then reuse it diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index d1c465b..e8593e0 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -117,6 +117,14 @@ public class InvocationHandler { } } + + // G2: tag an unmodeled placeholder return so visitGETVALUE_Object can concretize a value-typed + // result instead of identity-recovering it (which would re-bind the receiver's symbolic value, + // e.g. String.toLowerCase() returning `this`). This MUST stay after the context-loss check + // above, which compares retValue against PlaceHolder.instance by identity. + if (retValue == PlaceHolder.instance) { + retValue = new PlaceHolder(PlaceHolder.ValueOrigin.UNMODELED_RETURN, null, null); + } return retValue; } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index 24a2683..53a8092 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -34,7 +34,10 @@ public enum ValueOrigin { UNSPECIFIED, DATABASE, GETFIELD, - GETSTATIC + GETSTATIC, + // The return value of an unmodeled method (tagged in InvocationHandler). At recovery, a + // value-typed result with this origin is concretized rather than identity-recovered (G2). + UNMODELED_RETURN } public final boolean isSymbolic; diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy index ace5767..0798ab7 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.PendingFeature import spock.lang.See import spock.lang.Specification @@ -28,7 +27,6 @@ class HeapRecoveryV1AgentSpec extends Specification { } @See("docs/heap-redesign-tests.md") - @PendingFeature(reason = "G2 not implemented; toLowerCase this-return aliases the receiver, so the branch on the result references the symbolic input") def "V-1 (L2): a branch after unmodeled toLowerCase must not reference the symbolic input"() { when: TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy index 8314339..2f40c58 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -4,7 +4,6 @@ import de.uzl.its.swat.common.Util import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import org.sosy_lab.java_smt.api.Formula -import spock.lang.PendingFeature import spock.lang.See /** @@ -24,7 +23,6 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { } @See("docs/heap-redesign-tests.md") - @PendingFeature(reason = "G2 value-type boundary recovery not yet implemented; result still aliases the receiver's formula") def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { given: "a symbolic, already-lowercase String receiver registered on the heap" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") From 5b35699163efe142c50671c7a61c2709f9c49c2f Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 25 Jun 2026 23:10:24 +0000 Subject: [PATCH 06/33] feat(heap): G_oob - configurable out-of-band change detection at GETVALUE When a tracked object is mutated inside unmodeled code, the shadow's concrete diverges from the real value at the next GETVALUE sync. Today the primitive divergence path hard-fails (SWATAssert.check(false, ...)) in every assertion- enabled mode - an out-of-band change crashes rather than being a recoverable soundness signal. Make the response configurable. - New ShadowDivergence policy (config `shadow.divergence`): CRASH (default; the original hard assert, for dev/CI bug catching) | FLAG (graceful: record symbolic_context_loss -> SAFE downgraded to UNKNOWN, adopt the observed concrete, continue; sound, recommended for production). - visitGETVALUE_primitive: gate the divergence assert on the policy; the adopt-observed recovery (previously dead after the always-failing assert) is now shared and reachable. Default CRASH = byte-for-byte prior behavior. - Reuses the existing symbolic_context_loss flag (already SAFE-downgrading) rather than a new flag. Scope: primitive GETVALUE path only. Deferred to G4a: the escape-aware DIFFERENTIATED policy (crash only on genuine executor desync, flag legitimate out-of-band) + the escape bit, so the escape set is built once with the purity whitelist; plus StringBuilder/object-ref/array divergence detectors. Acceptance: GoobDetectionSpec E-1 (FLAG detects: flag + re-ground, no crash) and E-2 (no false positive) green; heap+processor+agentTest all green; 0 pending. Converged after 1 design-review round + 3 independent post-impl reviews (style/refactoring/correctness). Design + review: docs/heap-redesign-goob-design.md Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-goob-design.md | 87 +++++++++++++++++++ .../java/de/uzl/its/swat/config/Config.java | 6 ++ .../symbolic/SymbolicInstructionVisitor.java | 21 ++++- .../symbolic/shadow/ShadowDivergence.java | 22 +++++ .../symbolic/heap/GoobDetectionSpec.groovy | 80 +++++++++++++++++ 5 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/heap-redesign-goob-design.md create mode 100644 symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy diff --git a/docs/heap-redesign-goob-design.md b/docs/heap-redesign-goob-design.md new file mode 100644 index 0000000..6b0dec6 --- /dev/null +++ b/docs/heap-redesign-goob-design.md @@ -0,0 +1,87 @@ +# G_oob — Out-of-band change detection (no havoc): Problem & Proposed Solution + +Phase "G_oob" of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (1 design-review round + 3 independent post-impl reviews for style/refactoring/correctness). Builds on G1 (`67505c4`) + G2 (`e84b46e`). Addresses confirmed bug (b): a tracked object's concrete state diverging from its shadow (e.g. mutated inside unmodeled code) is not handled soundly. + +**Landed scope (user decision: "configurable now, differentiate with G4a"):** a configurable `shadowDivergence` policy = **CRASH** (default; the original hard `SWATAssert`, for dev/CI bug-catching) | **FLAG** (graceful: record context loss → SAFE downgraded to UNKNOWN, adopt the observed concrete, continue; sound, recommended for production). Scoped to the **primitive `GETVALUE`** path only. **Deferred to G4a:** the `DIFFERENTIATED` (escape-aware) policy and the escape bit — so the escape set is built once, faithfully, with the purity whitelist; and StringBuilder/object-ref/array divergence detectors. Reuses the existing `symbolic_context_loss` flag (already SAFE→UNKNOWN) rather than wiring a new `outOfBandChange` flag (a clean fast-follow if per-stat distinction is wanted). Acceptance: E-1/E-2 green (L1, `GoobDetectionSpec`); all heap+processor+agentTest green; default CRASH = byte-for-byte prior behavior. + +## Problem (recap, grounded) + +When a tracked object is passed to unmodeled code that mutates it, the shadow `fields[]` go stale. The divergence surfaces at the next `GETVALUE` concrete-sync (the shadow value's concrete vs the real observed value). Today that divergence is **detected but handled as a hard failure**, not a graceful soundness signal: + +- **Primitives:** `visitGETVALUE_primitive` (`SymbolicInstructionVisitor.java:3631-3641`): on mismatch (`!checkEquality(peek.concrete, inst.v)`) it executes `SWATAssert.check(false, "[GETVALUE_primitive]: Value on stack does not match expected value!...")` — a *literal-false* assert that always fails when reached, so it throws/halts (depending on `exitOnError`). The recovery right after it (pop stale, push a fresh concrete from `inst.v`, `:3635-3641`) is therefore **dead code** — unreachable past the always-failing assert. +- **String / StringBuilder:** the placeholder/`ADDRESS_UNKNOWN` recovery has the same shape — `SWATAssert.check(inst.val.equals(peek.concrete), "Concrete value of the object does not match...")` (`:1334`) and the StringBuilder variant (`:1296`). + +So an out-of-band change is **a crash** (or, with `exitOnError=false`, a thrown `SymbolicInstructionException`), in every mode — never a recorded, recoverable soundness loss. (The earlier audit's "stale value silently trusted" framing was for paths where no `GETVALUE` re-syncs; where a `GETVALUE` does fire, it currently crashes.) + +## Proposed solution (detect, no havoc, all modes) + +**Convert the hard divergence-assert into a graceful, flagged detection.** At each `GETVALUE` concrete-sync, when the observed concrete diverges from the shadow's concrete: + +1. **Record an out-of-band-change soundness flag** (see flag choice below) — the value's symbolic state is now unsound, so downstream verdicts can be downgraded. +2. **Adopt the observed concrete** — replace the stale shadow value with a fresh **non-symbolic** value built from the observed concrete (`ValueFactory.createNumericalValue(type, inst.v)` / `createObjectValue`), restoring the concrete-grounding invariant. This is the recovery that already sits (dead) after the assert. +3. **Continue** — no crash, no havoc of the wider object graph (v1 is *detect-only*: we flag the one diverged value and re-ground it; we do not invalidate other fields or attempt repair). + +This is **mode-independent**: `GETVALUE` is part of the shadow interpreter and runs under every `solver.mode` (LOCAL/HTTP/PRINT/NONE), so detection fires "in all modes." + +Concretely: at `:3631-3641` replace the `SWATAssert.check(false, …)` with `record-flag` + the (now-live) adopt-observed recovery; do the same at the String/StringBuilder asserts (`:1296`, `:1334`). + +## Flag choice (open — leaning new flag) + +`SymbolicTrace` has `symbolicContextLoss` and `referenceSemanticChange`. An out-of-band change is neither exactly. Proposed: add a dedicated trace flag (e.g. `outOfBandChange`) with getter/recorder mirroring the existing two, surfaced on `TraceDTO`, and downgrade verdicts like context loss does (SAFE→UNKNOWN). VIOLATION needs no downgrade (replay-witnessed, as established in G2). Alternative: reuse `referenceSemanticChange` (avoids new wiring) — but it muddies that flag's meaning. Reviewer to weigh in. + +## Scope / non-goals + +- **Detect-only.** No havoc of the object's other fields, no attempt to re-symbolize. Just flag + re-ground the diverged value. +- **No escape-tracking.** We do not (yet) distinguish "legitimate out-of-band mutation (object escaped to unmodeled code)" from "executor-internal desync bug." That distinction needs escape/leak tracking (G4a territory) and is deferred. + +## The key risk (open question for review) + +The divergence-asserts currently serve **two** purposes: catching legitimate out-of-band changes (which we now want to flag gracefully) **and** catching executor-internal desync **bugs** (which we want to keep catching loudly). Blanket flag-and-continue would **mask real executor bugs** as benign out-of-band changes — a regression in bug-detection sharpness. Options: +- **(a)** Flag-and-continue for all divergences (simplest; matches "detect in all modes"; loses dev-time bug catching). +- **(b)** Config-gated: keep the hard assert under a strict/debug flag (e.g. tie to `exitOnError` or a new `strictShadow`), flag-and-continue otherwise. Preserves dev bug-catching, graceful in real runs. *(Recommended.)* +- **(c)** Only flag-and-continue when the object is known to have escaped (needs escape-tracking → defer). + +## Acceptance tests + +- **E-1 (detection):** a tracked value whose concrete diverges from the observed concrete is flagged (not crashed) and the observed concrete is adopted. Cleanest at **L1** (fabricate the divergence: push a tracked `IntValue` concrete=10, run `GETVALUE_int` with `inst.v=20` → assert the out-of-band flag is set, no exception, value re-grounded to 20). Plus an **L2** variant if a real escape-then-mutate target is constructible (mutating mock), to satisfy "in all modes." +- **E-2 (no false positive):** a pure (no-mutation) call leaves no divergence → no flag. + +## Open questions for review + +- Flag choice (new `outOfBandChange` vs reuse `referenceSemanticChange`) + the verdict-downgrade rule. +- The masking-executor-bugs risk — option (a) vs (b) vs (c); is (b) the right call and what gates it? +- Are the three sync points (primitive, String, StringBuilder) the complete set, or are there other `GETVALUE`/reconcile divergence points (object-ref identity, arrays) that should detect too? +- Is adopting the observed concrete (dropping the stale symbolic value) the right v1 behavior, or should the diverged value become a fresh symbol (havoc)? (Plan says no havoc → concrete; confirm.) +- Clean up the now-dead post-assert recovery code as part of this. + +## Review round 1 + user steering — REVISED design + +Round-1 review (and the user) confirmed the draft's grounding was partly wrong and the existing handling is incomplete — so we do NOT trust it: + +- **Not three uniform sync points.** Only the *primitive* path (`:3631-3641`) is "assert + (dead) adopt-observed recovery." The StringBuilder assert (`:1296`) has NO recovery (falls through pushing the stale value); object-ref divergence (`:1377`) and arrays (no per-element check) are uncovered. ⇒ **v1 scopes to the primitive path only**; StringBuilder/object-ref/arrays are explicit follow-ups (each needs a *constructed* recovery, not un-commenting). +- **`SWATAssert.check(false,…)` is config-dependent, not "crash in all modes."** `useAssertions=false` → logs-and-continues (recovery live); `exitOnError=true` (default) → halt; `exitOnError=false` (tests) → throws. And today the crash / `[SWAT Exception]` log / halt is *exactly what forces the verdict to UNKNOWN* (`SVCompDriver.py` ERROR + `[SWAT Exception]` line handling). So a silent flag MUST replace that downgrade or it's a soundness regression. +- **Verdict downgrade must be SAFE→UNKNOWN.** Reusing `referenceSemanticChange` is UNSOUND (it only downgrades VIOLATION, `SVCompDriver.py:316`). The new `outOfBandChange` flag must downgrade SAFE→UNKNOWN like `symbolic_context_loss` (`:304`). The "reuse" option is dropped. (VIOLATION stays replay-witnessed → no false VIOLATION.) +- Flag wiring is ~10 sites (SymbolicTrace field/recorder/getter → DTOBuilder → TraceDTO positional ctor → explorer DataTransferObjects/ConstraintController/ConstraintService/Database/Tree → SVCompDriver downgrade). Enumerated so it's not discovered mid-impl. + +**User steering — the core design change:** + +1. **Configurable policy** `shadowDivergence` = `DIFFERENTIATED` (default) | `FLAG` | `CRASH`. Not gated on `exitOnError` (a test/unrelated knob); a dedicated config (read live via `Config.instance()`, not a static-init snapshot). +2. **Escape-aware differentiation** (the principled "know when out-of-band is possible"): + - Mark a tracked `ObjectValue` `escaped` when it is passed as receiver/arg to an **unmodeled** method (conservative v1: any unmodeled call; G4a's purity whitelist later refines — a *pure* call can't mutate, so it won't mark escape). + - At a divergence under `DIFFERENTIATED`: if attributable to an **escaped** object → legitimate out-of-band → record `outOfBandChange` + adopt observed concrete + continue; if **not escaped** → genuine executor desync → **crash** (preserve the bug-catching net the assert was for). Only the actually-diverged value is re-grounded (not full havoc; non-diverged fields keep their symbolic value). + - `FLAG` = always graceful (production runs); `CRASH` = always strict (dev/CI). + +## Round-2 resolution (converged, pending scope decision) + +Reviewer verified the revised design; key correction + the landable cut: + +- **Correction:** `GETVALUE_primitive` is injected after EVERY value-producing opcode (ILOAD/ARRAYLENGTH/field-read/return), not just GETFIELD. So at the `:3631` divergence there is often **no container** (e.g. a stale local copied out of an escaped object, then ILOAD'd). The escape-aware bridge only covers the **direct field-read-of-escaped-object** case; ILOAD/array/transitive cases fall to the `not-escaped → crash` path. ⇒ the v1 escape set is an **under-approximation** (the *unsafe* direction for differentiation: a legitimate-but-unmarked out-of-band change crashes). `shadowDivergence=FLAG` is the fully-sound production escape hatch (every divergence → flag → SAFE→UNKNOWN). +- **Plumbing:** option (i) thread-local, set in `visitGETFIELD` when `ref.escaped`, **cleared at the start of every `visitGETVALUE_primitive`** (mandatory — else the bit leaks onto a later non-GETFIELD divergence and masks a real executor desync), read at the `:3631` branch. Reject (iii) (partial havoc, breaks V-3 on escaped-unmutated fields). +- **Marking site:** `InvocationHandler.invoke`, inside the existing `retValue instanceof PlaceHolder && !IGNORED` unmodeled block, after `arguments.add(0, instance)` (receiver+args unified); `instanceof ObjectValue` filter; at minimum mark array elements. Conservative "mark on all unmodeled calls" is sound for the flag direction (over-flag → more UNKNOWN, never false SAFE). Transitive closure + purity-refinement deferred to G4a. +- **Production policy:** until G4a makes the escape set faithful, SV-COMP/production should default to **FLAG** (fully sound); DIFFERENTIATED is for dev/CI (catches executor desync, gracefully handles the clean GETFIELD-escaped case). + +**Scope decision (for the user):** the escape-aware differentiation is landable now but partial (above); the faithful version rides with G4a, and production runs FLAG either way. So: (A) land configurable policy + new flag/SAFE-downgrade + shallow escape-aware differentiation now; or (B) land just the configurable policy (CRASH/FLAG) + flag/SAFE-downgrade now, and land escape-aware faithfully with G4a. + +--- + +### (superseded) Open for round-2 review: plumbing the container's `escaped` status to the primitive `GETVALUE` divergence point. The divergence is detected in `visitGETVALUE_primitive`, but `escaped` lives on the *container*, known at the immediately-preceding `GETFIELD` (which pops `ref`). Options: (i) a thread-local "last field-read was from an escaped container" set by `visitGETFIELD`, read+cleared by the next `GETVALUE`; (ii) tag the pushed field value transiently; (iii) `visitGETFIELD`, when `ref.escaped`, pushes a placeholder so `GETVALUE` re-grounds (but that re-grounds even unmutated fields ⇒ partial havoc). Reviewer to assess feasibility + pick the cleanest, and to sanity-check the escape-marking site (which invoke handling, and that it covers receiver + ref args). diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/config/Config.java b/symbolic-executor/src/main/java/de/uzl/its/swat/config/Config.java index 2f9a014..25796d7 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/config/Config.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/config/Config.java @@ -6,6 +6,7 @@ import de.uzl.its.swat.coverage.CoverageType; import de.uzl.its.swat.instrument.TransformerType; import de.uzl.its.swat.solver.SolverMode; +import de.uzl.its.swat.symbolic.shadow.ShadowDivergence; import java.io.FileInputStream; import java.io.IOException; import java.util.*; @@ -184,6 +185,10 @@ public class Config { @Getter private SolverMode solverMode; private static final SolverMode DEFAULT_SOLVER_MODE = SolverMode.LOCAL; + /** How to handle a shadow/concrete divergence at a GETVALUE sync point (out-of-band change). */ + @Getter @Setter private ShadowDivergence shadowDivergence; + private static final ShadowDivergence DEFAULT_SHADOW_DIVERGENCE = ShadowDivergence.CRASH; + // ------------------------------------ // General options // ------------------------------------ @@ -431,6 +436,7 @@ private void readProperties() { // Solver options // ------------------------------------ solverMode = SolverMode.valueOf(readString("solver.mode", DEFAULT_SOLVER_MODE.toString())); + shadowDivergence = ShadowDivergence.valueOf(readString("shadow.divergence", DEFAULT_SHADOW_DIVERGENCE.toString())); // ------------------------------------ // SV-Comp options diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 6f93d68..d898961 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -9,6 +9,7 @@ import de.uzl.its.swat.common.Util; import de.uzl.its.swat.common.exceptions.*; import de.uzl.its.swat.common.logging.GlobalLogger; +import de.uzl.its.swat.config.Config; import de.uzl.its.swat.coverage.BranchCoverage; import de.uzl.its.swat.instrument.GlobalStateForInstrumentation; import de.uzl.its.swat.instrument.Intrinsics; @@ -20,6 +21,7 @@ import de.uzl.its.swat.symbolic.processor.SymbolicInstructionProcessor; import de.uzl.its.swat.symbolic.shadow.Frame; import de.uzl.its.swat.symbolic.shadow.ShadowContext; +import de.uzl.its.swat.symbolic.shadow.ShadowDivergence; import de.uzl.its.swat.symbolic.trace.SymbolicTraceHandler; import de.uzl.its.swat.symbolic.value.PlaceHolder; import de.uzl.its.swat.symbolic.value.Value; @@ -3630,8 +3632,23 @@ private void visitGETVALUE_primitive(GETVALUE_primitive inst, ValueType type) th } } else if ((peek instanceof BoxedValue && !checkEquality(((BoxedValue)peek).getVal().concrete, inst.v)) || (!(peek instanceof BoxedValue) && !checkEquality(peek.concrete, inst.v))) { - SWATAssert.check(false, "[GETVALUE_primitive]: Value on stack does not match expected value! Expected: {}, Actual: {}", - inst.v, peek.concrete); + // The shadow's concrete diverges from the value the real JVM produced - an out-of-band + // change (e.g. a tracked object mutated inside unmodeled code) or an executor desync. + // CRASH preserves the original hard-fail (dev/CI bug catching); FLAG records a soundness + // flag (context loss -> SAFE downgraded to UNKNOWN) and adopts the observed concrete + // (sound, graceful). Escape-aware differentiation (crash only on genuine desync) is G4a. + if (Config.instance().getShadowDivergence() == ShadowDivergence.CRASH) { + SWATAssert.check(false, "[GETVALUE_primitive]: Value on stack does not match expected value! Expected: {}, Actual: {}", + inst.v, peek.concrete); + } else { + symbolicTraceHandler.recordSymbolicContextLoss(); + Logger shadowStateLogger = ThreadHandler.getShadowStateLogger(currentThread().getId()); + shadowStateLogger.info( + "Out-of-band change detected (shadow {} != observed {}); adopting observed concrete", + peek.concrete, inst.v); + } + // Adopt the observed concrete (re-ground): always under FLAG; under CRASH only if the + // assert is soft (useAssertions=false), preserving the prior behavior. if (cat2) { stack.popWideOperand(); stack.pushWideOperand(ValueFactory.createNumericalValue(type, inst.v)); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java new file mode 100644 index 0000000..a29fe7a --- /dev/null +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java @@ -0,0 +1,22 @@ +package de.uzl.its.swat.symbolic.shadow; + +/** + * Policy for handling a shadow/concrete divergence detected at a GETVALUE sync point: the shadow + * value's concrete no longer matches the value the real JVM produced (an out-of-band change, e.g. an + * object mutated inside unmodeled code). Configured via {@code shadow.divergence}. + */ +public enum ShadowDivergence { + /** + * Hard-fail on divergence (SWATAssert) - preserves the original behavior, which is useful for + * catching executor-internal desync bugs in dev/CI. Default. + */ + CRASH, + + /** + * Detect gracefully: record a soundness flag (context loss -> SAFE downgraded to UNKNOWN), adopt + * the observed concrete value, and continue. Fully sound; recommended for SV-COMP / production + * runs (no spurious crashes). Until escape-aware differentiation lands (G4a), this does not + * distinguish a legitimate out-of-band change from an executor desync - both are flagged. + */ + FLAG +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy new file mode 100644 index 0000000..b92f020 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy @@ -0,0 +1,80 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.config.Config +import de.uzl.its.swat.instrument.GlobalStateForInstrumentation +import de.uzl.its.swat.symbolic.SymbolicInstructionVisitor +import de.uzl.its.swat.symbolic.instruction.GETVALUE_int +import de.uzl.its.swat.symbolic.instruction.Instruction +import de.uzl.its.swat.symbolic.instruction.NOP +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.processor.SymbolicInstructionProcessor +import de.uzl.its.swat.symbolic.shadow.ShadowDivergence +import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue +import de.uzl.its.swat.thread.ThreadHandler +import spock.lang.See + +/** + * E-1 / E-2 (Level L1): out-of-band change detection at a primitive GETVALUE sync. When the value the + * real JVM produced diverges from the tracked shadow's concrete (e.g. a field mutated inside unmodeled + * code), the {@code shadowDivergence=FLAG} policy detects it gracefully — record context loss, adopt + * the observed concrete, and do NOT throw — instead of the CRASH policy's hard assert. The + * escape-aware "crash only on genuine desync" differentiation is deferred to G4a. See + * docs/heap-redesign-tests.md and docs/heap-redesign-goob-design.md. + */ +class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { + + private ShadowDivergence savedPolicy + + def setup() { + savedPolicy = Config.instance().getShadowDivergence() + } + + def cleanup() { + Config.instance().setShadowDivergence(savedPolicy) + } + + /** Drives a single GETVALUE_int(observed) over the current operand-stack top. */ + private void runGetValueInt(int observed) { + List instructions = new ArrayList<>() + instructions.add(new GETVALUE_int(GlobalStateForInstrumentation.instance.incAndGetId(), observed, 0)) + instructions.add(new NOP(GlobalStateForInstrumentation.instance.incAndGetId())) + // setCurrent(first); processing the next visits the *current* (execution is one behind). + ThreadHandler.setCurrentInstruction(threadId, instructions.remove(0)) + SymbolicInstructionProcessor processor = new SymbolicInstructionProcessor() + while (!instructions.isEmpty()) { + processor.processInstruction(instructions.remove(0)) + } + } + + @See("docs/heap-redesign-tests.md") + def "E-1: under FLAG a diverging primitive GETVALUE is detected (flag + re-ground), not crashed"() { + given: "a tracked int shadow with concrete 10, under the FLAG policy" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + Config.instance().setShadowDivergence(ShadowDivergence.FLAG) + SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) + visitor.getStack().pushOperand(new IntValue(solverContext, 10)) + + when: "the real JVM produced a different value (20) for that slot - an out-of-band change" + runGetValueInt(20) + + then: "no crash; context loss flagged; the operand is re-grounded to the observed concrete" + ThreadHandler.getSymbolicTraceHandler(threadId).isSymbolicContextLoss() + visitor.getStack().getActiveFrame().peek().concrete == 20 + } + + @See("docs/heap-redesign-tests.md") + def "E-2: under FLAG a matching primitive GETVALUE records no flag (no false positive)"() { + given: + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + Config.instance().setShadowDivergence(ShadowDivergence.FLAG) + SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) + visitor.getStack().pushOperand(new IntValue(solverContext, 10)) + + when: "the observed value matches the shadow" + runGetValueInt(10) + + then: "no divergence, so no context-loss flag" + !ThreadHandler.getSymbolicTraceHandler(threadId).isSymbolicContextLoss() + } +} From 48763bcc0d3bc0a4678d56849e6a09cddfa43250 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Fri, 26 Jun 2026 11:51:48 +0000 Subject: [PATCH 07/33] feat(heap): G4 step 1 - generic UF for whitelisted pure unmodeled returns + SAFE-preserving exemption After G2, an unmodeled value-returning pure method's result is concretized, dropping the symbolic relationship to its inputs and downgrading SAFE. G4-full models such a method as a generic, axiom-free uninterpreted function over its inputs and makes that precision-preserving, so SAFE is preserved through the call. Mechanism (executor): - Whitelist of pure, deterministic, UNMODELED JDK methods (UFs/PureMethods; v1 starter: String.trim/strip) + descriptive UF naming pure__. - UFs/PureFunctionUF: per-context registry declaring one generic UF per signature (arg sorts from the input formulas), cached. - InvocationHandler.buildPureUF builds pure_(inputs) for a whitelisted call with a symbolic, value-typed input; carried on the UNMODELED_RETURN placeholder (PlaceHolder.recoveredFormula) and materialized at visitGETVALUE_Object as a StringValue(concrete=observed, formula=UF). v1: String returns only. SAFE preservation (both downgrades avoided for a modeled pure call): - precision_loss: DTOBuilder.isPrecisionLoss extracted to a testable predicate using a FormulaVisitor (visitRecursively + FunctionDeclarationKind.UF) that exempts pure_ UFs while keeping bespoke/axiomatized UFs and non-input vars lossy. Input detection uses exact JavaSMT term identity against the designated inputs (symbolicTrace.getInputs()), fixing String/array inputs that the [A-Z].*_[0-9].* regex rejected (a pre-existing limitation); the regex is kept as an additive backstop for recovery-created (non-designated) symbolic vars. - context_loss: a successfully UF-modeled whitelisted pure call no longer records context loss (sound: whitelist => pure/side-effect-free, return captured by the UF). Non-whitelisted calls (e.g. toLowerCase) still flag it. Trace prep for a future explorer-side decision: BranchDTO carries a per-branch precisionLoss flag (parsed by the explorer, not yet consumed); the aggregate (OR) still drives the current verdict, so the executor remains authoritative for now. Tests: PrecisionLossExemptionSpec (5 exemption controls, realistic java/lang/String_0 inputs), PureFunctionUFSpec U-4/U-5/U-6/U-soundness. 42 heap+processor + 2 L2 green; L2 anchor still flags non-whitelisted toLowerCase. Converged after 1 design-review round (2 iterations) + 3 post-impl reviews + a re-review of the two blocker fixes. Follow-up (noted): an L2 trim-to-branch anchor for end-to-end SAFE preservation. Design + review history: docs/heap-redesign-g4-design.md Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g4-design.md | 118 ++++++++++++++++++ .../symbolic/SymbolicInstructionVisitor.java | 13 +- .../its/swat/symbolic/UFs/PureFunctionUF.java | 52 ++++++++ .../its/swat/symbolic/UFs/PureMethods.java | 56 +++++++++ .../uzl/its/swat/symbolic/UFs/UFHandler.java | 9 ++ .../symbolic/invoke/InvocationHandler.java | 56 ++++++++- .../its/swat/symbolic/trace/DTOBuilder.java | 95 +++++++++++--- .../swat/symbolic/trace/dto/BranchDTO.java | 11 +- .../its/swat/symbolic/value/PlaceHolder.java | 18 +++ .../heap/PrecisionLossExemptionSpec.groovy | 71 +++++++++++ .../symbolic/heap/PureFunctionUFSpec.groovy | 112 +++++++++++++++++ .../parse/DataTransferObjects.py | 4 + 12 files changed, 590 insertions(+), 25 deletions(-) create mode 100644 docs/heap-redesign-g4-design.md create mode 100644 symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java create mode 100644 symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy diff --git a/docs/heap-redesign-g4-design.md b/docs/heap-redesign-g4-design.md new file mode 100644 index 0000000..465e8b8 --- /dev/null +++ b/docs/heap-redesign-g4-design.md @@ -0,0 +1,118 @@ +# G4 — Purity whitelist + generic UF for unmodeled pure returns: Problem & Proposed Solution + +Phase G4 (G4a whitelist + G4b generic UF, designed as one phase). **Status: G4-full, converged after 2 design-review rounds; awaiting user sign-off.** Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`). + +## Round 2 — CONVERGED (exemption validated) + must-fixes + +Reviewer verified the `precision_loss` exemption is sound: (a) pruning — UNSAT-under-free-UF ⇒ real UNSAT ⇒ SAFE sound; (b) coverage — branch *enumeration* is concrete-driven (`SymbolicTraceHandler.checkAndSetBranch` records the real direction), so an ungrounded UF can't hide a reachable error branch, only affect flip-feasibility (where it's sound); (c) VIOLATION stays replay-witnessed. Scoping correct (bespoke UFs non-exempt; a `pure_` over a non-input var still downgrades — transitivity confirmed: `FormulaCreator.VariableAndUFExtractor.visitFunction` returns CONTINUE, descending into UF args). + +**Must-fixes (mechanical, fold into implementation):** +1. **Implement the exemption with a `FormulaVisitor` via `fmgr.visitRecursively`, checking `functionDeclaration.getKind() == FunctionDeclarationKind.UF` — NOT the keys-only `extractVariablesAndUFs` map.** The flat name→formula map can't distinguish a UF symbol from a variable, so the obvious implementation would be silently wrong. One pass collects `(name, isUF)`; apply input-regex to variables, `pure_` rule to UFs. +2. **Apply the exemption at BOTH sites** — `DTOBuilder.java:84-87` and the `InterruptedException` fallback `:100-102`. +3. **Extract the precision-loss predicate into a testable method** (the predicate currently lives inline in package-private `DTOBuilder`) so the negative control (exemption-not-too-broad) is a real L1 unit test; L2 (`TraceObservation` already parses `symbolicPrecisionLoss`) anchors it end-to-end. + +**Note (soundness-load-bearing):** with the exemption active, **context-loss becomes the only remaining SAFE downgrade on the UF path** — so "keep context-loss flagging on the UF path" is part of the soundness story, not just hygiene. (v1 doesn't whitelist `toLowerCase`, so F-2 is untouched.) Also acceptable-but-conservative: a whitelisted result that flows through a *bespoke* UF before a branch still downgrades (the bespoke UF taints it). + +## Round 3 — user steering: executor decides now, prepare the trace for a future explorer-side decision + +Decision (user): keep the **executor** making the precision-loss decision for now (the `pure_` exemption stays executor-side), but **enrich the trace** so the explorer has what it needs to take over the decision later — specifically an explorer-side, **CFG-reachability-aware** precision-loss decision ("downgrade SAFE only if a precision loss is on a path that can reach an assert"). The static CFG doesn't exist yet — it's a separate block planned soon — so we design today to feed it, not build it. + +Concretely (changes vs the Round-1/2 plan): +- **Per-branch classification.** Run the precision-loss classifier (the must-fix `FormulaVisitor`: input-regex for variables, `pure_` rule for UFs) **per `BranchElement`** in `DTOBuilder`, producing a per-branch boolean. The existing aggregate `symbolicPrecisionLoss` (TraceDTO) = OR of the per-branch flags → **verdict behavior is unchanged**; the explorer still downgrades on the aggregate, so the executor remains the authoritative decision-maker for now. +- **Trace contract.** `BranchDTO` gains a `precisionLoss` boolean (the executor's per-branch verdict), alongside its existing `iid`. The explorer parses it (forward-compatible) but does **not** consume it for the verdict yet. Rationale: a future CFG-aware decision keys each precision-loss branch by `iid` → CFG node → assert-reachability, with **no new trace contract** needed when that block lands. +- **Why per-branch + iid is sufficient prep:** the future decision needs only (a) *where* each precision loss occurred (the per-branch flag + `iid`, NEW) and (b) the static CFG (separate, future). Symbol-level detail, if ever needed, is recoverable from the per-branch constraint string already in `BranchDTO`. So this is minimal-yet-sufficient, not speculative schema. +- **Scope:** today only `precision_loss` gets the per-branch shape; `context_loss`/`reference_semantic_change` follow the same pattern later (same future arc). + +Net G4-full deliverable: the UF mechanism (executor) + the executor-side per-branch precision-loss classifier with the `pure_` exemption (aggregate drives the verdict, unchanged) + the per-branch `BranchDTO.precisionLoss` trace field parsed by the explorer (staged for the upcoming explorer-side, CFG-aware decision) + the tiny starter whitelist. + +**Scope (user decision):** purity whitelist + generic per-signature UF — the precision upgrade to G2. The escape-aware `DIFFERENTIATED` policy (deferred from G_oob) and StringBuilder/array/object divergence detectors are a *separate* follow-up that reuses this whitelist. Not G3 (output de-interning), which is unrelated. + +## Problem (recap) + +After G2, an unmodeled value-returning method's result is **concretized** (constant formula, non-symbolic) + context-loss-flagged — sound, but it drops the symbolic relationship to the inputs. For a method that is genuinely a *deterministic function of its inputs* (e.g. `String.toLowerCase()`), we can do better than "forget it": model the result as `result = f(inputs)` for an unknown-but-fixed `f`. That preserves the relational fact (equal inputs ⇒ equal outputs) and lets the explorer reason about / generate inputs through the call, without us knowing `f`'s definition. + +This is **not** the bespoke axiomatized UFs (`ToLowerCaseUF` etc.) — those live inside symbolic *models* and the heap redesign doesn't touch them. G4 is a **generic** uninterpreted function: one fresh UF symbol *per method signature*, applied to the call's symbolic inputs, with **no hand axioms**. + +## Why it's sound + +A generic uninterpreted function is a valid over-approximation of *any* deterministic function: it asserts only `inputs₁ = inputs₂ ⇒ f(inputs₁) = f(inputs₂)` (referential transparency). So for a method that is a deterministic, side-effect-free function of its captured inputs, `result = UF_sig(inputs)` is sound **by construction** — there are no axioms to get wrong. The soundness precondition collapses to: **the whitelist contains only genuinely pure + deterministic methods** (the G4a job). (Concrete grounding still holds: the result's concrete = the real observed value; the UF is uninterpreted, so nothing forces `UF(concrete-inputs) = concrete-result`, and the solver's choices are replay-validated — no false verdicts.) + +## Proposed solution + +**Architecture (A): build the UF formula where the inputs are (InvocationHandler), materialize the value where the concrete is (GETVALUE).** + +1. **Purity whitelist** (`Util` or a dedicated class): a set of method signatures (`owner/name:desc`) known to be pure + deterministic. Starter set kept *small* (the motivating cases, e.g. `java/lang/String.toLowerCase:()...`, `toUpperCase`, `trim`, `strip`), config-extensible. Default policy is "model only what a test needs" — most methods stay concretized (G2). +2. **At `InvocationHandler.invoke`** (the G2 tag site, where receiver+args are in hand): if the method is unmodeled (`retValue == PlaceHolder.instance`) **and** whitelisted-pure **and** ≥1 input is symbolic, build `Formula uf = ufmgr.callUF(declareUF(sig, returnType, argTypes…), inputFormulas…)` — argTypes from the input `Value`s' formula sorts, returnType from `Type.getReturnType(desc)`. Carry `uf` on the `UNMODELED_RETURN` placeholder (new optional `Formula recoveredFormula` field). Declarations cached per signature (a small registry, e.g. on `UFHandler`). If all inputs are concrete → no UF (fall to G2 concretize). +3. **At the `GETVALUE` UNMODELED_RETURN branch** (extends G2's concretize): if the placeholder carries a UF formula → materialize the result `Value` with `concrete = inst.val` (observed) and `formula = uf` (via the formula-taking ctor for the result type, e.g. `new StringValue(ctx, s, ufFormula, addr)`); else → G2 concretize (constant). No divergence check (built fresh from the observed concrete). + +**v1 scope:** String-returning whitelisted methods first (the motivating `toLowerCase`/`trim` cases; aligns with V-1). The mechanism generalizes to boxed/primitive returns (extend the result-type → FormulaType mapping + materialization); those are fast-follows, not v1. + +## Test interaction (expected) + +Whitelisting `toLowerCase` changes **V-1**: today V-1 asserts the result is `disjoint` from the receiver's variables (the G2 concretize outcome). The UF outcome `UF_toLowerCase(s)` legitimately *depends on* `s` (just isn't *aliased* — `formula ≠ s.formula`). So V-1's assertion moves from "disjoint" to "**depends on `s` but is not `s`'s formula**", and a new **U-5** (modeled-result precision: a whitelisted pure call's result carries a UF over the input, equal inputs ⇒ equal results) is added. If `toLowerCase` is *not* in the v1 starter whitelist, V-1 stays as-is and a different whitelisted method drives the U tests — decide during impl. + +## Acceptance tests + +- **U-5 (precision):** a whitelisted pure unmodeled method with a symbolic input → result depends on the input (its var set ⊇ the input's), is not concrete, and not aliased. +- **U-4 (relational/determinism):** the same whitelisted method applied twice to equal symbolic inputs ⇒ equal result formulas (same UF, same args). (Via `extractVariables` / SAT-agreement, not formula sorts.) +- **U-soundness (no over-constraint):** the UF adds no constraint that excludes a real behavior (it's axiom-free; assert the PC stays SAT for inputs the real method admits). +- Stay green: V-2, V-3, F-1/F-2, V-5/V-6/V-9, O-4/O-5, G_oob E-1/E-2, processor specs, L2 anchor. V-1 updated (above). + +## Risks / open questions for review + +- **Architecture (A):** is building the UF at `InvocationHandler` and materializing at `GETVALUE` correct — is `inst.val` (concrete) reliably present at the recovery for a whitelisted return, and is carrying a `Formula` on `PlaceHolder` clean? Any path where the carried formula's sort won't match the materialized value's type? +- **Generic UF construction:** does `UFManager.declareUF`/`callUF` support heterogeneous arg sorts (String + bitvector + …)? Correct per-signature **caching** (re-`declareUF` with the same name — safe, or must cache the `FunctionDeclaration`?). Deriving arg `FormulaType`s from input `Value`s and the return `FormulaType` from the descriptor — complete for String (v1) and the extension types? +- **Determinism precondition:** the whitelist must exclude methods that are pure-but-nondeterministic across runs/JVMs (locale-dependent case mapping, `hashCode`, iteration order, identity). How do we vet entries? (Conservative: tiny, hand-audited starter set; document the bar.) +- **Symbolic-input gate:** only build a UF when ≥1 input is symbolic — confirm concrete-only inputs correctly fall to G2 concretize (a UF over constants is pointless). +- **Soundness of concrete vs UF:** the materialized value has `concrete = observed` and `formula = UF(inputs)`, with no constraint tying them. Confirm this can't yield a false verdict (it shouldn't — UF is uninterpreted, solver models are replay-validated; same argument as G2/G_oob). +- **V-1 interaction:** is updating V-1's assertion (disjoint → depends-but-not-aliased) the right call, and should `toLowerCase` be in the v1 whitelist (forcing that change) or deferred? +- **Whitelist home/form:** `Util` set vs dedicated class; signature format; config-extensibility — what's cleanest and consistent with existing config patterns. + +## Review round 1 + decision: G4-FULL (UF + precision-preserving exemption) + +Decision: **G4-full** — the generic UF *and* a `precision_loss` exemption so SAFE is no longer downgraded through whitelisted pure calls. (G4-minimal would only steer input-generation; SAFE stays downgraded.) + +**Key review finding (B1, verified):** `DTOBuilder.java:84-87` sets `symbolic_precision_loss` if any branch-constraint symbol fails the input-var regex `[A-Z].*_[0-9].*`; `SVCompDriver.py:308-310` downgrades SAFE→UNKNOWN on it. A generic UF symbol fails the regex ⇒ SAFE always downgrades ⇒ a UF alone gives **no** verdict gain over G2. G4-full adds the exemption. + +**The exemption (the soundness-critical, new part):** change the `precision_loss` test (`DTOBuilder.java:84-87` and the analogous `:100-102`) from "all symbols match the input regex" to: **fire precision_loss unless every *variable* matches the input regex AND every *UF* is a `pure_`-namespaced generic UF.** Rationale: an axiom-free UF over real inputs is a sound over-approximation of any deterministic function — if a path is UNSAT with the UF free, the real function (one interpretation) is also UNSAT ⇒ SAFE sound. Crucially: **the bespoke axiomatized UFs (ToLowerCaseUF/EqualsIgnoreCaseUF/SinCosUF) stay NON-exempt** (their partial axioms' soundness is uncertain — the "alpha" ones), and any non-input *variable* still downgrades. `extractVariablesAndUFs` already surfaces nested vars, so a `pure_` over a non-input variable still downgrades (transitivity handled). Must distinguish UF-vs-variable in the extracted map. + +**Review fixes folded in:** +- **Do NOT whitelist `toLowerCase` in v1** — it's locale-dependent (Turkish-i) AND whitelisting it breaks V-2 and F-2 (both exercise `toLowerCase`), not just V-1. Lead with **`trim`/`strip`/`Math.abs`** (locale-independent, deterministic); leave V-1/V-2/F-1/F-2 untouched; drive U-4/U-5 with those. +- **Keep context-loss flagging on the UF path** — the UF is additive precision, never a license to remove a flag. +- **Value-typed receivers/args only** — `UF(receiver.formula, args)` is sound only when the result is a function of value-typed inputs; forbid whitelisting instance methods on stateful (non-value) receivers (result could depend on mutable fields not captured). +- **Descriptive, self-documenting UF names** `pure__[_]` ([A-Za-z0-9_] only) — e.g. `pure_String_trim`, `pure_Math_abs_int`, `pure_String_substring_int_int`. Reads as "the pure-function model of String.trim"; arg types disambiguate overloads; survives SMT dump/re-parse; cannot collide with the bespoke `toLowerCase`/etc. names. The `pure_` prefix is the exemption's recognizer (no internal phase jargon). Whitelist is curated (java.lang), so simple class names don't collide. +- **Cache per full signature** (`owner/name:desc`) on `UFHandler` (per-thread/solver-context); assert arg/return `FormulaType`s match on reuse. +- **Derive arg sorts via `fmgr.getFormulaType(formula)`** (not the descriptor) so they match the value's actual sort (avoids the Integer-theory-vs-Bitvector mismatch). +- **Gate on ≥1 symbolic input** via the existing `containsSymbolicArgument` — but read args BEFORE `arguments.add(0, instance)` mutates the list (N1). +- **Primitive-return path** (`visitGETVALUE_primitive`) intentionally ignores any carried UF formula in v1 (String returns only); assert/note it so it's not mistaken for a bug. + +**Whitelist construction:** hand-audited tiny starter (trim/strip/Math.abs) to prove the mechanism + exemption soundness; then an agent survey of String/Integer/Long/Short/Byte/Character/Boolean/Float/Double/Math/StrictMath (hazard checklist: nondeterministic, env/property readers, locale-no-arg, arg-mutating, identity/intern, default Object) to populate the broad list. Format `owner/name:desc`, config-extensible. + +**New acceptance test (G4-full):** a whitelisted pure call whose result feeds a branch ⇒ SAFE is **not** downgraded (precision_loss NOT set) when only `pure_`+input symbols appear; and a control where a non-input variable / non-`pure_` UF in a branch **still** sets precision_loss (the exemption isn't too broad). Plus U-4/U-5/U-soundness. + +## Round 4 — naming + cross-run observed-pair aggregation (user steering) + +**Naming (done above):** `pure__[_]`, prefix `pure_` is the exemption recognizer. No `g4uf_` jargon. + +**Cross-run observed-pair aggregation (the thing that gives the UF teeth).** A bare generic UF is fully *free* (only the relational `equal-inputs⇒equal-outputs` fact). Each concolic run, however, observes a concrete `(inputs → output)` pair for a `pure_` call — ground truth about the real function. Asserting `pure_String_trim(" hi ") == "hi"` only *tightens* the over-approximation (the constraint is true of the real function), so it's **sound** (UNSAT-under-tightened ⇒ real UNSAT ⇒ SAFE still holds) and strictly more precise. Accumulating these across all runs of a testcase turns each UF into a growing partial lookup-table of observed behavior — better input generation and tighter reasoning. + +**Where/how — reuses the existing UF-constraint plumbing (no new accumulation infra):** +- The bespoke UFs already ship their *defining constraints* via `symbolicTrace.getConstraints()` → `UFDTO` (`DTOBuilder.java:68-71`), and the explorer already accumulates UF definitions **per-testcase** in a `Set` (`Tree.ufs` + `Tree.record_ufs`, `Database.add_trace`). So a `pure_` UF emits its observed pair the same way: at the UF materialization, `addConstraint(fmgr.equal(callUF(decl, constant(concrete_in…)), constant(concrete_out)))` — a ground fact over *constant* inputs (NOT the symbolic input; that would be wrong). It travels via `UFDTO`, dedups into `Tree.ufs` across runs, and is injected when solving (verify the injection path `SolverHandler`/`ConstraintManager` reads `Tree.ufs`). +- Capture point: at `InvocationHandler` we have the concrete inputs (input `Value.concrete`) + the `decl`; at `GETVALUE` we have the concrete output (`inst.val`). So carry the constant-input UF application (or the concrete inputs + decl) on the placeholder and complete `== constant(out)` at materialization. +- Scope: per-testcase accumulation (matches "for a single testcase each UF gets more constraints"); the function is deterministic so pairs are valid, but per-testcase keeps it simple and bounded. + +**Sequencing (two commits within G4):** +- **G4 step 1 (core):** UF mechanism (free UF) + the `pure_` exemption + per-branch precision-loss trace + tiny whitelist + U-4/U-5/U-soundness + exemption controls. Proves the mechanism + soundness + SAFE-precision. The UF is free here. +- **G4 step 2 (aggregation):** emit observed `(in→out)` pairs as `pure_` UF constraints; verify per-testcase accumulation + solver injection; tests that the constraint appears and accumulates. Makes the UF informative. +- **Then:** the `java.lang` whitelist survey agents to scale the list. + +Each step runs the loop: implement → 3 independent reviews → commit. + +## Round 5 — step-1 post-implementation review fixes (two real blockers) + +The first 3-review pass found step 1, as first implemented, did NOT actually deliver SAFE precision, for two reasons (both fixed): + +- **Issue A — input detection was wrong for String/array inputs (also pre-existing).** The precision-loss "is this a real input?" test used only the regex `[A-Z].*_[0-9].*`. Real input names: primitives are `I_0` (match), but **String inputs are `java/lang/String_0`** and arrays `[I_0` (don't match). So (pre-existing) String/array-input branches always tripped precision_loss → SAFE always downgraded; and G4's exemption could never fire (the String input var itself failed the regex). **Fix:** detect inputs by exact **term identity** against the designated inputs (`symbolicTrace.getInputs() → value.formula`, collected into a `Set`; JavaSMT formulas have value-based equals/hashCode) — correct for all types, no name pattern. The regex is **kept as an additive backstop** for symbolic variables that are grounded but NOT designated inputs — specifically values re-materialized by GETVALUE heap recovery, which call `MAKE_SYMBOLIC` with a fresh `I_n`-style name without registering an input (verified at SymbolicInstructionVisitor:1301,1318). Pure-term-only would conservatively (soundly) downgrade those → regression; the backstop avoids it. Net: term-identity is primary (fixes String/array designated inputs + the exemption); regex is a backstop. (Dropping the backstop later is safe once recovery-created vars are handled.) +- **Issue B — context-loss independently downgraded SAFE on the UF path, making the exemption pointless.** A whitelisted pure unmodeled call recorded context-loss (→ SAFE→UNKNOWN) regardless of the exemption. **Fix (supersedes the Round-2 "keep context-loss" note, which was self-defeating):** when a whitelisted pure call is **successfully modeled as a UF**, do NOT record context-loss for it. Sound because the whitelist guarantees purity/side-effect-freedom (nothing changed but the return) and the return is captured by the UF — so no context is actually lost. Non-whitelisted unmodeled calls (e.g. `toLowerCase`) still flag context-loss exactly as before (F-2 + the L2 anchor confirm). Both fixes rest entirely on whitelist correctness (purity), the standing G4 soundness precondition. + +Tests added/updated: PrecisionLossExemptionSpec now uses realistic `java/lang/String_0` input terms + passes the input set (5 cases); PureFunctionUFSpec adds U-6 (a whitelisted pure call does NOT flag context-loss). All green: 42 heap+processor, 2 L2 (anchor still flags `toLowerCase`), 0 fail. Plus style nits (redundant import, em-dash, import order). Re-review (3 agents) then commit. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index d898961..771744d 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1277,9 +1277,18 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc // already flagged in InvocationHandler. Non-value results fall through to G1 recovery. if (placeHolder.origin == PlaceHolder.ValueOrigin.UNMODELED_RETURN && Util.isValueType(inst.val)) { - tmp = ValueFactory.createObjectValue(inst.val, inst.address); Logger shadowStateLogger = ThreadHandler.getShadowStateLogger(currentThread().getId()); - shadowStateLogger.info("Concretized unmodeled value-typed result (no identity recovery): {}", tmp); + if (placeHolder.recoveredFormula != null && inst.val instanceof String s) { + // G4: a whitelisted pure method - model the result as the carried generic UF + // over the inputs (concrete = observed). Preserves the relational fact + // (equal inputs => equal outputs) instead of concretizing. + SolverContext context = ThreadHandler.getSolverContext(currentThread().getId()); + tmp = new StringValue(context, s, (StringFormula) placeHolder.recoveredFormula, inst.address); + shadowStateLogger.info("Modeled unmodeled pure result as a generic UF: {}", tmp); + } else { + tmp = ValueFactory.createObjectValue(inst.val, inst.address); + shadowStateLogger.info("Concretized unmodeled value-typed result (no identity recovery): {}", tmp); + } stack.pushOperand(tmp); return; } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java new file mode 100644 index 0000000..0c57d73 --- /dev/null +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java @@ -0,0 +1,52 @@ +package de.uzl.its.swat.symbolic.UFs; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.sosy_lab.java_smt.api.Formula; +import org.sosy_lab.java_smt.api.FormulaManager; +import org.sosy_lab.java_smt.api.FormulaType; +import org.sosy_lab.java_smt.api.FunctionDeclaration; +import org.sosy_lab.java_smt.api.SolverContext; +import org.sosy_lab.java_smt.api.UFManager; + +/** + * Per-thread registry of the generic uninterpreted functions that model whitelisted pure JDK methods + * (G4). One UF symbol per signature (named via {@link PureMethods#ufName}), declared lazily and + * cached. The UF is axiom-free: applying it asserts only equal-inputs => equal-outputs + * (referential transparency), a sound over-approximation of any deterministic function. Observed + * concrete input->output pairs are added separately (as constraints) to tighten it across runs. + * + *

Contrast with the bespoke UFs ({@link ToLowerCaseUF} etc.), which ship hand axioms and live + * inside symbolic models; this registry ships none. + */ +public class PureFunctionUF { + private final UFManager ufmgr; + private final FormulaManager fmgr; + private final Map> declarations = new HashMap<>(); + + public PureFunctionUF(SolverContext ctx) { + fmgr = ctx.getFormulaManager(); + ufmgr = fmgr.getUFManager(); + } + + /** + * Build the UF application {@code ufName(args)} of the given return type. The argument sorts are + * derived from the actual {@code args} formulas (not the descriptor) so they match the values' + * sorts. The declaration is cached per name; on reuse the signature is asserted to match. + */ + public Formula apply(String ufName, FormulaType returnType, List args) { + List> argTypes = + args.stream().map(fmgr::getFormulaType).collect(Collectors.toList()); + FunctionDeclaration decl = declarations.get(ufName); + if (decl == null) { + decl = ufmgr.declareUF(ufName, returnType, argTypes); + declarations.put(ufName, decl); + } else { + assert decl.getType().equals(returnType) && decl.getArgumentTypes().equals(argTypes) + : "Generic UF signature mismatch for " + ufName; + } + return ufmgr.callUF(decl, args); + } +} diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java new file mode 100644 index 0000000..371b289 --- /dev/null +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java @@ -0,0 +1,56 @@ +package de.uzl.its.swat.symbolic.UFs; + +import java.util.Set; +import org.objectweb.asm.Type; + +/** + * Whitelist of pure, deterministic, side-effect-free JDK methods that SWAT does NOT model, plus the + * descriptive naming scheme for the generic uninterpreted functions that model their unmodeled + * returns (G4). A whitelisted method's result is modeled as {@code pure__[_] + * (inputs)} instead of being concretized (G2) - preserving the relational fact (equal inputs => + * equal outputs) soundly, since an axiom-free UF over-approximates any deterministic function. + * + *

Membership is the soundness precondition: only genuinely pure + deterministic methods may + * appear here. Exclude locale-dependent (no-arg {@code toLowerCase}/{@code toUpperCase}), + * environment/property readers, argument-mutating, identity/{@code intern}, and nondeterministic + * (random/time) methods. v1 starter set is tiny and hand-audited (String returns only); it is later + * scaled by a per-class survey of {@code java.lang}. + */ +public final class PureMethods { + private PureMethods() {} + + /** Keys are {@code owner + "/" + name + desc} (descriptor included to disambiguate overloads). */ + private static final Set WHITELIST = + Set.of( + "java/lang/String/trim()Ljava/lang/String;", + "java/lang/String/strip()Ljava/lang/String;"); + + public static boolean isWhitelisted(String owner, String name, String desc) { + return WHITELIST.contains(owner + "/" + name + desc); + } + + /** + * Descriptive, SMT-safe UF name {@code pure__[_]}, e.g. + * {@code pure_String_trim}, {@code pure_String_substring_int_int}. The {@code pure_} prefix is the + * precision-loss exemption's recognizer; arg types disambiguate overloads. + */ + public static String ufName(String owner, String name, String desc) { + StringBuilder sb = new StringBuilder("pure_"); + sb.append(simpleName(owner)).append('_').append(name); + for (Type t : Type.getArgumentTypes(desc)) { + sb.append('_').append(simpleTypeName(t)); + } + return sb.toString(); + } + + private static String simpleName(String internalOwner) { + int slash = internalOwner.lastIndexOf('/'); + return slash >= 0 ? internalOwner.substring(slash + 1) : internalOwner; + } + + private static String simpleTypeName(Type t) { + String cn = t.getClassName().replace("[]", "Array"); + int dot = cn.lastIndexOf('.'); + return dot >= 0 ? cn.substring(dot + 1) : cn; + } +} diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java index 639b275..5fce3aa 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java @@ -8,6 +8,7 @@ public class UFHandler { private EqualsIgnoreCaseUF equalsIgnoreCaseUF; private ToLowerCaseUF toLowerCaseUF; private SinCosUF sinCosUF; + private PureFunctionUF pureFunctionUF; public EqualsIgnoreCaseUF getEqualsIgnoreCaseUF() throws NoThreadContextException { if (equalsIgnoreCaseUF == null) { @@ -29,4 +30,12 @@ public SinCosUF getSinCosUF() throws NoThreadContextException { } return sinCosUF; } + + /** Registry of generic uninterpreted functions for whitelisted pure JDK methods (G4). */ + public PureFunctionUF getPureFunctionUF() throws NoThreadContextException { + if (pureFunctionUF == null) { + pureFunctionUF = new PureFunctionUF(ThreadHandler.getSolverContext(currentThread().getId())); + } + return pureFunctionUF; + } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index e8593e0..47efc16 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -1,11 +1,13 @@ package de.uzl.its.swat.symbolic.invoke; import ch.qos.logback.classic.Logger; +import de.uzl.its.swat.common.Util; import de.uzl.its.swat.common.exceptions.NoThreadContextException; import de.uzl.its.swat.common.exceptions.NotImplementedException; import de.uzl.its.swat.common.exceptions.ValueConversionException; import de.uzl.its.swat.common.logging.GlobalLogger; import de.uzl.its.swat.common.logging.records.InvocationEntry; +import de.uzl.its.swat.symbolic.UFs.PureMethods; import de.uzl.its.swat.symbolic.trace.SymbolicTraceHandler; import de.uzl.its.swat.symbolic.value.PlaceHolder; import de.uzl.its.swat.symbolic.value.Value; @@ -14,9 +16,12 @@ import de.uzl.its.swat.thread.ThreadHandler; import java.util.ArrayList; import java.util.Arrays; +import java.util.List; import java.util.stream.Collectors; import org.objectweb.asm.Type; +import org.sosy_lab.java_smt.api.Formula; +import org.sosy_lab.java_smt.api.FormulaType; public class InvocationHandler { private static final Logger logger = GlobalLogger.getSymbolicExecutionLogger(); @@ -72,6 +77,8 @@ public class InvocationHandler { symbolicTraceHandler); } + // G4: generic UF for a whitelisted pure return; stays null -> recovery concretizes (G2). + Formula pureUF = null; // When the method is not implemented and its not on the ignore list, we record it if (retValue instanceof PlaceHolder && !(IGNORED_INVOCATIONS.contains(owner + "/" + name) @@ -104,8 +111,20 @@ public class InvocationHandler { invokeId, containsSymbolicArgument)); + // G4: model a whitelisted pure, value-returning call as a generic UF over its inputs + // (instead of concretizing at recovery). Sound by construction: an axiom-free UF + // over-approximates any deterministic function. Only when an input is symbolic; + // `arguments` here already includes the receiver (prepended above). + if (containsSymbolicArgument && PureMethods.isWhitelisted(owner, name, desc)) { + pureUF = buildPureUF(owner, name, desc, arguments); + } + if( - (retValue.equals(PlaceHolder.instance) // To detect a missing implementation + // G4: a successfully UF-modeled pure call loses no context - the whitelist + // guarantees no side effects and the return is captured by the UF - so it must NOT + // downgrade SAFE; only flag context loss when we did not model the call. + pureUF == null + && (retValue.equals(PlaceHolder.instance) // To detect a missing implementation || retValue instanceof VoidValue vv && !vv.isSymbolic()) // To detect a missing implementation that returns nothing && containsSymbolicArgument) { // Too strict? What about void methods that always have return value PlaceHolder.instance? @@ -115,17 +134,42 @@ public class InvocationHandler { desc); symbolicTraceHandler.recordSymbolicContextLoss(); } - } - // G2: tag an unmodeled placeholder return so visitGETVALUE_Object can concretize a value-typed + // G2/G4: tag an unmodeled placeholder return so visitGETVALUE_Object recovers a value-typed // result instead of identity-recovering it (which would re-bind the receiver's symbolic value, - // e.g. String.toLowerCase() returning `this`). This MUST stay after the context-loss check - // above, which compares retValue against PlaceHolder.instance by identity. + // e.g. String.toLowerCase() returning `this`). If a generic UF was built (G4), it rides along + // and the result is modeled as that UF; otherwise recovery concretizes (G2). This MUST stay + // after the context-loss check above, which compares retValue against PlaceHolder.instance by + // identity. if (retValue == PlaceHolder.instance) { - retValue = new PlaceHolder(PlaceHolder.ValueOrigin.UNMODELED_RETURN, null, null); + retValue = new PlaceHolder(PlaceHolder.ValueOrigin.UNMODELED_RETURN, pureUF); } return retValue; } + /** + * Build the generic UF {@code pure_(inputs)} for a whitelisted pure call, or null to fall + * back to G2 concretization. v1 handles String returns only; the inputs (receiver + args) must + * all be value-typed so their formula fully captures the input (sound; no stateful receivers). + */ + private static Formula buildPureUF( + String owner, String name, String desc, List> inputs) + throws NoThreadContextException { + // v1 scope: only String-returning methods are materialized as UFs. + if (!"java.lang.String".equals(Type.getReturnType(desc).getClassName())) { + return null; + } + List argFormulas = new ArrayList<>(); + for (Value v : inputs) { + if (v.formula == null || !Util.isValueType(v.concrete)) { + return null; // non-value-typed or formula-less input: defer to G2 concretize. + } + argFormulas.add((Formula) v.formula); + } + return ThreadHandler.getUFHandler(Thread.currentThread().getId()) + .getPureFunctionUF() + .apply(PureMethods.ufName(owner, name, desc), FormulaType.StringType, argFormulas); + } + } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java index 8fa442e..c09d3be 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java @@ -17,10 +17,16 @@ import java.util.ArrayList; import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; import org.sosy_lab.java_smt.api.BooleanFormula; import org.sosy_lab.java_smt.api.Formula; import org.sosy_lab.java_smt.api.FormulaManager; +import org.sosy_lab.java_smt.api.FunctionDeclaration; +import org.sosy_lab.java_smt.api.FunctionDeclarationKind; +import org.sosy_lab.java_smt.api.visitors.DefaultFormulaVisitor; +import org.sosy_lab.java_smt.api.visitors.TraversalProcess; /** * Builds a data transfer object (DTO) from the {@link SymbolicTrace SuymbolicState} for @@ -50,10 +56,14 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre ArrayList inputs = new ArrayList<>(); ArrayList ufs = new ArrayList<>(); ArrayList trace = new ArrayList<>(); + // G4: the terms of the designated symbolic inputs, used to classify precision loss - a branch + // variable is "grounded" iff its term is one of these (exact + type-aware, no name pattern). + Set inputTerms = new HashSet<>(); FormulaManager fmgr = ThreadHandler.getSolverContext(currentThread().getId()).getFormulaManager(); for (InputElement ie : symbolicTrace.getInputs()) { + inputTerms.add((Formula) ie.getValue().formula); String lowerBound = String.valueOf(fmgr.dumpFormula(ie.getValue().getBounds(false))); String upperBound = String.valueOf(fmgr.dumpFormula(ie.getValue().getBounds(true))); InputDTO iDto = @@ -74,34 +84,26 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre for (Element el : symbolicTrace.getTrace()) { if (el instanceof BranchElement be) { String constraint; + boolean branchPrecisionLoss = false; try { BooleanFormula f = fmgr.simplify(be.getConstraint()); - // Todo or go back to extracting variables and UFs? if(fmgr.getBooleanFormulaManager().isTrue(f) || fmgr.getBooleanFormulaManager().isFalse(f)){ constraint = "(assert true)"; } else { constraint = String.valueOf(fmgr.dumpFormula(f)); - if (!fmgr.extractVariablesAndUFs(f).isEmpty() && - !fmgr.extractVariablesAndUFs(f).keySet().stream().allMatch(s -> s.matches("[A-Z].*_[0-9].*"))) { - symbolicPrecisionLoss = true; - } + branchPrecisionLoss = isPrecisionLoss(f, fmgr, inputTerms); } - //fmgr.extractVariablesAndUFs(f).keySet().forEach(System.out::println); - // assert fmgr.extractVariablesAndUFs(f).keySet().stream() - // .allMatch(s -> s.matches("[A-Z].*_[0-9].*")): "[SWAT] UF introduced in: " + constraint; - } catch (InterruptedException e) { BooleanFormula f = be.getConstraint(); logger.warn("Error while simplifying formula", e); constraint = String.valueOf(fmgr.dumpFormula(f)); - //fmgr.extractVariablesAndUFs(f).keySet().forEach(System.out::println); - // assert fmgr.extractVariablesAndUFs(f).keySet().stream() - // .allMatch(s -> s.matches("[A-Z].*_[0-9].*")): "[SWAT] UF introduced in: " + constraint; - if (!fmgr.extractVariablesAndUFs(f).keySet().stream().allMatch(s -> s.matches("[A-Z].*_[0-9].*"))) { - symbolicPrecisionLoss = true; - } + branchPrecisionLoss = isPrecisionLoss(f, fmgr, inputTerms); } - trace.add(new BranchDTO(be.getIid(), constraint, be.isBranched())); + // Executor-side decision (unchanged): aggregate = OR of the per-branch flags. The + // per-branch flag also travels on the BranchDTO so a future explorer-side, + // CFG-reachability-aware precision-loss decision can take over with no trace change (G4). + symbolicPrecisionLoss |= branchPrecisionLoss; + trace.add(new BranchDTO(be.getIid(), constraint, be.isBranched(), branchPrecisionLoss)); } else if (el instanceof SpecialElement se) { trace.add(new BranchDTO(se.getIid(), se.getInst())); } @@ -109,6 +111,67 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre return new TraceDTO(inputs, trace, ufs, symbolicTrace.isSymbolicContextLoss(), symbolicPrecisionLoss, symbolicTrace.isReferenceSemanticChange()); } + /** + * A symbolic-variable name produced by the input-naming convention (primitive prefixes like + * {@code I_0}). Kept as a backstop for symbolic variables that are NOT designated inputs but are + * still grounded - notably values re-materialized by GETVALUE heap recovery (which call + * MAKE_SYMBOLIC with a fresh name, without re-registering an input). Dropping it would + * conservatively (but soundly) downgrade such cases; keeping it avoids that regression. + */ + private static final String INPUT_VAR_PATTERN = "[A-Z].*_[0-9].*"; + + /** + * Whether a branch constraint loses symbolic precision, given the terms of the designated symbolic + * inputs. It does iff it contains a symbol that is neither (a) a grounded variable - a designated + * input (its term is in {@code inputTerms}) or a recovery-named variable matching + * {@link #INPUT_VAR_PATTERN} - nor (b) a whitelisted generic pure UF (name starts with + * {@code pure_}). Case (b) is precision-preserving: an axiom-free UF over real inputs is a sound + * over-approximation of any deterministic function, so UNSAT under the free UF implies UNSAT for + * the real function (SAFE stays sound). Bespoke (axiomatized) UFs and any non-grounded variable + * do lose precision. + * + *

The primary input check is exact term identity against the designated inputs (JavaSMT + * formulas have value-based equals/hashCode), so it is correct for String/array inputs as well as + * primitives - which a name pattern alone is not. Uses a {@link DefaultFormulaVisitor} so UF + * symbols and variables are distinguished; it descends into UF arguments so a {@code pure_} UF + * applied to a non-input variable still loses precision (the nested variable is caught). + */ + public static boolean isPrecisionLoss( + BooleanFormula f, FormulaManager fmgr, Set inputTerms) { + AtomicBoolean lossy = new AtomicBoolean(false); + fmgr.visitRecursively( + f, + new DefaultFormulaVisitor() { + @Override + protected TraversalProcess visitDefault(Formula formula) { + return TraversalProcess.CONTINUE; + } + + @Override + public TraversalProcess visitFreeVariable(Formula formula, String name) { + if (!inputTerms.contains(formula) && !name.matches(INPUT_VAR_PATTERN)) { + lossy.set(true); + return TraversalProcess.ABORT; + } + return TraversalProcess.CONTINUE; + } + + @Override + public TraversalProcess visitFunction( + Formula formula, List args, FunctionDeclaration decl) { + // Descend into args regardless so nested non-input variables are caught; only a + // bespoke (non-pure_) UF taints the branch by itself. + if (decl.getKind() == FunctionDeclarationKind.UF + && !decl.getName().startsWith("pure_")) { + lossy.set(true); + return TraversalProcess.ABORT; + } + return TraversalProcess.CONTINUE; + } + }); + return lossy.get(); + } + protected static String encodeCoverage(InstrCoverage instrCoverage) throws JsonProcessingException { return buildRequestBody(buildInstrCoverageDTO(instrCoverage)); } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java index e26780c..86b5541 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java @@ -16,10 +16,19 @@ public class BranchDTO { @SuppressWarnings("unused") private String inst; - public BranchDTO(long iid, String constraint, boolean branched) { + /** + * G4: the executor's per-branch precision-loss verdict. Carried so a future explorer-side, + * CFG-reachability-aware decision can key it by {@link #iid} to a CFG node. The current verdict + * uses the aggregate {@code symbolicPrecisionLoss} on the TraceDTO (OR of these). + */ + @SuppressWarnings("unused") + private boolean precisionLoss; + + public BranchDTO(long iid, String constraint, boolean branched, boolean precisionLoss) { this.iid = iid; this.constraint = constraint; this.branched = branched; + this.precisionLoss = precisionLoss; this.type = "Branch"; } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index 53a8092..ae11c2d 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -8,6 +8,7 @@ import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.LongValue; import de.uzl.its.swat.symbolic.value.reference.ObjectValue; import java.util.Map; +import org.sosy_lab.java_smt.api.Formula; /** Author: Koushik Sen (ksen@cs.berkeley.edu) Date: 6/17/12 Time: 6:05 PM */ public class PlaceHolder extends Value { @@ -44,6 +45,11 @@ public enum ValueOrigin { public final ValueOrigin origin; public final Instruction inst; public final ObjectValue referenceValue; + /** + * For an UNMODELED_RETURN placeholder of a whitelisted pure method (G4): the generic UF formula + * {@code pure_(inputs)} modeling the result. Null otherwise (then recovery concretizes, G2). + */ + public final Formula recoveredFormula; public static final PlaceHolder instance = new PlaceHolder(false); public static final PlaceHolder symbolicInstance = new PlaceHolder(true); @@ -52,6 +58,7 @@ public PlaceHolder(boolean isSymbolic) { this.origin = ValueOrigin.UNSPECIFIED; this.inst = null; this.referenceValue = null; + this.recoveredFormula = null; } public PlaceHolder(ValueOrigin origin, Instruction inst, ObjectValue referenceValue) { @@ -59,6 +66,7 @@ public PlaceHolder(ValueOrigin origin, Instruction inst, ObjectValue refer this.isSymbolic = false; this.inst = inst; this.referenceValue = referenceValue; + this.recoveredFormula = null; } public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { @@ -66,6 +74,16 @@ public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { this.origin = origin; this.inst = null; this.referenceValue = null; + this.recoveredFormula = null; + } + + /** UNMODELED_RETURN placeholder carrying a generic-UF formula for a whitelisted pure method (G4). */ + public PlaceHolder(ValueOrigin origin, Formula recoveredFormula) { + this.origin = origin; + this.isSymbolic = false; + this.inst = null; + this.referenceValue = null; + this.recoveredFormula = recoveredFormula; } public ObjectValue asObjectValue() throws ValueConversionException { diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy new file mode 100644 index 0000000..dd264ee --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy @@ -0,0 +1,71 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.symbolic.trace.DTOBuilder +import org.sosy_lab.java_smt.api.BooleanFormula +import org.sosy_lab.java_smt.api.Formula +import org.sosy_lab.java_smt.api.FormulaType +import org.sosy_lab.java_smt.api.StringFormula +import spock.lang.See + +/** + * G4 exemption controls (Level L0): {@link DTOBuilder#isPrecisionLoss} must treat a whitelisted + * generic {@code pure_} UF over real inputs as precision-PRESERVING (sound: an axiom-free UF over + * inputs over-approximates any deterministic function, so UNSAT-under-free-UF => real UNSAT => + * SAFE holds), while a non-input variable or a bespoke (non-{@code pure_}) UF still loses precision. + * Input-ness is decided by exact term identity against the designated inputs (NOT a name pattern), so + * it works for String inputs (named {@code java/lang/String_0}, which the old regex wrongly rejected). + * See docs/heap-redesign-g4-design.md. + */ +class PrecisionLossExemptionSpec extends BaseValueSpec { + + // A realistic String input variable name - lowercase + slashes, which the old [A-Z].*_[0-9].* regex rejected. + private StringFormula inputVar() { fmgr.getStringFormulaManager().makeVariable("java/lang/String_0") } + private StringFormula otherVar() { fmgr.getStringFormulaManager().makeVariable("intermediate") } + + private Set inputs(StringFormula... terms) { return terms.toList() as Set } + + private StringFormula ufOver(String ufName, StringFormula arg) { + def decl = fmgr.getUFManager().declareUF(ufName, FormulaType.StringType, FormulaType.StringType) + return (StringFormula) fmgr.getUFManager().callUF(decl, arg) + } + + private BooleanFormula eqAbc(StringFormula s) { + return fmgr.getStringFormulaManager().equal(s, fmgr.getStringFormulaManager().makeString("abc")) + } + + @See("docs/heap-redesign-g4-design.md") + def "a designated input variable alone is not precision loss (incl. a String input the regex rejected)"() { + given: + def v = inputVar() + expect: + !DTOBuilder.isPrecisionLoss(eqAbc(v), fmgr, inputs(v)) + } + + @See("docs/heap-redesign-g4-design.md") + def "a non-input variable is precision loss"() { + expect: + DTOBuilder.isPrecisionLoss(eqAbc(otherVar()), fmgr, inputs(inputVar())) + } + + @See("docs/heap-redesign-g4-design.md") + def "a pure_ UF over an input variable is NOT precision loss (the exemption)"() { + given: + def v = inputVar() + expect: + !DTOBuilder.isPrecisionLoss(eqAbc(ufOver("pure_String_trim", v)), fmgr, inputs(v)) + } + + @See("docs/heap-redesign-g4-design.md") + def "a pure_ UF over a NON-input variable is still precision loss (transitivity)"() { + expect: + DTOBuilder.isPrecisionLoss(eqAbc(ufOver("pure_String_trim", otherVar())), fmgr, inputs(inputVar())) + } + + @See("docs/heap-redesign-g4-design.md") + def "a bespoke (non-pure_) UF stays non-exempt (precision loss)"() { + given: + def v = inputVar() + expect: + DTOBuilder.isPrecisionLoss(eqAbc(ufOver("toLowerCase", v)), fmgr, inputs(v)) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy new file mode 100644 index 0000000..f1faf78 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy @@ -0,0 +1,112 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import org.sosy_lab.java_smt.api.BooleanFormula +import org.sosy_lab.java_smt.api.Formula +import org.sosy_lab.java_smt.api.StringFormula +import spock.lang.See + +/** + * G4 generic-UF mechanism (Level L1). A whitelisted, pure, UNMODELED value-returning call + * ({@code String.trim}) on a symbolic input is modeled as the generic UF {@code pure_String_trim} + * over that input, instead of being concretized (G2). This preserves the relational fact (equal + * inputs => equal outputs) while staying sound (the UF is axiom-free). See + * docs/heap-redesign-g4-design.md and docs/heap-redesign-tests.md. + */ +class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { + + private static final String STRING = "java/lang/String" + private static final String TRIM = "()Ljava/lang/String;" + + private Set symbolsOf(value) { + return solverContext.getFormulaManager().extractVariablesAndUFs(value.formula as Formula).keySet() + } + + private Set varsOf(value) { + return solverContext.getFormulaManager().extractVariables(value.formula as Formula).keySet() + } + + private boolean isValid(BooleanFormula f) { + def p = solverContext.newProverEnvironment() + try { + p.addConstraint(solverContext.getFormulaManager().getBooleanFormulaManager().not(f)) + return p.isUnsat() + } finally { p.close() } + } + + private boolean isSat(BooleanFormula f) { + def p = solverContext.newProverEnvironment() + try { + p.addConstraint(f) + return !p.isUnsat() + } finally { p.close() } + } + + @See("docs/heap-redesign-tests.md") + def "U-5: trim (whitelisted, unmodeled) models its result as a UF over the input"() { + given: "a symbolic String receiver" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) + receiver.MAKE_SYMBOLIC() + def receiverVars = varsOf(receiver) + assert receiverVars.size() == 1 + + when: "trim() is invoked (unmodeled, whitelisted) and recovered" + def result = executeBoundaryRecovery(receiver, STRING, "trim", TRIM, "abc") + + then: "modeled as the generic UF over the input - depends on it, not concretized, not aliased" + symbolsOf(result.recovered).contains("pure_String_trim") + symbolsOf(result.recovered).containsAll(receiverVars) + result.recovered.concrete == "abc" + } + + @See("docs/heap-redesign-tests.md") + def "U-4: equal inputs yield equal results (UF congruence / determinism)"() { + given: "two independently symbolic String receivers" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue s1 = new StringValue(solverContext, "abc", 0x1000); s1.MAKE_SYMBOLIC() + StringValue s2 = new StringValue(solverContext, "abc", 0x2000); s2.MAKE_SYMBOLIC() + + when: "trim() is recovered for each" + def r1 = executeBoundaryRecovery(s1, STRING, "trim", TRIM, "abc") + def r2 = executeBoundaryRecovery(s2, STRING, "trim", TRIM, "abc") + + then: "(s1 == s2) implies (trim(s1) == trim(s2)) is valid" + def smgr = solverContext.getFormulaManager().getStringFormulaManager() + def bmgr = solverContext.getFormulaManager().getBooleanFormulaManager() + BooleanFormula premise = smgr.equal(s1.formula as StringFormula, s2.formula as StringFormula) + BooleanFormula conclusion = smgr.equal(r1.recovered.formula as StringFormula, r2.recovered.formula as StringFormula) + isValid(bmgr.implication(premise, conclusion)) + } + + @See("docs/heap-redesign-tests.md") + def "U-soundness: the axiom-free UF excludes no real behavior (result can equal any value)"() { + given: + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) + receiver.MAKE_SYMBOLIC() + + when: + def result = executeBoundaryRecovery(receiver, STRING, "trim", TRIM, "abc") + + then: "no hidden axiom forces the result - it may take an arbitrary value (SAT)" + def smgr = solverContext.getFormulaManager().getStringFormulaManager() + isSat(smgr.equal(result.recovered.formula as StringFormula, smgr.makeString("a totally different value"))) + } + + @See("docs/heap-redesign-tests.md") + def "U-6: a whitelisted pure call is modeled (UF), so it does NOT flag context loss"() { + given: "a symbolic String receiver" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) + receiver.MAKE_SYMBOLIC() + + when: "trim() (whitelisted, pure) is invoked and modeled as a UF" + def result = executeBoundaryRecovery(receiver, STRING, "trim", TRIM, "abc") + + then: "context loss is NOT flagged (it is the only remaining SAFE downgrade, and we modeled it)" + !result.contextLoss + } +} diff --git a/symbolic-explorer/parse/DataTransferObjects.py b/symbolic-explorer/parse/DataTransferObjects.py index be30d85..11f80e8 100644 --- a/symbolic-explorer/parse/DataTransferObjects.py +++ b/symbolic-explorer/parse/DataTransferObjects.py @@ -10,6 +10,10 @@ class TraceItem(BaseModel): branched: bool type: str inst: Optional[str] = None + # G4: executor's per-branch precision-loss verdict. Parsed for forward compatibility; the current + # verdict still uses the aggregate symbolicPrecisionLoss. A future explorer-side, CFG-reachability + # -aware decision keys this by iid to a CFG node. Default keeps older traces parseable. + precisionLoss: bool = False class UFItem(BaseModel): definition: str From 252ec5d071ce1bf0847b170fc05f19e48b28edf1 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Fri, 26 Jun 2026 11:57:22 +0000 Subject: [PATCH 08/33] test(heap): G4 L2 anchor - SAFE preserved end-to-end through a whitelisted pure trim Closes the noted L2 gap for G4 step 1. The real agent runs a @Symbolic program that calls the whitelisted pure unmodeled String.trim() and branches on its result (TrimTarget). The emitted trace shows the relationship preserved (the branch references the input through pure_String_trim) and NEITHER soundness flag set (context-loss skipped because modeled; precision-loss exempt for the pure_ UF over a real input) - i.e. SAFE is genuinely preserved through the call, verified with real instrumentation, contrasting with the non-whitelisted toLowerCase anchor that still flags context loss. Co-Authored-By: Claude Opus 4.8 --- .../heap/PureFunctionUFAgentSpec.groovy | 37 +++++++++++++++++++ .../test/resources/targets/TrimTarget.java | 26 +++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/TrimTarget.java diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy new file mode 100644 index 0000000..1b71fbf --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy @@ -0,0 +1,37 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G4 end-to-end anchor at Level L2: the REAL agent runs a {@code @Symbolic} program that calls a + * whitelisted pure unmodeled method ({@code String.trim()}) and branches on its result + * (TrimTarget). The emitted TraceDTO must show the relationship preserved (the branch references the + * input through the {@code pure_String_trim} UF) AND neither soundness flag set - i.e. SAFE is + * genuinely preserved through the call, the headline G4-full claim, verified with real instrumentation + * rather than a fabricated instruction stream. Contrast with HeapRecoveryV1AgentSpec, where the + * non-whitelisted {@code toLowerCase} still flags context loss. + * + * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + */ +class PureFunctionUFAgentSpec extends Specification { + + @See("docs/heap-redesign-g4-design.md") + def "G4 (L2): a whitelisted pure trim into a branch preserves SAFE (no context/precision loss)"() { + when: + TraceObservation obs = AgentRun.run("targets/TrimTarget.java", "TrimTarget") + String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } + + then: "the symbolic input is designated" + inputVar != null + + and: "the relationship is preserved - the branch references the input through the UF (unlike G2)" + obs.anyBranchReferences(inputVar) + + and: "neither SAFE downgrade fires: context loss skipped (modeled) and the pure_ UF is exempt" + !obs.symbolicContextLoss + !obs.symbolicPrecisionLoss + } +} diff --git a/symbolic-executor/src/test/resources/targets/TrimTarget.java b/symbolic-executor/src/test/resources/targets/TrimTarget.java new file mode 100644 index 0000000..83f400e --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/TrimTarget.java @@ -0,0 +1,26 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 target for the G4 end-to-end anchor: a symbolic String is passed to an unmodeled but + * WHITELISTED pure method ({@code String.trim()}), whose result then drives a branch. Run under the + * real SWAT agent, this exercises the generic-UF path ({@code pure_String_trim}) end to end: the + * branch constraint should reference the input through the UF, and NEITHER soundness flag + * (context-loss, precision-loss) should fire - so SAFE would be preserved through the call. + * + * Mirrors {@code ToLowerCaseTarget} (the non-whitelisted contrast, which still flags context loss). + * {@code @Symbolic} is on the parameter. See docs/test-architecture.md (Level L2). + */ +public class TrimTarget { + + public static void main(String[] args) { + test("abc"); + } + + public static String test(@Symbolic String s) { + String r = s.trim(); // whitelisted pure + unmodeled -> modeled as pure_String_trim(s) + if (r.equals("abc")) { // the UF result reaches a branch + return "eq"; + } + return "ne"; + } +} From 6d399043ae45bcf65196bee47e690cb2648d419a Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Fri, 26 Jun 2026 12:24:46 +0000 Subject: [PATCH 09/33] feat(heap): G4 step 2 (executor) - emit observed UF (input->output) pairs; explorer side documented Step 2 gives the generic UF teeth by accumulating observed ground facts across a testcase's runs. Per the user decision, the EXECUTOR side is implemented now and the explorer side (which touches the solve path + verdict logic, overlapping the upcoming explorer rework) is documented for that rework. Executor (this commit): for a whitelisted pure call, in addition to the result UF pure_(symbolic inputs), build the same (cached) UF over the CONSTANT observed inputs and carry it on the placeholder (PlaceHolder.observedApplication); at GETVALUE emit `addConstraint(pure_(const inputs) == observed output)` - a ground fact that only tightens the axiom-free UF (sound; one pair per run cannot self-contradict). It rides the existing UF channel (SymbolicTrace.getConstraints -> UFDTO -> Tree.ufs). v1: String inputs/returns. Explorer (documented, deferred): docs/heap-redesign-g4-step2-explorer-handoff.md specifies the 4 remaining items found by the 2-round design review - (1) inject the accumulated Tree.ufs at solve time (it is currently dead; Node.ufs is frozen per-node, so cross-run accumulation does not reach the solver), threaded through select_branch; (2) a structural contradiction guard in record_ufs; (3) a solve-time UNSAT->UNKNOWN backstop (verdict soundness); (4) an L3 test. Items 1-3 MUST land together (cross-run injection is what enables a contradictory set -> false SAFE), and raise the whitelist's purity bar to a soundness requirement. Tests: PureFunctionUFSpec U-7 (observed pair emitted + ground); 43 heap+processor + 3 L2 green (incl. the trim SAFE-preservation anchor). Design + review: heap-redesign-g4-design.md. Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g4-design.md | 46 ++++++++ ...heap-redesign-g4-step2-explorer-handoff.md | 103 ++++++++++++++++++ .../symbolic/SymbolicInstructionVisitor.java | 10 ++ .../symbolic/invoke/InvocationHandler.java | 46 ++++++-- .../its/swat/symbolic/value/PlaceHolder.java | 20 +++- .../symbolic/heap/PureFunctionUFSpec.groovy | 21 ++++ 6 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 docs/heap-redesign-g4-step2-explorer-handoff.md diff --git a/docs/heap-redesign-g4-design.md b/docs/heap-redesign-g4-design.md index 465e8b8..7958091 100644 --- a/docs/heap-redesign-g4-design.md +++ b/docs/heap-redesign-g4-design.md @@ -108,6 +108,52 @@ Decision: **G4-full** — the generic UF *and* a `precision_loss` exemption so S Each step runs the loop: implement → 3 independent reviews → commit. +## Step 2 — implementation design (for review) + +**Status: G4 step 1 committed (48763bc) + L2 anchor (252ec5d). Step 2 EXECUTOR side implemented + committed; explorer side documented for the upcoming rework in `docs/heap-redesign-g4-step2-explorer-handoff.md` (user decision: prepare the executor, document the explorer work, move on to the whitelist survey).** + +Goal: give the free generic UF teeth by accumulating observed `(input -> output)` ground facts across a testcase's runs. A run that calls `String.trim()` on concrete `"abc "` observing `"abc"` is ground truth: `pure_String_trim("abc ") == "abc"`. Asserting it only *tightens* the axiom-free UF (a true fact about the real function), so it stays sound (UNSAT-under-tightened => real UNSAT => SAFE holds; concrete-grounded => no false VIOLATION) while making the UF informative for input generation + reasoning. + +**Emission (executor).** The concrete inputs are known at `InvocationHandler` but the concrete output only arrives at `GETVALUE`. So: +- At `InvocationHandler.buildPureUF`, in addition to the result UF `pure_sig(symbolic inputs)`, build a second application over the **constant** inputs `pure_sig(makeString(input.concrete), ...)` using the SAME cached declaration (so it's the same UF symbol). Carry it on the `UNMODELED_RETURN` placeholder (new field, e.g. `observedApplication`). +- At the `GETVALUE` materialization, emit `symbolicTraceHandler.addConstraint(equal(observedApplication, makeConstant(inst.val)))`. + +**Accumulation (explorer) — reuses existing plumbing.** `addConstraint`-ed constraints travel via `symbolicTrace.getConstraints() -> UFDTO` (DTOBuilder:68-71), and the explorer accumulates UF definitions per-testcase in a `Set` (`Tree.ufs` + `Tree.record_ufs`, dedup by definition string). So each run's observed pair accrues across runs for that testcase, with no new accumulation infra. + +v1 scope: String returns + String inputs only (constant building = `makeString`); other arg/return types arrive with the whitelist survey. + +### Round-1 review (BLOCKER) — the accumulation path doesn't exist; step 2 must build it + +The reviewer verified that the "rides existing accumulation, no new infra" premise is **false**: +- **`Tree.ufs` is dead code.** It is written by `Tree.record_ufs` (Tree.py:60) and **read nowhere** — it is never injected into the solver. +- **The live injection uses `Node.ufs`, which is frozen per-node.** `StrategyService.collect_uf_definitions` (StrategyService.py:78-82) injects `node.ufs` into `path_constraints` → `Z3Handler.solve` (SolverHandler.py:407-409). But `Node.ufs` is set only in the `Node` ctor (Node.py:51); `Tree.add_recursive` never merges incoming ufs into an EXISTING node. So a run's observed pair is injected only onto branches THAT run first created — flipping a pre-existing branch sees only the pairs frozen at its creation, NOT the accumulated table. The cross-run accumulation (step 2's whole point) does not happen. + +**Revised design (the rework):** step 2 must ADD explorer-side injection of the accumulated per-testcase set. Chosen fix: in `StrategyService.collect_uf_definitions`/`solve_branch`, also union the live tree's accumulated `Tree.ufs` into `path_constraints` (this finally gives `Tree.ufs` a consumer; `get_tree` returns a deepcopy, so read the set that reflects all `add_trace` calls before this solve). The executor emission half (carry const-application, emit `addConstraint(equal(...))` at GETVALUE) is unchanged and was confirmed sound. + +**C1 soundness wrinkle (must handle):** at solve time, if the injected UF-constraint set is UNSAT, `SVCompDriver` treats "no SAT branch" as **SAFE** (SVCompDriver.py:271-273) → a contradictory set yields a **false SAFE** for the whole testcase. All-true (deterministic) observed pairs can never be contradictory, so the whitelist's determinism bar prevents it — but step 2 elevates a bad whitelist entry from "imprecision" to "potential false SAFE." Mitigation: a contradiction guard at the dedup/accumulation point (same UF+inputs, different output ⇒ drop/skip, don't poison) + document the elevated stakes. (Confirmed clean by review: #2 getConstraints→UFDTO, #3 same cached decl for symbolic+constant applications, #4 ground-fact-only-tightens, #6 carry one const-application Formula on PlaceHolder, #7 emit at GETVALUE / inst.val faithful.) + +**Tests:** executor-side emission at L1 (assert the pair is in `getConstraints()`); plus an explorer/L3 test that an accumulated pair from an EARLIER run actually constrains a LATER flip — the current L2 anchor would pass even if injection were inert, so it does not cover this. + +### Round-2 review — design converged; step 2 is materially bigger than "ride existing plumbing" + +Round-2 validated the revised injection approach (no staleness: the `select_branch` deepcopy reflects all prior `record_ufs`; format/idempotence clean; `collect_uf_definitions` is the universal chokepoint) but surfaced an implementation hole + sharpened the guard. Net: step 2 now spans the executor AND the explorer solve path AND verdict soundness. Four must-fixes: +1. **Injection wiring.** `collect_uf_definitions(node)` cannot reach `Tree.ufs` (Node has no tree ref; `endpoint_id` is None for 4/5 drivers). Thread the accumulated set through `select_branch` → `solve_branch` → `collect_uf_definitions` (select_branch already holds the post-`add_trace` deepcopy). NOT via `get_tree(endpoint_id)`. (StrategyService.py:16-23,78-96) +2. **C1 contradiction guard (structural, not string-Set identity).** In `Tree.record_ufs`, parse each `pure_*` observed pair, key on `(uf-name, args) → rhs`, drop on conflicting rhs. New code in the accumulation path; narrow to `pure_` names. (Tree.py:52-60) +3. **C1 solve-time backstop (verdict soundness).** Before any UNSAT→SAFE conclusion (SVCompDriver.py:271-273), if the accumulated `pure_` facts ALONE are UNSAT, downgrade SAFE→UNKNOWN. Converts a worst-case false SAFE (bad whitelist entry) into a conservative UNKNOWN. ~3 lines, gated on `pure_` ufs present. +4. **L3 explorer test.** Drive the explorer directly (`Database.add_trace` + `select_branch` + `solve_branch`), assert an accumulated pair from an EARLIER run flips a LATER solve SAT→UNSAT (reset Database + `clear_constraint_cache` between cases). The L2 anchor would pass even if injection were inert. + +Confirmed clean: executor emission half (carry const-application, emit at GETVALUE, same cached decl); format match; idempotent double-add. Nit: the per-node `node.ufs` walk becomes redundant once the union lands. + +**Scope observation (for the user):** must-fixes 1 + 3 are explorer **solve-path / verdict-logic** changes — they overlap the explorer rework (static CFG + explorer-side decisions) you said is coming soon, and #3 changes the SAFE/UNKNOWN logic. Step 1 already delivers G4's core (SAFE preserved through pure calls); cross-run accumulation is a precision/input-generation enhancement on top, not core soundness. So there's a real choice about whether to build the explorer machinery now or coordinate it with that rework. + +Original open questions (now answered): +1. **Injection (critical) — ANSWERED: does NOT exist for accumulated facts; must be built (must-fix 1 above).** +2. **Carry design.** Is carrying the pre-built const-application (one `Formula`) cleaner/sounder than carrying the concrete inputs + decl and building at GETVALUE? Any hazard adding a second `Formula` field to `PlaceHolder`? +3. **Same-symbol sharing.** The result UF (over symbolic inputs) and the observed-pair UF (over constants) must be the SAME declaration/symbol (else the observed facts don't constrain the symbolic result). Confirm `PureFunctionUF.apply` returns the cached decl for both calls. +4. **Soundness.** Re-confirm asserting `pure_sig(const_in) == const_out` only tightens (never excludes a real behavior), and that a wrong/duplicate emission can't cause a false verdict. Dedup correctness across runs (definition-string identity). +5. **Determinism caveat.** If a whitelisted method were non-deterministic across runs, two runs could assert `f(x)==a` and `f(x)==b` with a!=b -> UNSAT (poisoning the whole testcase). The whitelist's determinism bar prevents this; flag that step 2 raises the stakes on it (a non-deterministic entry now corrupts solving, not just precision). +6. **Testing.** Executor-side: assert the observed-pair constraint is emitted (in `symbolicTrace.getConstraints()`); the cross-run accumulation itself is explorer/L3. Right altitude? + ## Round 5 — step-1 post-implementation review fixes (two real blockers) The first 3-review pass found step 1, as first implemented, did NOT actually deliver SAFE precision, for two reasons (both fixed): diff --git a/docs/heap-redesign-g4-step2-explorer-handoff.md b/docs/heap-redesign-g4-step2-explorer-handoff.md new file mode 100644 index 0000000..40e4bfc --- /dev/null +++ b/docs/heap-redesign-g4-step2-explorer-handoff.md @@ -0,0 +1,103 @@ +# G4 step 2 — explorer hand-off: cross-run UF observed-pair accumulation + +**Status:** the EXECUTOR side is implemented + committed. This document specifies the remaining +**explorer** work, deliberately deferred to coordinate with the upcoming explorer rework (static CFG ++ explorer-side decisions), since it touches the solve path and verdict logic. + +## What the executor already does (done) + +For a whitelisted pure unmodeled value-returning call (G4 step 1, e.g. `String.trim`/`strip`), the +executor models the result as a generic axiom-free UF `pure__(inputs)`. Step 2 adds: +each run, at the GETVALUE recovery, the executor emits the **observed ground pair** for that call as a +constraint: + +``` +pure_() == +e.g. (= (pure_String_trim "abc ") "abc") +``` + +built with the SAME cached UF declaration as the symbolic result UF (so it constrains the same +symbol). It is emitted via `symbolicTraceHandler.addConstraint(...)`, so it travels to the explorer on +the existing UF channel: `SymbolicTrace.getConstraints()` → `DTOBuilder` `UFDTO` (DTOBuilder.java:78-81) +→ explorer `Parser.parse_ufs` → `Database.add_trace` → `tree.add(...)` (onto `Node.ufs`) **and** +`tree.record_ufs(...)` (into the per-testcase `Tree.ufs` set). Verified by `PureFunctionUFSpec` U-7 +(the pair is emitted, and is ground — over the constant input, no free variable). + +Emitting one pair per run is **sound on its own**: a single observed pair is a true fact about the +real function and cannot self-contradict, so nothing here can cause a wrong verdict today. + +## Why the explorer work is needed (the gap) + +Accumulation across runs does **not** currently reach the solver: +- **`Tree.ufs` is dead code** — written by `Tree.record_ufs` (Tree.py:60), read nowhere. +- The live UF injection (`StrategyService.collect_uf_definitions`, StrategyService.py:78-82 → + `Z3Handler.solve`, SolverHandler.py:407-409) reads **`Node.ufs`**, which is frozen in the `Node` + constructor (Node.py:51) and never merged across runs (`Tree.add_recursive` updates only + `node.constraint`, Tree.py:116-117). So a run's pair is injected only onto branches that run first + created; flipping a pre-existing branch never sees the accumulated table. + +So without the work below, step 2 is (almost) inert: the per-testcase lookup table accumulates in a +dead set and never tightens later solves. + +## Required explorer changes (land these together — see the coupling note) + +### 1. Inject the accumulated per-testcase UF set at solve time +Thread the accumulated set through the existing chokepoint. `collect_uf_definitions(node)` cannot reach +`Tree.ufs` (a `Node` has no tree back-reference; `solve_branch(..., endpoint_id=None)` is called with +no endpoint by 4/5 drivers: SVCompDriver.py:261, TargetDriver.py:154, SimpleDriver.py:160, +HTTPDriver.py:681, SVCompHandler.py:195 — only PassiveDriver passes it). So: +- In `StrategyService.select_branch` (which already holds the post-`add_trace` deepcopy `tree`, + StrategyService.py:16-23), pass `tree.ufs` into `solve_branch` → `collect_uf_definitions`, and union + those definition strings into `path_constraints` alongside the existing `node.ufs` walk. +- Do **NOT** add a `get_tree(endpoint_id)` inside `solve_branch` (endpoint_id is None on the main + paths). Use the snapshot `select_branch` already has. +- **No staleness:** the per-round lifecycle is sequential — `Database.add_trace` calls `tree.add` then + `tree.record_ufs` on the live tree before `retrieve_solution` → `select_branch` deepcopies, so the + snapshot reflects all prior runs. +- Format is identical (both `Node.ufs` defs and `Tree.ufs` strings are the same `fmgr.dumpFormula` + output), and re-asserting the same fact is idempotent in Z3 — double-adds are harmless. Once the + union lands, the per-node `node.ufs` walk is a redundant subset and may be dropped. + +### 2. Contradiction guard at accumulation (structural, not string-set identity) +`Tree.ufs` is a `Set` keyed by full-string identity — it cannot tell "same UF application, different +RHS" from two unrelated facts. Add a guard in `Tree.record_ufs` (Tree.py:52-60), narrowed to +`pure_`-named applications: parse each observed pair (via the existing `ConstraintManager` +parse machinery), key on `(uf-name, args) → rhs`; on a key collision with a **different** rhs, drop +the new pair (do not store both). A contradiction can only arise from a nondeterministic whitelist +entry; the guard prevents it from poisoning the accumulated set. + +### 3. Solve-time UNSAT backstop (verdict soundness — defense in depth) +Before any UNSAT→SAFE conclusion (`SVCompDriver.py:271-273` sets SAFE when no branch is SAT), if the +accumulated `pure_` UF facts ALONE are UNSAT, downgrade SAFE→UNKNOWN instead. This converts the +worst case (a bad/nondeterministic whitelist entry making the injected set self-contradictory → +**false SAFE** for the whole testcase) into a conservative UNKNOWN. ~3 lines, gated on `pure_` ufs +being present so it costs nothing on non-G4 testcases. + +### 4. L3 test (the L2 anchor does NOT cover this) +Drive the explorer directly (no JVM): `Database.instance().reset()` + `clear_constraint_cache()`, +then `add_trace` run A (a branch on `pure_String_trim(s)`, no pair) → the flip is SAT; `add_trace` +run B (same branch + an observed pair that forbids the value the flip needs) → assert +`select_branch`/`solve_branch` on that branch flips SAT→UNSAT. This proves an accumulated pair from an +earlier run constrains a later flip — i.e. injection is live, not inert. + +## Coupling note (important) + +**Items 1, 2, 3 must land together.** The executor emission and item 1 (injection) are what make the +accumulated facts reach the solver; once they do, a nondeterministic whitelist entry can make the set +UNSAT → false SAFE (`SVCompDriver.py:271-273`). So injection MUST NOT ship without the contradiction +guard (2) and the UNSAT backstop (3). Until this lands, the executor's emitted pairs sit in +`Tree.ufs`/`Node.ufs` and (apart from the existing within-run `Node.ufs` injection, which is benign — +a single pair can't contradict) do not affect verdicts. + +This also raises the bar on whitelist curation: with cross-run injection live, a wrongly-whitelisted +non-deterministic/side-effecting method becomes a *soundness* risk (potential false SAFE), not just an +imprecision. Keep `PureMethods` strictly pure + deterministic. + +## Key file references +Executor (done): `symbolic-executor/.../invoke/InvocationHandler.java` (buildPureUF, PureUFModel), +`.../value/PlaceHolder.java` (observedApplication), `.../SymbolicInstructionVisitor.java` +(visitGETVALUE_Object emit), `.../UFs/PureFunctionUF.java`, `.../trace/SymbolicTraceHandler.java:93` +(addConstraint), `.../trace/DTOBuilder.java:78-81` (UFDTO). Test: `PureFunctionUFSpec` U-7. +Explorer (to do): `symbolic-explorer/strategy/StrategyService.py:16-23,78-96`, +`data/BinaryExecutionTree/Tree.py:39,52-60,103-130`, `Node.py:51`, `solver/SolverHandler.py:391-393, +407-409`, `driver/SVCompDriver.py:261,271-273`, `solver/ConstraintCache.py`. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 771744d..effed55 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1285,6 +1285,16 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc SolverContext context = ThreadHandler.getSolverContext(currentThread().getId()); tmp = new StringValue(context, s, (StringFormula) placeHolder.recoveredFormula, inst.address); shadowStateLogger.info("Modeled unmodeled pure result as a generic UF: {}", tmp); + // G4 step 2: record this run's observed (input -> output) ground pair - + // pure_(constant inputs) == observed output. Sound: a true fact about the + // real function only tightens the axiom-free UF. Cross-run accumulation + + // solve-time injection are the explorer's job - see + // docs/heap-redesign-g4-step2-explorer-handoff.md. + if (placeHolder.observedApplication != null) { + StringFormulaManager smgr = context.getFormulaManager().getStringFormulaManager(); + symbolicTraceHandler.addConstraint( + smgr.equal((StringFormula) placeHolder.observedApplication, smgr.makeString(s))); + } } else { tmp = ValueFactory.createObjectValue(inst.val, inst.address); shadowStateLogger.info("Concretized unmodeled value-typed result (no identity recovery): {}", tmp); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 47efc16..55c8adf 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -7,6 +7,7 @@ import de.uzl.its.swat.common.exceptions.ValueConversionException; import de.uzl.its.swat.common.logging.GlobalLogger; import de.uzl.its.swat.common.logging.records.InvocationEntry; +import de.uzl.its.swat.symbolic.UFs.PureFunctionUF; import de.uzl.its.swat.symbolic.UFs.PureMethods; import de.uzl.its.swat.symbolic.trace.SymbolicTraceHandler; import de.uzl.its.swat.symbolic.value.PlaceHolder; @@ -22,6 +23,7 @@ import org.objectweb.asm.Type; import org.sosy_lab.java_smt.api.Formula; import org.sosy_lab.java_smt.api.FormulaType; +import org.sosy_lab.java_smt.api.StringFormulaManager; public class InvocationHandler { private static final Logger logger = GlobalLogger.getSymbolicExecutionLogger(); @@ -77,8 +79,9 @@ public class InvocationHandler { symbolicTraceHandler); } - // G4: generic UF for a whitelisted pure return; stays null -> recovery concretizes (G2). - Formula pureUF = null; + // G4: model of a whitelisted pure return (result UF over symbolic inputs + the same UF over + // constant observed inputs, for the step-2 observed pair); stays null -> recovery concretizes (G2). + PureUFModel pureUF = null; // When the method is not implemented and its not on the ignore list, we record it if (retValue instanceof PlaceHolder && !(IGNORED_INVOCATIONS.contains(owner + "/" + name) @@ -143,33 +146,58 @@ public class InvocationHandler { // after the context-loss check above, which compares retValue against PlaceHolder.instance by // identity. if (retValue == PlaceHolder.instance) { - retValue = new PlaceHolder(PlaceHolder.ValueOrigin.UNMODELED_RETURN, pureUF); + retValue = new PlaceHolder( + PlaceHolder.ValueOrigin.UNMODELED_RETURN, + pureUF == null ? null : pureUF.result(), + pureUF == null ? null : pureUF.observedApplication()); } return retValue; } + /** A whitelisted pure call modeled as a generic UF: the result over symbolic inputs, and (G4 + * step 2) the same UF over the constant observed inputs for the observed (input -> output) pair. */ + private record PureUFModel(Formula result, Formula observedApplication) {} + /** * Build the generic UF {@code pure_(inputs)} for a whitelisted pure call, or null to fall * back to G2 concretization. v1 handles String returns only; the inputs (receiver + args) must * all be value-typed so their formula fully captures the input (sound; no stateful receivers). + * Also builds the same UF applied to the CONSTANT inputs (step 2's observed-pair application), + * using the SAME cached declaration; that is null unless every input is a String (v1: only String + * concretes can be turned into constant formulas here). */ - private static Formula buildPureUF( + private static PureUFModel buildPureUF( String owner, String name, String desc, List> inputs) throws NoThreadContextException { // v1 scope: only String-returning methods are materialized as UFs. if (!"java.lang.String".equals(Type.getReturnType(desc).getClassName())) { return null; } - List argFormulas = new ArrayList<>(); + StringFormulaManager smgr = + ThreadHandler.getSolverContext(Thread.currentThread().getId()) + .getFormulaManager() + .getStringFormulaManager(); + List symbolicArgs = new ArrayList<>(); + List constArgs = new ArrayList<>(); + boolean observable = true; // an observed pair needs constant-buildable (v1: String) inputs for (Value v : inputs) { if (v.formula == null || !Util.isValueType(v.concrete)) { return null; // non-value-typed or formula-less input: defer to G2 concretize. } - argFormulas.add((Formula) v.formula); + symbolicArgs.add((Formula) v.formula); + if (v.concrete instanceof String s) { + constArgs.add(smgr.makeString(s)); + } else { + observable = false; // v1: only String inputs become constant formulas here. + } } - return ThreadHandler.getUFHandler(Thread.currentThread().getId()) - .getPureFunctionUF() - .apply(PureMethods.ufName(owner, name, desc), FormulaType.StringType, argFormulas); + PureFunctionUF uf = ThreadHandler.getUFHandler(Thread.currentThread().getId()).getPureFunctionUF(); + String ufName = PureMethods.ufName(owner, name, desc); + Formula result = uf.apply(ufName, FormulaType.StringType, symbolicArgs); + // Same cached declaration applied to the constant inputs, so the observed pair constrains the + // very symbol used in `result`. + Formula observed = observable ? uf.apply(ufName, FormulaType.StringType, constArgs) : null; + return new PureUFModel(result, observed); } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index ae11c2d..ac169ae 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -50,6 +50,13 @@ public enum ValueOrigin { * {@code pure_(inputs)} modeling the result. Null otherwise (then recovery concretizes, G2). */ public final Formula recoveredFormula; + /** + * G4 step 2: the same generic UF applied to the CONSTANT (observed) inputs, e.g. + * {@code pure_(makeString(concreteInput))}. At recovery this is asserted equal to the + * observed concrete output to record a ground (input -> output) pair. Null when no pair is + * emitted (non-String inputs / not a whitelisted pure call). + */ + public final Formula observedApplication; public static final PlaceHolder instance = new PlaceHolder(false); public static final PlaceHolder symbolicInstance = new PlaceHolder(true); @@ -59,6 +66,7 @@ public PlaceHolder(boolean isSymbolic) { this.inst = null; this.referenceValue = null; this.recoveredFormula = null; + this.observedApplication = null; } public PlaceHolder(ValueOrigin origin, Instruction inst, ObjectValue referenceValue) { @@ -67,6 +75,7 @@ public PlaceHolder(ValueOrigin origin, Instruction inst, ObjectValue refer this.inst = inst; this.referenceValue = referenceValue; this.recoveredFormula = null; + this.observedApplication = null; } public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { @@ -75,15 +84,22 @@ public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { this.inst = null; this.referenceValue = null; this.recoveredFormula = null; + this.observedApplication = null; } - /** UNMODELED_RETURN placeholder carrying a generic-UF formula for a whitelisted pure method (G4). */ - public PlaceHolder(ValueOrigin origin, Formula recoveredFormula) { + /** + * UNMODELED_RETURN placeholder carrying, for a whitelisted pure method (G4): the generic UF over + * the symbolic inputs ({@code recoveredFormula}, modeling the result) and the same UF over the + * constant observed inputs ({@code observedApplication}, used to record the observed pair). Either + * may be null. + */ + public PlaceHolder(ValueOrigin origin, Formula recoveredFormula, Formula observedApplication) { this.origin = origin; this.isSymbolic = false; this.inst = null; this.referenceValue = null; this.recoveredFormula = recoveredFormula; + this.observedApplication = observedApplication; } public ObjectValue asObjectValue() throws ValueConversionException { diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy index f1faf78..407b97d 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy @@ -3,6 +3,7 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.common.Util import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import de.uzl.its.swat.thread.ThreadHandler import org.sosy_lab.java_smt.api.BooleanFormula import org.sosy_lab.java_smt.api.Formula import org.sosy_lab.java_smt.api.StringFormula @@ -96,6 +97,26 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { isSat(smgr.equal(result.recovered.formula as StringFormula, smgr.makeString("a totally different value"))) } + @See("docs/heap-redesign-g4-design.md") + def "U-7: a whitelisted pure call emits its observed (input->output) pair as a ground UF constraint"() { + given: "a symbolic String receiver with a known concrete value" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "abc", 0x1000) + receiver.MAKE_SYMBOLIC() + + when: "trim() is invoked (whitelisted) and recovered, observing output 'abc'" + executeBoundaryRecovery(receiver, STRING, "trim", TRIM, "abc") + + then: "an observed-pair constraint over the pure_String_trim UF was recorded for this run" + def fm = solverContext.getFormulaManager() + def constraints = ThreadHandler.getSymbolicTraceHandler(threadId).getConstraints() + def pair = constraints.find { fm.extractVariablesAndUFs(it).keySet().contains("pure_String_trim") } + pair != null + + and: "the pair is GROUND (over the constant input, not the symbolic variable)" + fm.extractVariables(pair).isEmpty() + } + @See("docs/heap-redesign-tests.md") def "U-6: a whitelisted pure call is modeled (UF), so it does NOT flag context loss"() { given: "a symbolic String receiver" From 58c39321c37fad885c912510c9267f7dd59debcf Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Fri, 26 Jun 2026 12:37:06 +0000 Subject: [PATCH 10/33] feat(heap): G4 - grow purity whitelist (String methods) from java.lang survey Audited java.lang for pure/deterministic/side-effect-free, String-returning, UNMODELED methods (5 parallel survey agents; whitelist is soundness-load-bearing so the bar is strict). Added the verified String-class entries to PureMethods.WHITELIST: stripLeading, stripTrailing (no-arg), substring(I), substring(II), repeat(I), replace(CC), indent(I) (arg-taking -> mixed-sort UFs). All confirmed unmodeled stubs in StringValue. Deliberately NOT added (documented): the boxed integral/boolean toString-family is already modeled (IntegerInvocation/etc.) so a UF would never fire (inert); cross-class static String returns (Float/Double/Character.toString, verified unmodeled + pure) are a backlog item pending static-invoke test support; Math/StrictMath have no String returns. Soundness traps catalogued (locale-dependent toLowerCase(), getInteger/ getBoolean property readers, Math.random, raw-bit/NaN, intern, arg-mutating, etc.). Full audited classification + backlog: docs/heap-redesign-g4-whitelist-survey.md. Tests: L2 SubstringTarget (arg-taking, mixed-sort UF -> branch references input via UF, both soundness flags clear, end-to-end SAFE preservation). 43 heap+processor + 4 L2 green. Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g4-whitelist-survey.md | 92 +++++++++++++++++++ .../its/swat/symbolic/UFs/PureMethods.java | 18 +++- .../heap/PureFunctionUFAgentSpec.groovy | 17 ++++ .../resources/targets/SubstringTarget.java | 23 +++++ 4 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 docs/heap-redesign-g4-whitelist-survey.md create mode 100644 symbolic-executor/src/test/resources/targets/SubstringTarget.java diff --git a/docs/heap-redesign-g4-whitelist-survey.md b/docs/heap-redesign-g4-whitelist-survey.md new file mode 100644 index 0000000..9e08b17 --- /dev/null +++ b/docs/heap-redesign-g4-whitelist-survey.md @@ -0,0 +1,92 @@ +# G4 purity whitelist — java.lang survey (audited backlog) + +Audited classification of `java.lang` methods for the G4 generic-UF whitelist (`UFs/PureMethods.WHITELIST`). +The whitelist is **soundness-load-bearing** (G4 step-1 fix B skips context-loss for whitelisted calls, and +step-2's cross-run injection would let a wrong entry cause a false SAFE), so the bar is: **pure + +deterministic + side-effect-free + identical across JVM runs/platforms**. Surveyed by 5 parallel agents +(String / Integer+Long+Short+Byte / Float+Double / Boolean+Character / Math+StrictMath). + +## What is active now (committed to PureMethods.WHITELIST) + +Only **String-returning, UNMODELED** methods are active (v1 materializes String returns; whitelisting a +method SWAT already models is inert — the UF never fires). All instance methods on `String` (the proven +receiver shape): + +``` +java/lang/String/trim()Ljava/lang/String; (G4 step 1) +java/lang/String/strip()Ljava/lang/String; (G4 step 1) +java/lang/String/stripLeading()Ljava/lang/String; no-arg, same shape as trim +java/lang/String/stripTrailing()Ljava/lang/String; no-arg +java/lang/String/substring(I)Ljava/lang/String; arg-taking (mixed-sort UF: String,int) +java/lang/String/substring(II)Ljava/lang/String; arg-taking +java/lang/String/repeat(I)Ljava/lang/String; arg-taking +java/lang/String/replace(CC)Ljava/lang/String; arg-taking (String,char,char) +java/lang/String/indent(I)Ljava/lang/String; arg-taking +``` +Verified unmodeled (stubs returning `PlaceHolder.instance` in `StringValue`). Tested: no-arg shape by +`PureFunctionUFSpec` (trim) + L2 `TrimTarget`; arg-taking shape by L2 `SubstringTarget`. + +## Backlog — verified-effective, NOT yet added (needs test-harness support) + +These are sound + unmodeled + String-returning, but introduce shapes the current L1 fixture / test +harness doesn't yet drive (static invoke; the executor handles them, but we add nothing untested to a +soundness-load-bearing list): +- **Cross-class static String returns (UNMODELED — only `valueOf` is modeled in their Invocation handlers):** + `java/lang/Float/toString(F)`, `Float/toHexString(F)`, `java/lang/Double/toString(D)`, + `Double/toHexString(D)`, `java/lang/Character/toString(C)`, `Character/toString(I)`. Add once a + static-invoke L1/L2 test exists. (FP `toString` is spec-deterministic; Character case mapping is + locale-INDEPENDENT, unlike String.) + +## Inert — do NOT add (already modeled → the UF never fires) + +The boxed integral/boolean `toString`-family is modeled in `IntegerInvocation`/`LongInvocation`/ +`ShortInvocation`/`ByteInvocation`/`BooleanInvocation` (cases: `toString`, `toHexString`, +`toBinaryString`, `toOctalString`, `toUnsignedString`, `valueOf`). Whitelisting them is inert. +(`String.concat(String)` single-arg is also modeled.) The SV-COMP autostub corpus exercises +`Integer.toHexString`/`toBinaryString` etc. — already handled by these models, no UF needed. + +## Future tier — pure but NON-String return (activate when UF materialization extends beyond String) + +Large vetted set (sound to model as UFs once non-String return categories are materialized): +- **Integer/Long/Short/Byte:** parse*/valueOf/decode (String→num, deterministic), bit ops (bitCount, + highestOneBit, numberOfLeadingZeros, reverse, rotate*, reverseBytes), compare/compareUnsigned, + divideUnsigned/remainderUnsigned, sum/max/min, toUnsignedInt/Long, static hashCode(prim). +- **Float/Double:** parseFloat/parseDouble, isNaN/isInfinite/isFinite, compare, sum/max/min, static + hashCode, the CANONICALIZING bit ops (`floatToIntBits`/`doubleToLongBits`, `intBitsToFloat`/ + `longBitsToDouble`). +- **Boolean:** parseBoolean, compare, logicalAnd/Or/Xor, static hashCode. +- **Character:** the large predicate/conversion set (isDigit/isLetter/isWhitespace/getNumericValue/ + digit/forDigit/toUpperCase(char)/toLowerCase(char)/toTitleCase, codePoint helpers) — all + locale-independent + deterministic. +- **Math (exactly-specified only):** sqrt, fma, ceil/floor/rint/round/IEEEremainder, toRadians/toDegrees, + all integer arithmetic (*Exact, floorDiv/floorMod, abs, min/max), bit helpers (copySign, ulp, signum, + getExponent, nextAfter/Up/Down, scalb). +- **StrictMath (ALL math, incl. transcendentals):** bit-for-bit reproducible by spec — sin/cos/tan/exp/ + log/pow/cbrt/hypot/sinh/... plus everything Math has. + +Instance accessors on wrappers (`intValue()`, `doubleValue()`, instance `toString()`/`hashCode()`, +`compareTo(boxed)`) are pure-on-value but receiver-keyed — `ufName` keys on arg descriptors only, so two +distinct boxed receivers would collide to one UF term unless the receiver is incorporated. EXCLUDE until +receiver-keyed UFs exist; prefer the static equivalents (`hashCode(I)`, static `toString(I)`, `compare(II)`). + +## EXCLUDE — soundness traps (never whitelist) + +- **Locale-dependent:** `String.toLowerCase()`/`toUpperCase()` no-arg (default locale; Turkish-I). The + `(Locale)` overloads are deterministic given the locale arg, but the Locale arg is a non-value-typed + object so the UF wouldn't fire anyway. +- **Environment/property readers (look pure, are not):** `Integer.getInteger`, `Long.getLong`, + `Boolean.getBoolean` — read `System.getProperty`. +- **Nondeterministic:** `Math.random()` AND `StrictMath.random()` (the "Strict" prefix does NOT rescue it). +- **Cross-JVM nondeterministic:** `Math` transcendentals (sin/cos/tan/exp/log/pow/atan2/sinh/...) — only + within-ulp, not bit-identical across platforms; use the StrictMath twins. The raw-bit variants + `Float.floatToRawIntBits`/`Double.doubleToRawLongBits` (NaN payload varies). +- **Identity / arg-mutating / arbitrary-code / fresh-object:** `String.intern()`; `String.getChars`, + `Character.toChars(I[CI)` (mutate an arg); `String.format`/`valueOf(Object)`/`join(Iterable)`/ + `transform` (default locale or invoke arbitrary `toString`/`Function`); array/stream returners + (`toCharArray`, `getBytes*`, `split*`, `chars/codePoints/lines`); `equals(Object)` (consumes runtime + type + a foreign reference); constructors (`` allocate identity); `describeConstable`/ + `resolveConstantDesc` (Optional/reflection). `String.replaceAll/replaceFirst` excluded for v1 ($-group/ + escape replacement semantics a plain UF won't capture — revisit if regex is modeled). + +Per-class survey counts (INCLUDE/EXCLUDE) and full rationale are in the agents' transcripts; this file is +the actionable distillation. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java index 371b289..de80e10 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java @@ -19,11 +19,25 @@ public final class PureMethods { private PureMethods() {} - /** Keys are {@code owner + "/" + name + desc} (descriptor included to disambiguate overloads). */ + /** + * Keys are {@code owner + "/" + name + desc} (descriptor included to disambiguate overloads). + * Entries are pure, deterministic, side-effect-free, String-RETURNING, and UNMODELED by SWAT (so + * the generic UF actually fires). Curated from the java.lang purity survey + * (docs/heap-redesign-g4-whitelist-survey.md); the boxed types' toString-family is intentionally + * absent (already modeled -> a UF would never fire), and cross-class static String methods + * (Float/Double/Character.toString) are a documented backlog pending static-invoke test support. + */ private static final Set WHITELIST = Set.of( "java/lang/String/trim()Ljava/lang/String;", - "java/lang/String/strip()Ljava/lang/String;"); + "java/lang/String/strip()Ljava/lang/String;", + "java/lang/String/stripLeading()Ljava/lang/String;", + "java/lang/String/stripTrailing()Ljava/lang/String;", + "java/lang/String/substring(I)Ljava/lang/String;", + "java/lang/String/substring(II)Ljava/lang/String;", + "java/lang/String/repeat(I)Ljava/lang/String;", + "java/lang/String/replace(CC)Ljava/lang/String;", + "java/lang/String/indent(I)Ljava/lang/String;"); public static boolean isWhitelisted(String owner, String name, String desc) { return WHITELIST.contains(owner + "/" + name + desc); diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy index 1b71fbf..3ed97fe 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy @@ -34,4 +34,21 @@ class PureFunctionUFAgentSpec extends Specification { !obs.symbolicContextLoss !obs.symbolicPrecisionLoss } + + @See("docs/heap-redesign-g4-whitelist-survey.md") + def "G4 (L2): a whitelisted pure substring (arg-taking, mixed-sort UF) into a branch preserves SAFE"() { + when: + TraceObservation obs = AgentRun.run("targets/SubstringTarget.java", "SubstringTarget") + String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } + + then: "the symbolic input is designated" + inputVar != null + + and: "the branch references the input through the mixed-sort UF pure_String_substring_int" + obs.anyBranchReferences(inputVar) + + and: "neither SAFE downgrade fires (whitelist expansion to an arg-taking method stays sound)" + !obs.symbolicContextLoss + !obs.symbolicPrecisionLoss + } } diff --git a/symbolic-executor/src/test/resources/targets/SubstringTarget.java b/symbolic-executor/src/test/resources/targets/SubstringTarget.java new file mode 100644 index 0000000..2c46522 --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/SubstringTarget.java @@ -0,0 +1,23 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 target for the G4 arg-taking shape: a symbolic String is passed to an unmodeled but + * WHITELISTED pure method that TAKES AN ARGUMENT ({@code String.substring(int)}), whose result drives + * a branch. Exercises the mixed-sort generic UF ({@code pure_String_substring_int(String, int)}) end + * to end: the branch should reference the input through the UF, and NEITHER soundness flag should + * fire. Complements TrimTarget (the no-arg shape). See docs/test-architecture.md (Level L2). + */ +public class SubstringTarget { + + public static void main(String[] args) { + test("abcd"); + } + + public static String test(@Symbolic String s) { + String r = s.substring(1); // whitelisted pure + unmodeled, arg-taking -> pure_String_substring_int(s, 1) + if (r.equals("bcd")) { // the UF result reaches a branch + return "eq"; + } + return "ne"; + } +} From 994e6428b096c0b99ff512a1d74256776df52143 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Mon, 29 Jun 2026 11:54:52 +0000 Subject: [PATCH 11/33] feat(heap): G3-A1 - output-boundary de-interning for String returns + register-only-non-constant G3 closes the value-type heap-key unsoundness at its source: a value returned from un-instrumented code can be interned/shared (a literal, a constant, a this-return), so the reference-keyed heap (G1) can collide two logically distinct values onto one cell. De-interning at the output boundary gives every produced value a fresh identity, making the reference key sound for value types and letting their shadow (a G4 UF for a whitelisted pure method) round-trip through untracked space. A1 = the String slice (boxed types are A2; the equality-handling review is B): - Part 1 (NoCacheMethodAdapter): wrap a String return `new String(result)` (null-guarded) iff the callee is NOT instrumented (!Util.shouldInstrument(owner)) - the boundary - minus an explicit literal skip-set for the symbolic-input/witness intrinsics (de/uzl/its/swat/** and org/sosy_lab/sv_benchmarks/Verifier; this is load-bearing, not delegated to shouldInstrument). Gated on the existing useStringInterning switch. - Part 2 (visitGETVALUE_Object, String sub-branch only): register the recovered shadow in the heap only when it is NON-CONSTANT (!extractVariablesAndUFs(formula).isEmpty()) - a constant is reconstructible from the observed concrete on round-trip, so registering it would only grow the self-pinning heap leak (a String is its own weak key); symbolic/ UF shadows must be registered to round-trip. Dropped the always-constant formula==null registration. Sound (verified): only object identity changes; `==`/refEquals is value-equality by class (independent of de-interning); VIOLATION is replay-witnessed; the only flag effect is Objects.equals(deInterned) -> VIOLATION->UNKNOWN (conservative). Converged after 2 rounds of 3 independent design auditors + 3 post-impl reviews. Tests: OutputDeInternSpec (L1) pins register-only-non-constant (symbolic->registered, constant->not). 45 heap+processor + 4 L2 green; the L2 trim/substring agent runs now exercise the de-intern wrap (loads, runs, UF survives, flags clear). The wrap is verified at instrument time by NoCacheTransformer's inline CheckClassAdapter. Tracked pre-merge gate (bounded, ~0 expected): measure the reference_semantic_change VIOLATION->UNKNOWN delta on the SV-COMP suite (no String Objects.equals in the suite). Design + full audit history: docs/heap-redesign-g3-design.md. Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g3-design.md | 298 ++++++++++++++++++ .../nocache/NoCacheMethodAdapter.java | 34 ++ .../symbolic/SymbolicInstructionVisitor.java | 16 +- .../value/reference/lang/StringValue.java | 5 +- .../symbolic/heap/OutputDeInternSpec.groovy | 70 ++++ 5 files changed, 418 insertions(+), 5 deletions(-) create mode 100644 docs/heap-redesign-g3-design.md create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy diff --git a/docs/heap-redesign-g3-design.md b/docs/heap-redesign-g3-design.md new file mode 100644 index 0000000..b95207c --- /dev/null +++ b/docs/heap-redesign-g3-design.md @@ -0,0 +1,298 @@ +# G3 — Output-boundary de-interning: Problem & Proposed Solution + +Phase G3. Draft for review (not yet implemented). Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`) + G4 (`48763bc`/`6d39904`/`58c3932`). + +## Problem (recap) + +The shadow heap (G1) is now a **reference-keyed** weak identity map: a tracked value is recovered by the +real object's reference identity. For mutable objects (J1) that key is sound. For **immutable value types +(J2: String + boxed)** the reference is NOT a sound key — interning and `this`-returns make two logically +distinct values share one object (so one heap cell), or make a result share the receiver's object. + +G2 defends at the *recovery* site (concretize an unmodeled value-typed return; don't consult/mutate the +heap for it). But a documented **residual** remains: if a value-typed result (e.g. `toLowerCase()` +returning `this`) flows through **untracked** space (an untracked field/store) and is later recovered by +its real reference, the heap lookup finds the *receiver's* stale symbolic cell → the concretized result +**re-aliases** to the receiver. This is **precision-only**: G2 already flags context-loss on the unmodeled +call (SAFE→UNKNOWN), and VIOLATION is replay-witnessed, so it cannot cause a wrong verdict — but it +pollutes tracking and inflates candidate-set sizes. + +The principled fix for J2's "no sound reference key": make every produced value a **distinct object**, so +the reference key becomes sound for value types too (no two logical values share one object; no result +shares its receiver). That is **de-interning**, extended to method *returns*. + +## What already exists (nocache, active by default `useStringInterning=true`) + +`instrument/nocache/NoCacheMethodAdapter` already de-interns at instrumented sites: +- **String literals:** `LDC "x"` → `new String("x")`. +- **`String.intern()`** calls are removed. +- **Boxed `valueOf`** (`Integer/Long/Short/Byte/Character/Boolean.valueOf(prim)`) → `new Boxed(prim)`. + +So de-interning of *inputs/literals* is done. G3 adds the missing **output boundary**: value-typed method +*returns*. + +## Proposed solution (G3) + +Extend `NoCacheMethodAdapter.visitMethodInsn`: after a call that returns a **value type** (v1: `String`), +wrap the result in a fresh object so the produced value has a fresh identity. Bytecode to wrap a `String` +already on the stack: +``` +NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V +``` +(leaves `new String(result)` on the stack). Gated on `useStringInterning` (the existing de-intern switch), +and only for calls at instrumented sites whose return descriptor is `Ljava/lang/String;`. Skip our own +de-intern `` (void return — not value-returning, so naturally skipped) and `IGNORED_INVOCATIONS`-style +intrinsics. + +Effect (D-1): every value-returning call yields a fresh-identity object → a `this`-return can no longer +alias the receiver, and the reference key is unique per produced value → the G2 residual closes and +candidate sets stay singletons. + +## Why it's verdict-neutral / sound (D-2) + +De-interning copies the value (`new String(s)` has the same content) — `.equals`/value semantics are +unchanged. The only behavioral change is **reference identity** (`==`): two formerly-`==` values become +`!=`. The executor already detects this (`SymbolicTraceHandler.recordReferenceSemanticChange`, fired in +`ObjectsInvocation` when de-interned values are reference-compared) and downgrades **VIOLATION→UNKNOWN** +(never SAFE→VIOLATION or VIOLATION→SAFE). So de-interning can only make a verdict *more* conservative +(a `==`-dependent VIOLATION becomes UNKNOWN), never wrong. The same reasoning already justifies the +existing literal/boxed de-interning. + +## Acceptance tests (from heap-redesign-tests.md) + +- **D-1 Fresh identity on produced values:** an unmodeled value-returning call at an instrumented site → + result has a fresh identity distinct from inputs; a this-return cannot alias the receiver; candidate set + is a singleton. +- **D-2 De-interning is verdict-neutral:** a program run with/without output de-interning → same verdict + (soundness-neutral; de-interning never flips soundness). +- **D-3 reference-semantic-change only when warranted:** `==` on de-interned values → the flag fires only + when de-interned `==` actually diverges from real reference semantics, not otherwise. + +## The central tradeoff (the thing to decide) + +De-interning returns is **precision on the SAFE/tracking side at a cost on the VIOLATION side**: more +distinct objects → more `==` divergence → more `reference_semantic_change` → more VIOLATION→UNKNOWN, plus a +`new String` allocation per value-typed return. The G2 residual it closes is precision-only (cannot cause a +wrong verdict). So G3's worth hinges on: does the tracking-precision gain (cleaner candidate sets, no +round-trip re-aliasing) outweigh the added VIOLATION→UNKNOWN cost + overhead? + +## FINAL PLAN — boundary de-intern (un-instrumented value-type returns) + register-only-non-constant + +Supersedes the "narrow this-return-prone" scoping below: that was a mis-framing (user-corrected). Interning is +**general** — a literal `"Hello"`, a constant, or any object returned from code we don't instrument can be +interned/shared, which is the same heap-key unsoundness as a `this`-return. So the criterion is the *boundary*, +not specific methods. + +**Part 1 — Bytecode (`NoCacheMethodAdapter.visitMethodInsn`): de-intern un-instrumented value-type returns.** +After a call whose return type is a value type subject to interning (v1: `Ljava/lang/String;`) where **the +callee is not instrumented** (`!Util.shouldInstrument(owner)`), wrap the result, **null-guarded**: +``` +DUP ; IFNULL L ; NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V ; L: +``` +- **Skip the SWAT / sv-benchmarks intrinsic owners** (`org/sosy_lab/sv_benchmarks/Verifier`, `de/uzl/its/swat/**` + — Intrinsics/Witness/UtilInstrumented/instrument.svcomp.Verifier): un-instrumented but the symbolic-input + designation / witness seam — de-interning them would de-intern the symbolic input. +- Gate on `useStringInterning`. Value types only (boxed deferred; mutable J1 never de-interned). +- **Why complete:** instrumented code's literals are already de-interned at `LDC` (existing nocache), `intern()` + removed, boxed `valueOf` de-cached; every value entering from un-instrumented space is now de-interned at that + call. So the only un-de-interned values are exactly the un-instrumented returns this targets. + +**Part 2 — Executor: register only NON-CONSTANT shadows (`:1366`).** The de-interned copy `O2` already registers +via the existing value-typed ADDRESS_UNKNOWN `putToHeap` (`:1366`) so it round-trips with no new mechanism — but +broad de-interning makes the **self-pinning leak** (a String is its own never-evicting weak-map key) grow. Fix: +guard that `putToHeap` on the shadow being **non-constant** — register iff `!fmgr.extractVariablesAndUFs(formula) +.isEmpty()`. A pure constant is fully reconstructible from the observed concrete (`inst.val`) on round-trip, so +registering it buys nothing; a symbolic/UF shadow carries a relationship that the concrete can't reconstruct, so +it must be registered. This keeps the full de-intern (identity fix for all) while bounding the leak to the +UF/symbolic round-trips that benefit. +- **Predicate = non-constant formula, NOT `isSymbolic()`** (a G4 UF result is built by a constructor, so its + flag may be false though it carries a UF — guarding on the flag would wrongly skip exactly the win). +- **`:1366` is a SHARED path** (all value-typed ADDRESS_UNKNOWN recoveries, incl. modeled constant results like + `concat`-of-constants), so the guard also stops registering those — safe by the same reconstructible argument, + but broader than G3-only: auditors must confirm no non-G3 path relies on a constant being heap-registered. Also + audit `:1363` (the `formula==null` sibling — a constant by definition; would now skip, consistent) and confirm + `:1331` is irrelevant (placeholder path, value types return early at `:1303`). +- No other executor change. G2 concretize branch unchanged; the de-interned O2's UF survives (`invokeInit` copies + the formula). + +**Soundness / tests / costs:** as established by the prior audit — G3 can't flip a verdict; `==`/`refEquals` is +class-based + independent of de-interning; VIOLATION replay-witnessed; register-only-non-constant is sound +(constants reconstructible). Null-guard needed; the bytecode-wrap test MUST run with `checkClassAdapter=true` +(off in `sv-comp.cfg`). Tests at corrected altitudes: UF-survives-round-trip + a constant-still-recovers + +clobber-regression at L1; bytecode-wrap/null/loads+verifies + D-1 fresh-identity at L2; D-2 verdict-neutral at L3; +D-3 + measure the `Objects.equals` `reference_semantic_change` change (G3's O2 sets `userDeInterned`). Pre-existing +`replace(CharSequence)→this` `:1398` modeling inaccuracy: confirm de-intern (which wraps AFTER O1's GETVALUE) +doesn't worsen it. + +### ROUND-2 VERDICT — CONVERGED (all 3 auditors): sound, complete, ready, with gates + +- **Bytecode (auditor 1):** broad criterion sound. `!Util.shouldInstrument(owner)` is the correct, transformer-consistent boundary (and returns false for `java/lang/*` → those String returns de-interned, intended). **Refinement:** the skip-set MUST be an explicit literal owner check — + `owner.startsWith("de/uzl/its/swat/") || owner.equals("org/sosy_lab/sv_benchmarks/Verifier")` — layered as an ADDITIONAL exclusion when `!shouldInstrument(owner)`; do NOT delegate to `shouldInstrument` (it already returns false for those, so it would no-op the skip; note its hard-coded true-cases for `svcomp/Verifier`+`UtilInstrumented` mean the explicit `de/uzl/its/swat/**` entry is the load-bearing protection for Intrinsics/Witness). Wrap mechanics invariant across invoke opcodes/stack depths. **Watch-item:** broad scope multiplies wrap sites → method-bytecode-size (64 KB) pressure on String-heavy methods. +- **Executor (auditor 2):** register-only-non-constant is correct + a STRICT IMPROVEMENT over the dropped fresh-guard (gates on shadow content, so it never wrongly skips a legit cell update — eliminates the round-1 first-wins hazard). Keeps G4 UF results; bounds the leak to symbolic/UF returns; round-trip + no-clobber hold. **Doc fixes:** (a) the reason to use `extractVariablesAndUFs` (not `isSymbolic()`) is that it's the safe SUPERSET (catches UF-over-constants) — NOT "the G4 result's flag is false" (for the result shadow reaching `:1366` it carries the input var, so the flag is true there); (b) the `:1363` `formula==null` sibling is always-constant → simply DROP its `putToHeap` rather than predicate-guard it. +- **Soundness/scope (auditor 3):** sound (broad scope touches only identity, which no `==`/witness path consults) + complete (boundary criterion + existing LDC/intern/boxed de-intern provably covers all interning entry points). register-only-non-constant resolves the heap-bloat leak. Residual over-reach = pure `new String` allocation on ALL String returns incl. modeled (no perf baseline) — accepted watch-item. + +**TWO MUST-DO GATES (not optional):** +1. **Measure the `reference_semantic_change`-induced VIOLATION→UNKNOWN delta on the SV-COMP suite, before/after.** Broad scope makes the `Objects.equals(deInterned String, …)` firing strictly larger (every O2 sets `userDeInterned=true`); a currently-VIOLATION testcase flipping to UNKNOWN is a real score regression. (Mitigating fact: the suite currently has zero `Objects.equals` on Strings — auditor round-1 — and the `==`/refEquals path does NOT set the flag and is value-equality regardless of de-intern, so the expected delta is ~0; the gate is to confirm + catch regressions.) +2. **Run the bytecode-wrap test with `checkClassAdapter=true`** (`sv-comp.cfg` has it off, so a malformed wrap would otherwise degrade silently to a `VerifyError`→UNKNOWN). + +**Tests:** L1 — UF-survives-untracked-round-trip; a constant-return still recovers (pins the `:1363` reconstruct path); clobber-regression; **the non-constant-guard predicate itself** (drive a GETVALUE at `:1366` with a constant-formula StringValue → assert `getFromHeap` stays null; with a UF-formula → assert registered). L2 (`checkClassAdapter=true`) — bytecode-wrap + null-return + loads-and-verifies + D-1 fresh-identity. L3 — D-2 verdict-neutral (de-intern on/off → same verdict). D-3 — assert the `Objects.equals`-on-de-interned firing + measure (gate 1). + +### Round-2 audit re-check points (the changes vs the narrow plan) +1. The **broad criterion** (`!shouldInstrument(owner)` + value-type return, minus SWAT/sv-benchmarks intrinsics): + is it sound + complete, is `Util.shouldInstrument(owner)` the right boundary test at the call site, and is the + intrinsic skip-set correct/sufficient? +2. **Register-only-non-constant at the shared `:1366`**: is `!extractVariablesAndUFs(formula).isEmpty()` the right + predicate, does it correctly keep G4 UF results (flag-independent), and is skipping constant registration safe + for ALL value-typed ADDRESS_UNKNOWN recoveries (no non-G3 regression)? +3. Surviving items: null-guard + `checkClassAdapter=true` test; the `replace`/`:1398` interaction; the + `Objects.equals` firing change; test altitudes. + +## (Superseded) CONVERGED PLAN (narrow this-return-prone) — replaced by the FINAL PLAN above + +Three independent auditors (bytecode / executor-clobber / soundness-scope-tests) converged. Soundness is +confirmed by all three (G3 cannot flip SAFE↔VIOLATION; the `==`→value-equality rewrite is class-based and +total-for-Strings, hence **independent of** whether G3 de-interns any object; VIOLATION is replay-witnessed on +the already-de-interned program). The plan is revised as follows: + +1. **DROP the fresh-guard (it was misdiagnosed).** A *modeled* `this`-return (`toString()→this`) returns the + receiver object carrying its **real address**, so it takes the address-match branch (`:1397-1399`) and does + NO `putToHeap` — it never reaches `:1366`, so there is no clobber to guard. And the de-interned copy `O2` is + always a fresh object (`getFromHeap(O2)==null` always), so the guard would never trigger on it; worse, a + `getFromHeap==null` guard would change `:1366` from latest-wins to first-wins for already-keyed Strings, + which some paths (the `setAddress`+`putToHeap` pattern) may rely on. → **Do not add the guard.** Instead, + first write the clobber test against *current* code and confirm it is already green (it is, per audit). +2. **NO new executor code needed.** The de-interned `O2` (a fresh `String` carrying the copied UF/concretized + formula via `invokeInit`, which preserves concrete+formula) already lands on the existing value-typed + registration at `:1364-1366` (reached via the wrap's `` GETVALUE), so it is `putToHeap`'d and + **round-trips correctly** (recovering the G4 UF for whitelisted methods). The user's "does the symbolic + handling store it correctly?" → **yes, the existing path already does**, once de-interning gives `O2` a + fresh identity. (G2's concretize branch stays unchanged — O1 is consumed by the wrap; only O2 is registered.) +3. **NARROW the bytecode scope (all three flagged broad-scope problems):** + - **MUST skip the symbolic-input / SWAT intrinsic owners.** `nocache` runs BEFORE the SV-COMP transformer, + so a blanket String-return wrap would de-intern `org/sosy_lab/sv_benchmarks/Verifier.nondetString()` — the + symbolic input at its designation seam. `NoCacheMethodAdapter` cannot see `InvocationHandler.IGNORED_INVOCATIONS`, + so mirror a minimal skip-set (`de/uzl/its/swat/**` owners + `org/sosy_lab/sv_benchmarks/Verifier`). + - **Restrict to `this`-return-prone `java/lang/String` methods**, not all String returns — bounds (a) the + self-pinning heap leak (each de-interned String return is a never-evicting weak-map cell, since a String + is its own key), (b) the per-return `new String` overhead on the hottest opcode class, and (c) the one + real `reference_semantic_change` increase (C-4 below). Curated v1 set: `toLowerCase`, `toUpperCase`, + `trim`, `strip`, `stripLeading`, `stripTrailing`, `substring` (by owner+name+desc at the call site). + **Exclude `replace(CharSequence,CharSequence)`** (its modeled `this`-return can trip the `:1398` + address-match assertion — a pre-existing inaccuracy G3 shouldn't poke) and `concat`/fresh-producers + (no aliasing → pure cost). +4. **Null-guard confirmed needed**; and `instrumentation.checkClassAdapter=false` in `sv-comp.cfg`, so a bad + wrap is NOT caught by the verifier (it surfaces as a silent `VerifyError`→UNKNOWN). → the bytecode-wrap test + MUST run with `checkClassAdapter=true`. +5. **One real cost to test+measure (C-4):** G3's `O2` sets `userDeInterned=true` (via `invokeInit`), so a + program doing `Objects.equals(trim(x), s)` now fires `reference_semantic_change` where a PlaceHolder result + previously might not → more VIOLATION→UNKNOWN. Sound (only conservatism), but contradicts the earlier + "~0 added firings"; narrowing the scope minimizes it. Cover with a D-3 test + measurement. +6. **Corrected test altitudes:** UF-survives-round-trip + clobber-regression at **L1** (drive GETVALUE with + chosen identities, introspect `getFromHeap`); bytecode-wrap correctness + null-return + loads-and-verifies + at **L2** with `checkClassAdapter=true`; D-2 verdict-neutral at **L3** (same verdict with de-intern on/off); + D-3 `Objects.equals`-firing at L1/L2. The L1 fixture is post-instrumentation so it can't test the wrap; + "candidate-set singleton" isn't observable at L2 without a new field — demote to L1 heap-introspection. +7. **Confirm the win materializes:** before paying even the narrow cost, confirm at least one in-scope pattern + exists where a whitelisted pure String method's result round-trips through untracked space (the UF-survival + win G3 uniquely enables). + +Net: G3(b) shrinks to **a narrow, null-guarded, intrinsic-skipping bytecode de-intern of `this`-return-prone +String methods** + **no executor change** (existing registration suffices) + tests at corrected altitudes. + +## (Original) PLAN — superseded by the converged plan above + +Corrected after user steering: the earlier "defer" reasoning under-valued G3 (it framed it as a narrow +residual). G3 is the *core* fix that makes the reference key sound for value-typed returns, enabling them +to be tracked (UF for whitelisted, concretized otherwise) **through untracked round-trips** (option (b)). +The `==`/`refEquals` concern noted below is pre-existing, total-for-Strings, and INDEPENDENT of G3 +(`refEquals` maps every String `==` to value-equality based on the class, not the object) — so it neither +motivates nor blocks G3. + +### Part 1 — Bytecode: de-intern value-typed returns (NoCacheMethodAdapter.visitMethodInsn) +After a call whose **return descriptor is `Ljava/lang/String;`** (v1) at an instrumented site, give the +result a fresh identity, **null-guarded** (`new String((String)null)` would NPE): +``` +DUP ; IFNULL L ; NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V ; L: +``` +- Gate on the existing `useStringInterning` switch. Value types only (String in v1; mutable J1 returns keep + their sound identity key — never de-intern them). Boxed returns deferred. +- Applies to **all** String returns incl. modeled ones: de-interning fixes the *real object identity* + (this/interned), independent of how the executor shadows it. The shadow flows through unchanged — the + wrap's `String.(String)` runs `invokeInit`, copying the shadow's concrete + formula (so a modeled + or G4-UF formula survives; only identity becomes fresh). + +### Part 2 — Executor: make registration sound + round-trip-correct (visitGETVALUE_Object) +The forward (de-interned) object `O2` is a fresh `String` carrying the shadow's formula; it already lands on +the non-placeholder ADDRESS_UNKNOWN path which **already** `putToHeap`s it (`:1366`) → round-trip recovery +works with no new registration code needed for `O2`. The fix is to make that registration **non-clobbering**: +- **Fresh-guard the value-typed `putToHeap`**: register only when the object isn't already tracked + (`getFromHeap(inst.val) == null` before `putToHeap` at `:1366`, and audit `:1363`/`:1331`). This prevents a + **modeled `this`-return** (`O1 == receiver`, possibly reaching `:1366` with ADDRESS_UNKNOWN) from + overwriting the receiver's symbolic cell — re-introducing the very aliasing G3 removes. (Unmodeled + this-returns are already handled+returned in the G2 concretize branch `:1278-1304`, never reaching + `:1366`, so they don't clobber today.) +- Net executor change is small: a guard, not a new mechanism. G2's deliberate "don't putToHeap" for the + concretize branch can stay (O1 is consumed by the de-intern; O2 carries the shadow forward and is + registered at `:1366`). + +### Two-object (O1 → O2) model (the subtlety to audit) +Each de-interned return yields O1 = the method's real return (maybe `this`/interned, immediately consumed by +the wrap's ``) and O2 = `new String(O1)` (fresh, flows forward). Both trigger a `GETVALUE_Object`. The +plan relies on: O1 (unmodeled) being handled in the G2 branch without registration; O1 (modeled this-return) +being **skipped by the fresh-guard** at `:1366`; O2 being fresh → registered at `:1366`. Round-trip recovers +O2's shadow (the UF for whitelisted methods — the goal). + +### Acceptance tests +D-1 fresh identity (L2: this-return produces a distinct-identity result, candidate set singleton); a new +**round-trip** test (L2: a whitelisted pure return stored via untracked space then recovered keeps its UF — +no re-alias to receiver, shadow preserved); D-2 verdict-neutral; D-3 reference-semantic-change only when +warranted; plus a **clobber regression** test (a this-return must not overwrite the receiver's shadow). + +### Key audit questions (for the three auditors) +1. **Bytecode**: is the null-guarded wrap correct (stack states, COMPUTE_FRAMES + CheckClassAdapter, the + IFNULL frame)? Does `LocalVariablesSorter` need anything? Discarded-result / chained-call edges? +2. **The clobber/fresh-guard**: does a modeled `this`-return actually reach `:1366` with ADDRESS_UNKNOWN and + `O1 == receiver`? Is the `getFromHeap == null` guard the right and sufficient fix at `:1366` (and + `:1363`/`:1331`)? Could the guard wrongly SKIP a legitimately-fresh registration? Is there any non-G3 path + that already clobbers here today? +3. **Round-trip correctness**: does O2 (fresh String with the copied UF/concretized formula) actually get + registered at `:1366` and recovered on an untracked round-trip? Does `invokeInit` truly preserve the + formula (so the UF survives the de-intern copy)? +4. **Soundness/verdict-neutrality (D-2)**: can de-interning + the registration change a verdict's soundness + (vs only conservatism)? Interaction with G2 (concretize branch unchanged), G4 (UF formula carried through + the copy), G_oob (divergence detection unaffected?). +5. **Scope/perf**: all String returns (incl. modeled) — acceptable overhead, or narrow? Is registering every + de-interned String return going to bloat the heap / change candidate-set behavior unexpectedly? + +## (Superseded) earlier defer analysis — kept for context + +An adversarial review (verified against code) recommends **not doing G3 now**: +- **Precision-only + narrow.** The residual G3 closes is a 4-way intersection (unmodeled call ∧ this-return ∧ untracked round-trip ∧ receiver previously heap-registered) and already yields the correct *conservative* verdict (G2 context-loss → SAFE→UNKNOWN at `InvocationHandler.java:138`/`SVCompDriver.py:304`; VIOLATION replay-witnessed). No current target exhibits a precision problem it would fix. +- **The "cost tradeoff" as framed is ~0.** `reference_semantic_change` fires ONLY on `Objects.equals(String,String)` (one caller, `ObjectsInvocation.java:47`); the SV-COMP suite has zero such String uses. So broad de-interning adds ~0 flag firings — but also means the advertised "VIOLATION→UNKNOWN conservatism" does NOT cover the `==` path. +- **Real costs/bugs G3 would add:** a `new String` per String return incl. already-MODELED returns (overhead, redundant); and a real **null-return NPE** (`new String((String)null)` throws) for any in-scope method that can return null. +- **It widens a pre-existing soundness approximation (the real issue):** `String ==` is rewritten by `RefEqualityMethodAdapter` → `UtilInstrumented.refEquals` → `Objects.equals` (value-equality) for de-interned classes, **with no flag**. So `new String("x") == new String("x")` is modeled as **true** (real JVM: false), unflagged, and VIOLATION is witnessed by re-running this de-interned+value-equality program. This is independent of G3 (it exists for literal/boxed de-interning today) but G3 would enlarge the de-interned-String population and thus the divergence surface. **Deciding whether this `==`→value-equality approximation is acceptable is the higher-value soundness question in this area — not the G2 residual.** + +**If G3 is done anyway:** narrow hard (only the unmodeled `this`-return-prone String methods — `toLowerCase/toUpperCase/trim/strip/substring`-style — not all String returns; feasible at the call site via callee owner/name), add a null guard, and first resolve/document the `==`/refEquals gap. + +## Risks / open questions for review + +- **Worth it + scope.** Is *broad* String-return de-interning right, or should it be narrowed (only + `this`-return-prone methods like `toLowerCase`/`trim`; or only value-returning calls in symbolic scope) + to bound the `reference_semantic_change` cost? Is G3 worth doing now at all, given it's precision-only and + G1/G2/G4 already prevent wrong verdicts? +- **Bytecode correctness.** Is `NEW; DUP_X1; SWAP; INVOKESPECIAL (String)V` the right wrap for a + String on the stack? COMPUTE_FRAMES/verify implications; interaction with the existing `CheckClassAdapter`. +- **reference_semantic_change cost (D-3).** Does the existing flag fire *only when warranted*? Will broad + return de-interning materially increase VIOLATION→UNKNOWN on the SV-COMP suite? (Plan says "measure + candidate-set sizes; monitor reference_semantic_change cost.") +- **Verdict-neutrality (D-2).** Confirm de-interning a return can never change a verdict's soundness, only + conservatism. +- **Interaction with G2/G4.** Does de-interning returns make G2's concretize partly redundant (the heap + recovery would no longer find the receiver)? Does it interfere with G4's UF-modeled returns (the UF + result is a materialized value; de-interning the real object underneath it — any conflict)? +- **Performance.** Return sites are hot-ish (every value-returning call); a `new String` per String return. + No perf baseline exists. Acceptable, or gate/narrow? +- **Boxed returns.** Defer to a follow-up (wrapping a boxed return needs unbox+rebox), or include? v1 = + String only (matches G2/G4 v1 scope). +- **Testing altitude.** D-1/D-3 likely L2 (real instrumentation + real identities); D-2 is L3 (verdict + with/without). What's feasible? diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java index aba06b8..97a7b6e 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java @@ -1,5 +1,6 @@ package de.uzl.its.swat.instrument.nocache; +import de.uzl.its.swat.common.Util; import de.uzl.its.swat.config.Config; import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; @@ -135,6 +136,39 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri } // For all other method calls, proceed normally. mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface); + + // G3: output-boundary de-interning (v1: String). De-intern a value-typed return from an + // UN-instrumented callee - the boundary where interned/shared values (literals, constants, + // this-returns, interned objects) enter shadow space. A fresh `new String(result)` gives the + // produced value a distinct identity, so the reference-keyed heap stays sound for value types. + // Null-guarded (`new String((String) null)` would NPE). Skip the SWAT / sv-benchmarks + // intrinsics (the symbolic-input designation / witness seam). Gated on the existing de-intern + // switch. (Boxed types are G3-A2.) + if (Config.instance().isUseStringInterning() + && "Ljava/lang/String;".equals(Type.getReturnType(descriptor).getDescriptor()) + && !Util.shouldInstrument(owner) + && !isDeInternSkippedOwner(owner)) { + Label done = new Label(); + mv.visitInsn(Opcodes.DUP); + mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap + mv.visitTypeInsn(Opcodes.NEW, "java/lang/String"); + mv.visitInsn(Opcodes.DUP_X1); + mv.visitInsn(Opcodes.SWAP); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/String", "", + "(Ljava/lang/String;)V", false); + mv.visitLabel(done); + } + } + + /** + * Owners whose value-typed returns must NOT be de-interned even though they are un-instrumented: + * the symbolic-input designation / witness intrinsics. {@code shouldInstrument} already returns + * false for these (they're excluded), so this explicit check is the load-bearing exclusion, not a + * delegation to it. + */ + private static boolean isDeInternSkippedOwner(String owner) { + return owner.startsWith("de/uzl/its/swat/") + || owner.equals("org/sosy_lab/sv_benchmarks/Verifier"); } /* diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index effed55..609ec37 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1356,14 +1356,24 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc "Concrete value of the object does not match the value in the stack: {} | {}", inst.val, peek.concrete); if(peek.formula == null) { - // TODO This needs to be cleaned up! + // A constant String: reconstruct from the observed concrete. G3 + // register-only-non-constant: a constant is recoverable from inst.val on + // round-trip, so it is NOT heap-registered (registering it only grows the + // self-pinning heap leak - a String is its own weak key). stack.popOperand(); StringValue val = ValueFactory.createStringValue(s, inst.address); stack.pushOperand(val); - stack.putToHeap(inst.val, val); } else { (peek.asObjectValue()).setAddress(inst.address); - stack.putToHeap(inst.val, peek); + // G3 register-only-non-constant: register iff the shadow carries symbolic + // content (a variable/UF) - those can't be reconstructed from the concrete + // and must round-trip via the heap (e.g. a whitelisted pure method's UF); + // pure constants are skipped to bound the leak. + FormulaManager fmgr = + ThreadHandler.getSolverContext(currentThread().getId()).getFormulaManager(); + if (!fmgr.extractVariablesAndUFs((Formula) peek.formula).isEmpty()) { + stack.putToHeap(inst.val, peek); + } } } else { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java index ebba905..327a6c8 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java @@ -219,8 +219,9 @@ public BooleanFormula getBounds(boolean upper) { this.concrete = s.concrete; this.formula = s.formula; } - // Mark this string as user-de-interned since it was created via new String() - // in user code (as opposed to SWAT's NoCacheMethodAdapter de-interning) + // Mark this string as de-interned: it was created via a `new String(...)` constructor, which + // gives it a fresh identity. This covers both user `new String()` and SWAT's own de-interning + // (NoCacheMethodAdapter: LDC literals and G3 output-boundary return wrapping). this.userDeInterned = true; return VoidValue.instance; } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy new file mode 100644 index 0000000..6a9fc6c --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy @@ -0,0 +1,70 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.common.Util +import de.uzl.its.swat.instrument.GlobalStateForInstrumentation +import de.uzl.its.swat.symbolic.SymbolicInstructionVisitor +import de.uzl.its.swat.symbolic.instruction.GETVALUE_Object +import de.uzl.its.swat.symbolic.instruction.Instruction +import de.uzl.its.swat.symbolic.instruction.NOP +import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec +import de.uzl.its.swat.symbolic.processor.SymbolicInstructionProcessor +import de.uzl.its.swat.symbolic.value.reference.ObjectValue +import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import de.uzl.its.swat.thread.ThreadHandler +import spock.lang.See + +/** + * G3 register-only-non-constant (Level L1). At the value-typed GETVALUE recovery, a String shadow that + * carries symbolic content (a variable/UF) IS heap-registered so it round-trips through untracked space + * (the whitelisted-pure-UF win), while a pure-constant String shadow is NOT registered — it is + * reconstructible from the observed concrete, so registering it would only grow the self-pinning heap + * leak (a String is its own weak-map key). G3 de-interning (bytecode) makes the registered reference + * sound; this spec pins the executor-side registration policy. See docs/heap-redesign-g3-design.md. + */ +class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { + + /** Recover a value-typed object at ADDRESS_UNKNOWN: push the shadow, then drive GETVALUE_Object. */ + private void recover(StringValue shadow, Object obj) { + SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) + visitor.getStack().pushOperand(shadow) + List instructions = new ArrayList<>() + instructions.add(new GETVALUE_Object(GlobalStateForInstrumentation.instance.incAndGetId(), + System.identityHashCode(obj), obj, 0)) + instructions.add(new NOP(GlobalStateForInstrumentation.instance.incAndGetId())) + // setCurrent(first); processing the next visits the *current* (execution is one behind). + ThreadHandler.setCurrentInstruction(threadId, instructions.remove(0)) + SymbolicInstructionProcessor processor = new SymbolicInstructionProcessor() + while (!instructions.isEmpty()) { + processor.processInstruction(instructions.remove(0)) + } + } + + @See("docs/heap-redesign-g3-design.md") + def "G3: a symbolic String shadow IS heap-registered (round-trip enabled)"() { + given: "a symbolic String shadow awaiting its address (ADDRESS_UNKNOWN)" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + String obj = "symbolic-abc" + StringValue shadow = new StringValue(solverContext, obj, ObjectValue.ADDRESS_UNKNOWN) + shadow.MAKE_SYMBOLIC() + + when: "the value is recovered" + recover(shadow, obj) + + then: "it is registered, so an untracked round-trip can recover the symbolic shadow" + ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null + } + + @See("docs/heap-redesign-g3-design.md") + def "G3: a constant String shadow is NOT heap-registered (reconstructible; no leak)"() { + given: "a pure-constant String shadow (formula = makeString) awaiting its address" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + String obj = "constant-xyz" + StringValue shadow = new StringValue(solverContext, obj, ObjectValue.ADDRESS_UNKNOWN) + + when: "the value is recovered" + recover(shadow, obj) + + then: "it is NOT registered - reconstructible from the observed concrete, so no leak" + ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null + } +} From 791ce4187cc0aea892d266eb0a01ef8217fc1195 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Mon, 29 Jun 2026 14:53:18 +0000 Subject: [PATCH 12/33] feat(heap): G3-A2 - extend output-boundary de-interning to the boxed wrapper types A1 de-interned un-instrumented String returns so the reference-keyed heap stays sound for that value type. A2 completes the value-type set: the six CACHED boxed wrappers in Util.deInternedClasses (Integer/Long/Short/Byte/Character/Boolean). Float/Double are deliberately excluded - uncached (no shared identity to break) and reference-equality, so de-interning them would be unsound. Part 1 (NoCacheMethodAdapter): A1's inline String wrap is refactored into deInternReturn(Type). Boxed wrappers have no copy constructor, so a boxed return is unbox+reboxed (new Integer(result.intValue()), etc.), null-guarded, via a per-type `Boxed` table. A local holds the unboxed primitive across the NEW - required for the category-2 long (DUP_X1/SWAP cannot reorder a wide value) and uniform across types via Type.getOpcode(ISTORE/ILOAD). Mirrors the existing valueOf(primitive) rewrites; those handle the (prim) overloads and return first, so there is no double-processing. Part 2 (visitGETVALUE_Object non-String branch): register-only-non-constant, scoped via Util.isDeInternedClass(inst.val) so it applies ONLY to the de-interned boxed types - mutable objects and Float/Double keep unconditional registration (sound identity key). A boxed wrapper carries its formula in the inner BoxedValue.getVal(), not the wrapper's own field, so the non-constant predicate reads the right source. Sound (reviewed): de-intern changes only identity; == on boxed is already value-equality via Util.shouldUseValueEquality (independent of A2); a constant boxed is reconstructible from inst.val on round-trip (ValueFactory.createObjectValue), so skipping its registration loses nothing. The boxed UF round-trip win is not yet active (whitelist "Future tier"); the soundness win (fresh identity -> no aliasing collision) holds regardless and the policy is forward-ready. Tests: OutputDeInternSpec (L1) - symbolic boxed registered, constant boxed not, and a mutable object ALWAYS registered (shared-path regression guard); BoxedDeInternAgentSpec (L2) - Integer (cat-1) + Long (cat-2 wide) returns load, verify, and run under the real agent. The category-2 long wrap (LSTORE/LLOAD, 2 slots) was verified valid via javap + CheckClassAdapter and executed. 48 heap+processor + 5 L2 green. 3 post-impl reviews (correctness/refactoring/style). Follow-up (out of scope): the pre-existing six valueOf(prim) rewrite blocks hard-code the same per-type data the `Boxed` table now holds; they could be driven from it so the two don't drift. Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g3-design.md | 12 ++- .../java/de/uzl/its/swat/common/Util.java | 10 +++ .../nocache/NoCacheMethodAdapter.java | 83 +++++++++++++++++-- .../symbolic/SymbolicInstructionVisitor.java | 30 ++++++- .../heap/BoxedDeInternAgentSpec.groovy | 31 +++++++ .../symbolic/heap/OutputDeInternSpec.groovy | 66 +++++++++++++-- .../resources/targets/BoxedReturnTarget.java | 25 ++++++ 7 files changed, 239 insertions(+), 18 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java diff --git a/docs/heap-redesign-g3-design.md b/docs/heap-redesign-g3-design.md index b95207c..5d17803 100644 --- a/docs/heap-redesign-g3-design.md +++ b/docs/heap-redesign-g3-design.md @@ -1,6 +1,16 @@ # G3 — Output-boundary de-interning: Problem & Proposed Solution -Phase G3. Draft for review (not yet implemented). Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`) + G4 (`48763bc`/`6d39904`/`58c3932`). +Phase G3. Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`) + G4 (`48763bc`/`6d39904`/`58c3932`). + +## Implementation status + +- **A1 (String): committed `994e642`.** Boundary de-intern of un-instrumented String returns + register-only-non-constant at the String recovery path. 2 design-audit rounds + 3 post-impl reviews. +- **A2 (boxed): implemented (this branch).** Extends de-interning to the six cached boxed wrappers in `Util.deInternedClasses` (Integer/Long/Short/Byte/Character/Boolean; **not** Float/Double — uncached, reference-equality). Two parts: + - *Bytecode* (`NoCacheMethodAdapter.deInternReturn` + the `Boxed` table): boxed wrappers have no copy constructor, so the return is **unbox+rebox**ed (`new Integer(result.intValue())`, etc.), null-guarded. A local holds the unboxed primitive across the `NEW` — required for `long` (category-2, where `DUP_X1`/`SWAP` can't reorder a wide value) and uniform via `Type.getOpcode(ISTORE/ILOAD)`. Mirrors the existing `valueOf(primitive)` rewrites. + - *Executor* (`visitGETVALUE_Object` non-String branch + `isConstantDeInternedValue`): register-only-non-constant, **scoped to `Util.isDeInternedClass(inst.val)`** so it touches only the de-interned boxed types. Mutable objects and Float/Double on this shared recovery path keep unconditional registration. A boxed wrapper carries its formula in the inner `BoxedValue.getVal()`, not the wrapper's own field — the predicate reads the right one. + - *Tests*: `OutputDeInternSpec` (L1) — symbolic boxed → registered, constant boxed → not, **mutable object → always registered** (shared-path regression guard); `BoxedDeInternAgentSpec` (L2) — Integer (cat-1) + Long (cat-2 wide) returns load, verify, and run under the real agent (`AgentRun` asserts `exit==0`, so a malformed wrap would fail). 48 heap+processor + 5 L2 green. + - *Note on the round-trip win*: boxed UF materialization is not yet active (the whitelist survey's "Future tier"), so today boxed shadows reaching recovery are constants or symbolic-input-derived; the soundness win (fresh identity → no aliasing collision) holds regardless, and register-only-non-constant is forward-ready for when boxed UFs land. +- **B (equality review): pending** — review `refEquals` / `==`→value-equality + the `reference_semantic_change` flag asymmetry for the de-interned cases. ## Problem (recap) diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java index c880b75..2c36d92 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java @@ -429,6 +429,16 @@ private static boolean isDeInternedClass(Class clazz) { return deInternedClasses.contains(clazz.getName()); } + /** + * Whether {@code o}'s runtime class is one G3 de-interns: String and the cached boxed wrappers + * (Boolean/Byte/Short/Character/Integer/Long), but NOT the uncached Float/Double (which use + * reference equality). Used by recovery to scope register-only-non-constant to exactly the + * de-interned value types, leaving mutable objects and Float/Double unconditionally registered. + */ + public static boolean isDeInternedClass(Object o) { + return o != null && isDeInternedClass(o.getClass()); + } + /** * Whether a concrete object is an immutable value type (String / boxed primitive). * Used by recovery to concretize an unmodeled value-returning method's result instead of diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java index 97a7b6e..5fc0318 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java @@ -137,17 +137,27 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri // For all other method calls, proceed normally. mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface); - // G3: output-boundary de-interning (v1: String). De-intern a value-typed return from an - // UN-instrumented callee - the boundary where interned/shared values (literals, constants, - // this-returns, interned objects) enter shadow space. A fresh `new String(result)` gives the - // produced value a distinct identity, so the reference-keyed heap stays sound for value types. - // Null-guarded (`new String((String) null)` would NPE). Skip the SWAT / sv-benchmarks - // intrinsics (the symbolic-input designation / witness seam). Gated on the existing de-intern - // switch. (Boxed types are G3-A2.) + // G3: output-boundary de-interning. De-intern a value-typed return from an UN-instrumented + // callee - the boundary where interned/shared values (literals, constants, this-returns, + // cached boxes) enter shadow space. A fresh copy gives the produced value a distinct identity, + // so the reference-keyed heap stays sound for value types. Skip the SWAT / sv-benchmarks + // intrinsics (the symbolic-input designation / witness seam). Gated on the de-intern switch. if (Config.instance().isUseStringInterning() - && "Ljava/lang/String;".equals(Type.getReturnType(descriptor).getDescriptor()) && !Util.shouldInstrument(owner) && !isDeInternSkippedOwner(owner)) { + deInternReturn(Type.getReturnType(descriptor)); + } + } + + /** + * Emit a fresh, distinctly-identified copy of the value just returned (now on the stack), if its + * type is an interning value type. Null-guarded - the wrap would NPE on a null return. String and + * the six cached boxed wrappers are covered; Float/Double are intentionally excluded (uncached, + * reference equality - see {@link Util#isDeInternedClass(Object)}). + */ + private void deInternReturn(Type returnType) { + if ("Ljava/lang/String;".equals(returnType.getDescriptor())) { + // String has a copy constructor: reorder the on-stack reference into new String(ref). Label done = new Label(); mv.visitInsn(Opcodes.DUP); mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap @@ -157,6 +167,63 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/String", "", "(Ljava/lang/String;)V", false); mv.visitLabel(done); + return; + } + Boxed boxed = Boxed.forDescriptor(returnType.getDescriptor()); + if (boxed != null) { + // Boxed wrappers have no copy constructor: unbox to the primitive, then rebox into a fresh + // instance (same shape as the valueOf de-interning above). A local holds the primitive + // across the NEW (required for the category-2 long; harmless for the others). + Label done = new Label(); + mv.visitInsn(Opcodes.DUP); + mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap + mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, boxed.owner, boxed.unboxMethod, + boxed.unboxDescriptor, false); + int local = newLocal(boxed.primType); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ISTORE), local); + mv.visitTypeInsn(Opcodes.NEW, boxed.owner); + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ILOAD), local); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, boxed.owner, "", + boxed.ctorDescriptor, false); + mv.visitLabel(done); + } + } + + /** The six cached boxed wrappers G3 de-interns, with their unbox method and primitive constructor. */ + private enum Boxed { + INTEGER("java/lang/Integer", "intValue", "()I", "(I)V", Type.INT_TYPE), + LONG("java/lang/Long", "longValue", "()J", "(J)V", Type.LONG_TYPE), + SHORT("java/lang/Short", "shortValue", "()S", "(S)V", Type.INT_TYPE), + BYTE("java/lang/Byte", "byteValue", "()B", "(B)V", Type.INT_TYPE), + CHARACTER("java/lang/Character", "charValue", "()C", "(C)V", Type.INT_TYPE), + BOOLEAN("java/lang/Boolean", "booleanValue", "()Z", "(Z)V", Type.INT_TYPE); + + final String owner; + final String unboxMethod; + final String unboxDescriptor; + final String ctorDescriptor; + final Type primType; + + Boxed(String owner, String unboxMethod, String unboxDescriptor, String ctorDescriptor, + Type primType) { + this.owner = owner; + this.unboxMethod = unboxMethod; + this.unboxDescriptor = unboxDescriptor; + this.ctorDescriptor = ctorDescriptor; + this.primType = primType; + } + + static Boxed forDescriptor(String descriptor) { + return switch (descriptor) { + case "Ljava/lang/Integer;" -> INTEGER; + case "Ljava/lang/Long;" -> LONG; + case "Ljava/lang/Short;" -> SHORT; + case "Ljava/lang/Byte;" -> BYTE; + case "Ljava/lang/Character;" -> CHARACTER; + case "Ljava/lang/Boolean;" -> BOOLEAN; + default -> null; + }; } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 609ec37..7d5775f 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1378,7 +1378,13 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc } else { (peek.asObjectValue()).setAddress(inst.address); - stack.putToHeap(inst.val, peek); + // G3-A2 register-only-non-constant for de-interned boxed value types (same + // policy as the String branch above): a constant boxed value is reconstructible + // from inst.val, so skip registering it; symbolic boxed values, mutable objects, + // and uncached Float/Double keep unconditional registration. + if (!isConstantDeInternedValue(inst.val, peek)) { + stack.putToHeap(inst.val, peek); + } } } else { // Need to obtain the Object address @@ -1453,6 +1459,28 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc } } + /** + * Whether {@code ref} is a de-interned value type (String / cached boxed wrapper) whose shadow is a + * pure constant - i.e. carries no symbolic variable or UF. Such a value is reconstructible from the + * observed concrete on round-trip, so G3 (register-only-non-constant) skips heap-registering it to + * bound the heap; only the symbolic/UF shadows that cannot be reconstructed are registered. A boxed + * value carries its formula in the inner {@link BoxedValue#getVal()}, not the wrapper's own field. + */ + private boolean isConstantDeInternedValue(Object ref, Value shadow) + throws NoThreadContextException { + if (!Util.isDeInternedClass(ref)) { + return false; + } + Formula formula = (shadow instanceof BoxedValue boxed) + ? (Formula) boxed.getVal().formula + : (Formula) shadow.formula; + if (formula == null) { + return true; + } + FormulaManager fmgr = ThreadHandler.getSolverContext(currentThread().getId()).getFormulaManager(); + return fmgr.extractVariablesAndUFs(formula).isEmpty(); + } + /** * Artificial instruction used to fetch concrete boolean values * diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy new file mode 100644 index 0000000..eaa2a27 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy @@ -0,0 +1,31 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G3-A2 end-to-end anchor (Level L2): the REAL agent runs a program that receives boxed wrappers from + * un-instrumented java/lang methods (Integer.valueOf(String), Long.valueOf(String)), which trigger the + * unbox+rebox de-intern wrap - including the category-2 (long) wide-local path. The run completing + * (AgentRun asserts exit == 0 plus a parsed TraceDTO) is the bytecode-validity oracle: a malformed wrap + * would VerifyError. Object-identity (the actual de-intern effect) is pinned at L1 by OutputDeInternSpec; + * this anchors that the boxed bytecode is real-JVM-valid. Mirrors PureFunctionUFAgentSpec. + * + * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + */ +class BoxedDeInternAgentSpec extends Specification { + + @See("docs/heap-redesign-g3-design.md") + def "G3-A2 (L2): boxed returns (Integer + Long) de-intern, load, verify, and run"() { + when: "the agent runs a program taking boxed wrappers from un-instrumented methods" + TraceObservation obs = AgentRun.run("targets/BoxedReturnTarget.java", "BoxedReturnTarget") + + then: "the run completed (exit 0 + parsed trace) - the unbox+rebox wrap is verifier-valid" + obs != null + + and: "the symbolic input was designated and traced" + !obs.inputNames.isEmpty() + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy index 6a9fc6c..f2df8fa 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy @@ -8,23 +8,28 @@ import de.uzl.its.swat.symbolic.instruction.Instruction import de.uzl.its.swat.symbolic.instruction.NOP import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.processor.SymbolicInstructionProcessor +import de.uzl.its.swat.symbolic.value.Value +import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.symbolic.value.reference.ObjectValue +import de.uzl.its.swat.symbolic.value.reference.lang.IntegerObjectValue import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import de.uzl.its.swat.thread.ThreadHandler import spock.lang.See /** - * G3 register-only-non-constant (Level L1). At the value-typed GETVALUE recovery, a String shadow that - * carries symbolic content (a variable/UF) IS heap-registered so it round-trips through untracked space - * (the whitelisted-pure-UF win), while a pure-constant String shadow is NOT registered — it is - * reconstructible from the observed concrete, so registering it would only grow the self-pinning heap - * leak (a String is its own weak-map key). G3 de-interning (bytecode) makes the registered reference - * sound; this spec pins the executor-side registration policy. See docs/heap-redesign-g3-design.md. + * G3 register-only-non-constant (Level L1). At the value-typed GETVALUE recovery, a de-interned value + * type (String or a cached boxed wrapper) whose shadow carries symbolic content (a variable/UF) IS + * heap-registered so it round-trips through untracked space (the whitelisted-pure-UF win), while a + * pure-constant shadow is NOT registered — it is reconstructible from the observed concrete, so + * registering it would only grow the heap. G3 de-interning (bytecode) makes the registered reference + * sound; this spec pins the executor-side registration policy for String (A1) and boxed (A2), and that + * mutable objects on the shared boxed/mutable recovery path keep unconditional registration. See + * docs/heap-redesign-g3-design.md. */ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { - /** Recover a value-typed object at ADDRESS_UNKNOWN: push the shadow, then drive GETVALUE_Object. */ - private void recover(StringValue shadow, Object obj) { + /** Recover an object at ADDRESS_UNKNOWN: push the shadow, then drive GETVALUE_Object. */ + private void recover(Value shadow, Object obj) { SymbolicInstructionVisitor visitor = ThreadHandler.getSymbolicVisitor(threadId) visitor.getStack().pushOperand(shadow) List instructions = new ArrayList<>() @@ -67,4 +72,49 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { then: "it is NOT registered - reconstructible from the observed concrete, so no leak" ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null } + + @See("docs/heap-redesign-g3-design.md") + def "G3-A2: a symbolic boxed shadow IS heap-registered (round-trip enabled)"() { + given: "a symbolic Integer shadow awaiting its address (formula carried by the inner IntValue)" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + Integer obj = 7 + IntValue inner = new IntValue(solverContext, obj) + inner.MAKE_SYMBOLIC() + IntegerObjectValue shadow = new IntegerObjectValue(solverContext, inner, ObjectValue.ADDRESS_UNKNOWN) + + when: "the value is recovered" + recover(shadow, obj) + + then: "it is registered, so an untracked round-trip can recover the symbolic boxed shadow" + ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null + } + + @See("docs/heap-redesign-g3-design.md") + def "G3-A2: a constant boxed shadow is NOT heap-registered (reconstructible; no leak)"() { + given: "a pure-constant Integer shadow awaiting its address" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + Integer obj = 7 + IntegerObjectValue shadow = + new IntegerObjectValue(solverContext, new IntValue(solverContext, obj), ObjectValue.ADDRESS_UNKNOWN) + + when: "the value is recovered" + recover(shadow, obj) + + then: "it is NOT registered - a constant boxed value is reconstructible from the observed concrete" + ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null + } + + @See("docs/heap-redesign-g3-design.md") + def "G3-A2: a mutable (non-value-type) object is ALWAYS registered (shared path unaffected)"() { + given: "a plain mutable object whose class is not de-interned" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + Object obj = new Object() + ObjectValue shadow = new ObjectValue(solverContext, ObjectValue.ADDRESS_UNKNOWN) + + when: "the value is recovered" + recover(shadow, obj) + + then: "register-only-non-constant must NOT touch mutable objects - sound identity key, must track" + ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null + } } diff --git a/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java new file mode 100644 index 0000000..613339f --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java @@ -0,0 +1,25 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 target for G3-A2 (boxed output-boundary de-interning): an instrumented method calls + * un-instrumented (java/lang) methods whose DECLARED return type is a boxed wrapper NOT matched by the + * existing valueOf(primitive) rewrites - Integer.valueOf(String) (category-1) and Long.valueOf(String) + * (category-2, the wide-local rebox path). Under the real agent these trigger the unbox+rebox de-intern + * wrap; the program must load, verify, and run to completion - a malformed wrap would VerifyError into a + * non-zero exit, which AgentRun asserts against. See docs/heap-redesign-g3-design.md (Level L2). + */ +public class BoxedReturnTarget { + + public static void main(String[] args) { + test(5); + } + + public static String test(@Symbolic int x) { + Integer i = Integer.valueOf(Integer.toString(x)); // Integer return -> category-1 unbox+rebox + Long l = Long.valueOf(Long.toString((long) x)); // Long return -> category-2 wide unbox+rebox + if (i.intValue() + l.longValue() > 0) { + return "pos"; + } + return "nonpos"; + } +} From 59ed40dc9eb806ad77e79e632479b3a8f08fc8f4 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 11:19:29 +0000 Subject: [PATCH 13/33] feat(heap): G3-B core - exact reference equality via provenance (refEquals -> root) De-interning (G3-A) gives value types fresh identities so the reference-keyed heap stays sound - but that fresh identity diverges from real JVM identity, so the prior mitigation modeled `==` by VALUE-equality (refEquals -> Objects.equals for de-interned classes) plus a conservative reference_semantic_change flag. That over-fired: a de-interned `==` (e.g. `s == "literal"`) was concretized AND flagged via BOTH symbolic-context-loss and reference-semantic-change -> spurious UNKNOWN, losing real SAFEs. G3-B models `==` by ORIGINAL identity instead, exactly: - Provenance (new): identity-keyed weak-key map de-interned-copy -> root(genuine canonical), collapsed at insert. Strong values keep a copy's canonical stable for the copy's lifetime (a fresh return's canonical is not independently reachable, so weak values could strand a live alias and flip a genuine `==` -> a GC-timing VIOLATION->SAFE). - UtilInstrumented.refEquals -> `shouldUseValueEquality(a,b) ? root(a)==root(b) : a==b` (reference on the originals, not Objects.equals). Two de-interned copies of the same interned literal / cached box / returned object share one canonical -> `==` matches the un-transformed program, exactly. - NoCacheMethodAdapter records provenance at every de-intern site: LDC (re-LDC the interned canonical after ), the six valueOf blocks (call the REAL valueOf via the raw delegate - NOT recursively rewritten - for the cached canonical; else valueOf(100)==valueOf(100) regresses true->false), and deInternReturn (restructured to keep the original in a local). - InvocationHandler IGNORED_INVOCATIONS += Util.shouldUseValueEquality + Provenance, so the stepped refEquals body fires no spurious context loss. Why empirically uniform (de-intern ALL, incl. literals): a String LITERAL can be the designated @Symbolic input (confirmed: `test("seed")` -> the literal is symbolic), so it must be de-interned (distinct identity, registered, recoverable; no collision with a same-value plain literal). Sound: root carries real JVM identity (`root(a)==root(b)` is exact reference-`==`); concretizing is sound (reference `==` is per-path-concrete + VIOLATION is concretely witnessed). Scope: `==`/`!=` exact; identityHashCode/IdentityHashMap on de-interned values in un-instrumented code is an accepted, documented residual. Converged after 2 design-audit rounds + a uniform-model final pass (3 auditors each) + 3 post-impl reviews. Tests: ProvenanceRefEqualsSpec (L0, refEquals+root incl. boxed-cache + collapse-at-insert), StringRefEqAgentSpec (L2: de-interned `==` fires NEITHER flag - the over-firing fix, end to end). 53 L0/L1 + 6 L2 green. Follow-up (G3-B2, separable cleanup; the core already fixes the `==` over-firing because refEquals no longer reaches Objects.equals): delete the now-spurious reference_semantic_change flag + userDeInterned + the ObjectsInvocation block, across Java + explorer (SVCompDriver) + Groovy tests. And (out of scope) table-drive the six valueOf blocks off the `Boxed` enum. Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g3-design.md | 150 ++++++++++++++++++ .../de/uzl/its/swat/common/Provenance.java | 63 ++++++++ .../uzl/its/swat/common/UtilInstrumented.java | 11 +- .../nocache/NoCacheMethodAdapter.java | 74 +++++++-- .../symbolic/invoke/InvocationHandler.java | 6 + .../common/ProvenanceRefEqualsSpec.groovy | 77 +++++++++ .../symbolic/heap/StringRefEqAgentSpec.groovy | 30 ++++ .../resources/targets/StringRefEqTarget.java | 22 +++ 8 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/StringRefEqTarget.java diff --git a/docs/heap-redesign-g3-design.md b/docs/heap-redesign-g3-design.md index 5d17803..4e38830 100644 --- a/docs/heap-redesign-g3-design.md +++ b/docs/heap-redesign-g3-design.md @@ -306,3 +306,153 @@ An adversarial review (verified against code) recommends **not doing G3 now**: String only (matches G2/G4 v1 scope). - **Testing altitude.** D-1/D-3 likely L2 (real instrumentation + real identities); D-2 is L3 (verdict with/without). What's feasible? + +--- + +# G3-B — Exact reference-equality via provenance (replaces the conservative flag) + +Phase G3-B. Design for review (after A1 `994e642` + A2 `791ce41`). Converged with the user before audit. + +## Problem +De-interning gives value-type objects fresh identities, which breaks reference-`==`. The current mitigation +(pre-G3) rewrites every `==`/`!=` to `UtilInstrumented.refEquals`, which uses **value-equality** for +de-interned classes, and a flag (`recordReferenceSemanticChange`) that downgrades VIOLATION->UNKNOWN when +value-equality might diverge from real reference-`==`. `UtilInstrumented` is instrumented, so the `==` path's +inner `Objects.equals` converges at `ObjectsInvocation`, which is where the flag lives. The flag is sound but: +1. **String-only** (`instanceof StringValue`) -> A2's de-interned **boxed** `==` divergence is unflagged + (e.g. `Integer.valueOf(200) == Integer.valueOf(200)`: real false, de-intern+value-eq true) -> a real + soundness gap. +2. **Over-fires** -> it flags comparisons that do NOT actually diverge (operands sharing the same original but + de-interned to distinct copies: `"x" == "x"`, a this-return `r == s`, and any `s == "literal"`), producing + spurious UNKNOWN and **losing SAFEs the user confirms cost us**. + +Root cause: `==` is **reference identity** (a concrete, per-path fact), but de-intern + value-equality models +it as a **value comparison**, which diverges exactly for equal-value / distinct-original-identity operands. + +## Design — model `==` by ORIGINAL identity (exact), drop the flag +1. **Stop de-interning constants.** `NoCacheMethodAdapter.visitLdcInsn` no longer de-interns String literals. + Literals are constants -> A1 `register-only-non-constant` never heap-registers them -> they cause no + reference-key collision -> they never needed distinct identities. Left interned, their `==` is real + reference identity (exact, no map). This removes the largest source of `==` divergence (`"x"=="x"`, + `s=="literal"`) for free and shrinks the provenance map to the genuinely-needed residual. +2. **Provenance map** for the remaining de-interned objects (non-constant un-instrumented returns - + this-returns etc.): a process-wide identity-keyed, weak-keyed map `copy -> root(original)` (guava + `MapMaker().weakKeys()`, the same primitive `JVMHeap` uses), populated at the de-intern step + (`NoCacheMethodAdapter` records `(copy, original)` when wrapping a return; `root` collapses chains so a + copy-of-a-copy resolves to the ultimate original). +3. **`refEquals`** (concrete branch decider): `shouldUseValueEquality(a,b) ? root(a) == root(b) : a == b` - + **reference on the originals**, NOT `Objects.equals`. Exact original `==`. +4. **Symbolic `==` becomes a per-path concrete constant.** Reference identity is not solver-controllable + (distinct allocations are never `==` regardless of input values; value comparison is `.equals`), so the + `refEquals` result is concretized. **Delete** `recordReferenceSemanticChange` and the `instanceof + StringValue` divergence block in `ObjectsInvocation`. Explicit `Objects.equals` / instance `.equals` stay + value-equality (correct - those are value comparisons and are de-intern-invariant). + +Result: `==` exact in every case (interned literal, this-return, distinct objects, alias, boxed cache) for +String AND boxed; no flag; no spurious UNKNOWN. + +## Why each case is now exact +| Operands | real `==` | provenance model | how | +|---|---|---|---| +| alias (`b = a`) | true | true | same object / same root | +| different values | false | false | distinct roots | +| two interned literals `"x"`,`"x"` | true | true | not de-interned -> same interned object | +| `s == "literal"` (input vs literal) | false | false | distinct roots (s not de-interned, literal interned) | +| `new String("x") == "x"` | false | false | distinct roots | +| this-return `s.toLowerCase() == s` | true | true | copy's root **is** s | +| boxed `valueOf(200) == valueOf(200)` | false | false | distinct roots | + +## Open questions for the auditors +- **Soundness:** does provenance + concretized `==` reproduce real reference-`==` in EVERY case above (and any + missed), for String AND boxed? Does concretizing lose any constraint the solver actually needs (claim: no - + identity is concrete/per-path)? Any path where a wrong verdict could result? +- **Literal-handling safety:** is leaving literals interned safe - does anything rely on the original LDC + de-intern (why was it there)? Interaction with `register-only-non-constant`, the heap, G2 concretize, G4 UFs? + Do literals ever need distinct identities for a reason we're missing? +- **Feasibility & cost:** the de-intern bytecode restructure to keep the original accessible (String: an extra + DUP; boxed: reuse the local); the map reachable consistently from concrete `refEquals` (raw object) and the + symbolic side (`shadow.concrete`); weak-key GC behavior; per-`==` lookup cost; the interaction with + `UtilInstrumented` being instrumented (do we still need that special-case once `refEquals` is reference-only + and concretized?). +- **Scope:** does the de-intern step record provenance for ALL de-interned returns (incl. constant returns it + can't distinguish at bytecode), and does that matter for map size / correctness? + +--- + +## G3-B REVISED (after round-1 of 3 design auditors + a characterization experiment) + +Round-1 outcome: the design is sound + buildable but the framing had two errors, one "blocker" was a mis-analysis, and a characterization experiment changed the mechanism. Revised design below; this is what round-2 reviews. + +### Experiment (settles the auditor contradiction) +Ran a symbolic `s`, `if (s == "abc")` under the real agent. Result: branch constraint = `(assert true)` (NOT a flippable value-equality constraint), AND `symbolicContextLoss=true` AND `referenceSemanticChange=true`. Conclusions: +- `==` is **concretized** today (the value-equality computed inside `refEquals`'s instrumented body is discarded; the call's boolean is concretized). The feasibility auditor was right; the "solver-controllable value-equality" framing was wrong. +- **Context-loss is a SECOND flag** firing in this area (because `refEquals(s,…)` concretizes a symbolic operand). Fixing only `referenceSemanticChange` would NOT recover the SAFE — context-loss would still fire. + +### Corrected mechanism — MODEL `refEquals` as a reference comparison (not "concretize") +G3-B must make `refEquals` a **modeled** reference comparison so that BOTH flags correctly do not fire and the result is exact: +- Route `refEquals` to a model that returns `root(a) == root(b)` as a **concrete identity boolean** for de-interned classes (`a == b` otherwise). Because the result depends only on object identity, not on `s`'s symbolic value, it must NOT trigger context-loss, and (provenance being exact) needs no `reference_semantic_change` downgrade. +- This supersedes the earlier "concretize the result" wording. The point is to model `==` as the (concrete, per-path) reference fact it is, sourced from `root`. + +### Soundness (corrected) — the round-1 "blocker" was a mis-analysis +The soundness auditor's claimed VIOLATION->SAFE (un-instrumented callee returns a JVM-shared object, SWAT de-interns it, `root(b)!=a` -> missed violation) does NOT hold: we record `root(copy) = the genuine pre-wrap returned object`, which carries the **real JVM identity**. So `root(a)==root(b)` reproduces real `==` exactly, including canonical-cache hits. The auditor assumed `root` points to a fresh copy; it points to the genuine original. **Hard requirement that makes this true:** record the genuine pre-wrap reference at EVERY de-intern site. + +Corrected justification (replaces "identity isn't solver-controllable"): concretizing `==` is sound because real reference-`==` is a **per-path concrete fact** and VIOLATION is **concretely witnessed** on the transformed program. Removing the discarded value-equality loses nothing real — a `==`-true path that only value-equality reached is infeasible in real Java (distinct objects are never `==` regardless of value). + +### Scope (decided): `==`/`!=` only; identity-hash is a documented residual (option A) +Provenance makes `==`/`!=` exact. `System.identityHashCode` / `IdentityHashMap` / identity-keyed logic in **un-instrumented** code on a de-interned value is an **irreducible, pre-existing, rare residual** (nocache has de-interned literals/boxed since before G3): reinjecting the original at the boundary would break the round-trip recovery (the de-interned identity must persist across the boundary for `getFromHeap`), and a redirect can't reach un-instrumented calls. Decision (user): document it; do NOT build a redirect or reinjection. The exactness claim is scoped to `==`/`!=`. + +### Implementation obligations (from all three round-1 auditors) +- **Atomic package:** stop-de-interning-literals (point 1) + model-refEquals-via-root (point 3) + delete-the-flag (point 4) must land together (the only consumer of `userDeInterned` is the flag block). Point 1 is **String-literal-only** (no boxed-literal analogue; boxed exactness comes from provenance). +- **Record provenance at EVERY de-intern site** with the genuine pre-wrap reference - LDC (if any de-intern remains there - but point 1 removes it), the 6 `valueOf` rewrites, and `deInternReturn`. A missed site breaks a `root` chain -> divergence. No single choke point today. +- **Map:** `MapMaker().weakKeys().weakValues()` (identity keys, like `JVMHeap`); `root(x)` returns `x` on a miss (collected or never-recorded) and collapses chains. Weak keys alone strongly pin the originals (leak); weak values + root-on-miss=self is leak-safe AND correct. +- **Bytecode:** restructure `deInternReturn` to keep the original across the wrap (String: extra DUP / a local; boxed: store the boxed original, not just the primitive), null-guard-correct, and tested with `checkClassAdapter=true` (sv-comp.cfg has it off). Compounds the 64KB method-size watch-item. +- **Deletions span 3 languages:** Java (`recordReferenceSemanticChange` + `userDeInterned` + the `ObjectsInvocation` block + the TraceDTO/SymbolicTrace field), the explorer (`SVCompDriver.py` VIOLATION->UNKNOWN downgrade + DTO parse), and Groovy tests (`TraceObservation`, `BaseSymbolicInstructionProcessorSpec`). Coordinate, or leave the explorer field inert with a comment - don't leave a silently-dead downgrade. +- **Leave alone:** `UtilInstrumented`'s instrumented status + the RefEquality skip-set (entangled with `liftClass` + the regress guard). +- **Re-verify A1** "a constant String is never `putToHeap`'d" under the no-literal-de-intern assumption (it's what makes leaving literals interned safe), and add a regression test that two occurrences of the same literal don't merge in the heap. + +### Round-2 questions +- Is "model `refEquals` as `root`-reference" the right mechanism, and does it actually avoid BOTH context-loss and `reference_semantic_change` (verify the executor doesn't flag a modeled reference comparison with a symbolic operand)? +- Is the corrected soundness argument airtight (genuine-pre-wrap-reference recording at all sites => `root` == real JVM identity => exact `==`; no VIOLATION->SAFE)? +- Is the atomic-package + record-at-all-sites + weakValues + 3-language-deletion plan complete and correctly sequenced? + +--- + +## G3-B round-2 outcome (all 3 auditors): String converged + sound; mechanism corrected; boxed fork + +- **Blocker RESOLVED.** Soundness auditor withdrew round-1 Finding C: recording `root(copy)=genuine pre-wrap object` => `root` carries real JVM identity => `root(a)==root(b)` is exact reference-`==` (incl. canonical hits). No VIOLATION->SAFE for String. +- **MECHANISM CORRECTED (decisive, feasibility auditor).** The design's "route `refEquals` in the invoke dispatch" is **DEAD CODE**: `UtilInstrumented` is force-instrumented, so the engine STEPS INTO `refEquals`'s body (the `getNextInst() instanceof INVOKEMETHOD_END` discriminator is false for instrumented callees) and never models the call -> `StaticInvocation` routing is never consulted. The two flags fire from INSIDE the stepped body: `symbolicContextLoss` from `Util.shouldUseValueEquality(s,..)` (un-instrumented `common/Util`, NOT in IGNORED, symbolic arg) and `referenceSemanticChange` from the inner `Objects.equals`. **Correct mechanism:** rewrite `refEquals`'s BODY to `shouldUseValueEquality(a,b) ? Provenance.root(a)==Provenance.root(b) : a==b`, and add `Provenance.root` + `Util.shouldUseValueEquality` to `IGNORED_INVOCATIONS` (so those inner calls concretize without firing context-loss). Keeps `UtilInstrumented` instrumented (no contradiction with leaving its status alone). Then `root(a)==root(b)` is an IF_ACMPEQ on concretized root objects -> a constant identity boolean -> no flags, exact. +- **HARD requirement:** collapse provenance chains at INSERTION (store the fully-resolved root at `record`), NOT lazy lookup -> eliminates the GC'd-intermediate-link VIOLATION->SAFE that `weakValues()`+lazy-walk would introduce. +- **BOXED FORK (real, new).** The Integer cache makes boxed `==` harder than String: `Integer.valueOf(100)==Integer.valueOf(100)` is TRUE in real Java (cache), and today's value-equality `refEquals` gets it right. But the existing `valueOf` de-intern turns both into distinct `new Integer(100)`, so `root`=self -> FALSE -> with the flag deleted that is a **false SAFE (regression)**. We CANNOT "stop de-interning boxed like literals" because `valueOf` can take a symbolic value (cache-range collisions), unlike always-constant String literals. Two sound options: + - **(X) Exact boxed:** at each `valueOf` de-intern site, also call the real `valueOf` to get the cached canonical and `record(freshBox, canonical)`. Then `root(a)==root(b)` is exact for boxed too (cache-hit -> same canonical -> TRUE; non-cache -> distinct -> FALSE). Cost: one extra `valueOf` call per de-intern site. Flag fully deleted; consistent with String. + - **(Z) Hybrid:** String exact via provenance (no String flag); boxed keeps value-equality + a conservative flag (the original A2-gap fix) -> sound, boxed `==`-divergence -> UNKNOWN. Keeps a flag for the rare boxed case. +- **Doc corrections:** the identityHashCode/IdentityHashMap/argument-identity residual is **unsound w.r.t. real Java** (accepted, no backstop) - not merely "rare/precision." + +--- + +## G3-B CORRECTION — literals DO turn symbolic; de-intern ALL (no "stop de-interning literals") + +Empirically confirmed (ran a `@Symbolic String s = "seed"` target under the agent): the literal `"seed"` +becomes a **symbolic String input** (`inputNames=[java/lang/String_0]`, a branch references it). So a literal +is NOT "always a constant" - it can be made symbolic (the designated input is a literal; more generally any +string may acquire a symbolic shadow). Therefore it needs a **distinct identity** so that (a) it is registered +and recoverable through untracked space, and (b) it does not collide with a same-valued *plain* literal that +shares the interned object. **=> "Stop de-interning literals" (REVISED point 1) is WITHDRAWN as unsound.** + +**Corrected model = UNIFORM (the user's original assumption): de-intern ALL value types + provenance everywhere.** +- Keep the existing de-intern at ALL sites (LDC literals + boxed `valueOf` + un-instrumented returns). No + special-casing. +- Record provenance `copy -> root(genuine canonical/original)` at EVERY de-intern site, collapsed-at-insert: + - **LDC literal:** the interned literal (the LDC value, on the stack before the wrap; DUP it). Two `"abc"` + literals -> same interned canonical -> `root` equal -> `==` true (exact). A literal-that-turns-symbolic -> + distinct de-interned identity, registered, recoverable; `==` vs a plain same-value literal -> both root to + the interned canonical -> true (exact, matches real JVM). + - **boxed `valueOf`:** the cached canonical (extra real `valueOf` call - the rewrite replaces valueOf before + it runs). Cache-hit -> same canonical -> true; non-cache -> distinct -> false (exact). + - **un-instrumented return:** the genuine pre-wrap returned object. +- `refEquals` body -> `shouldUseValueEquality(a,b) ? root(a)==root(b) : a==b`; `Provenance.root` + + `Util.shouldUseValueEquality` in IGNORED_INVOCATIONS (suppress context-loss); delete the flag. + +This is simpler (no literal special-casing, no "is it safe to stop de-interning literals" analysis, no +heap-isolation-for-interned-literals concern) and is exactly uniform across String + boxed: every value type is +de-interned and carries provenance to its canonical/original. Cost: a `record` call per de-intern site (incl. +per literal - bytecode/64KB watch), plus the extra `valueOf` at boxed sites. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java new file mode 100644 index 0000000..2c838c5 --- /dev/null +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java @@ -0,0 +1,63 @@ +package de.uzl.its.swat.common; + +import com.google.common.collect.MapMaker; + +import java.util.Map; + +/** + * G3-B provenance for reference equality of de-interned value types. + * + *

De-interning (G3) gives every produced value type a fresh identity so the reference-keyed shadow + * heap stays sound. That fresh identity diverges from the real JVM identity, which would break + * reference equality ({@code ==}). To model {@code ==} exactly, each de-interned copy records the + * genuine original ("canonical") object it was made from, and {@code UtilInstrumented.refEquals} + * compares {@link #root}s instead of the de-interned objects: two copies of the same interned literal + * (or the same cached box, or the same returned object) share one canonical, so they compare equal, + * exactly as the un-transformed program would. + * + *

The map is identity-keyed and weak-keyed (an entry lives only while its de-interned copy is + * reachable). Values are held strongly on purpose: a copy's canonical must stay alive and stable for + * as long as the copy can be {@code ==}-compared, otherwise {@link #root} could strand a live alias and + * flip a genuine {@code ==} to false. Entries whose copy has been collected are reclaimed as the map + * is subsequently written (guava weak-key maps clear stale entries on later writes, not eagerly under + * GC); because the de-intern sites that write this map are hot, the retained set stays bounded during + * an active run. Same primitive and reclamation behavior as the shadow heap ({@code JVMHeap}). + * + *

Chains are collapsed at insertion ({@link #record} stores the fully-resolved root), so {@link + * #root} is always a single lookup and never depends on an intermediate link surviving GC. + * + *

This class is excluded from instrumentation and listed in {@code IGNORED_INVOCATIONS}, so the + * symbolic executor never models or context-loss-flags its calls; {@link #root} returns a concrete + * object whose only use is the (concrete) reference comparison in {@code refEquals}. + */ +public final class Provenance { + + private Provenance() {} + + /** de-interned copy (weak, identity key) -> its canonical/original (strong value). */ + private static final Map ROOTS = new MapMaker().weakKeys().makeMap(); + + /** + * Record that {@code copy} (a freshly de-interned object) originates from {@code canonical} (the + * genuine pre-de-intern object). Stores the fully-resolved root of {@code canonical} so chains stay + * depth-1. No-op for nulls or a self-mapping. + */ + public static void record(Object copy, Object canonical) { + if (copy == null || canonical == null || copy == canonical) { + return; + } + ROOTS.put(copy, root(canonical)); + } + + /** + * The original identity {@code x} stands for: the recorded canonical, or {@code x} itself when + * {@code x} was never recorded (e.g. a non-de-interned object) or its entry has been collected. + */ + public static Object root(Object x) { + if (x == null) { + return null; + } + Object r = ROOTS.get(x); + return r != null ? r : x; + } +} diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java index e288bec..b8009db 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java @@ -5,7 +5,6 @@ import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; -import java.util.Objects; @SuppressWarnings("unused") public class UtilInstrumented { @@ -24,13 +23,17 @@ public static void liftClass(Object param, String paramCnameDot, String methodNa } } /** - * Conditionally uses Objects.equals() for de-interned classes, - * otherwise uses reference equality. + * Models reference equality ({@code ==}) for de-interned value types by comparing the ORIGINAL + * identities (G3-B). De-interning gave {@code a}/{@code b} fresh identities that diverge from the + * real JVM; comparing {@link Provenance#root}s (the canonical object each was de-interned from) + * reproduces the un-transformed program's {@code ==}: two copies of the same interned literal / + * cached box / returned object share one canonical and so compare equal. Non-de-interned classes + * keep plain reference equality. */ @SuppressWarnings("unused") public static boolean refEquals(Object a, Object b) { if (Util.shouldUseValueEquality(a, b)) { - return Objects.equals(a, b); + return Provenance.root(a) == Provenance.root(b); } return a == b; } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java index 5fc0318..c771e34 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java @@ -24,13 +24,43 @@ public void visitLdcInsn(Object value) { mv.visitLdcInsn(value); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/String", "", "(Ljava/lang/String;)V", false); - //NoCacheTransformer.getPrintBox() - // .addMsg("Replacing LDC with new String"); + // G3-B: record provenance (de-interned copy -> the interned literal canonical). The + // interned literal is a compile-time constant, so re-LDC'ing it pushes the SAME canonical + // object that every other occurrence of this literal shares; never null (no guard needed). + mv.visitInsn(Opcodes.DUP); + mv.visitLdcInsn(value); + emitProvenanceRecord(); } else { mv.visitLdcInsn(value); } } + /** + * Emit {@code Provenance.record(copy, canonical)} consuming the top two stack entries + * {@code [..., copy, canonical]} and leaving {@code [...]}. Callers DUP the copy first so the copy + * survives. Emitted via the raw delegate so it is not re-processed by this adapter. + */ + private void emitProvenanceRecord() { + mv.visitMethodInsn(Opcodes.INVOKESTATIC, "de/uzl/its/swat/common/Provenance", "record", + "(Ljava/lang/Object;Ljava/lang/Object;)V", false); + } + + /** + * After a de-interned boxed copy is on top of the stack and its primitive is in {@code primLocal}, + * record provenance to the box's REAL cached canonical. The valueOf rewrite replaced the original + * call before it ran, so the canonical is not on the stack - we materialize it by calling the + * genuine {@code valueOf} via the raw delegate (so it is NOT itself re-rewritten). Without this, + * {@code root} of two cached boxes (e.g. valueOf(100)) would be self -> distinct -> {@code ==} + * false, regressing real Java (cache hit -> true). Stack: {@code [copy] -> [copy]}. + */ + private void recordBoxedProvenance(String owner, String valueOfDescriptor, int primLoadOpcode, + int primLocal) { + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(primLoadOpcode, primLocal); + mv.visitMethodInsn(Opcodes.INVOKESTATIC, owner, "valueOf", valueOfDescriptor, false); + emitProvenanceRecord(); + } + // Intercept method calls to disable interning and caching. @Override public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { @@ -55,6 +85,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Integer", "", "(I)V", false); + recordBoxedProvenance("java/lang/Integer", "(I)Ljava/lang/Integer;", Opcodes.ILOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Integer.valueOf with new Integer"); return; @@ -70,6 +101,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.LLOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Long", "", "(J)V", false); + recordBoxedProvenance("java/lang/Long", "(J)Ljava/lang/Long;", Opcodes.LLOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Long.valueOf with new Long"); return; @@ -85,6 +117,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Short", "", "(S)V", false); + recordBoxedProvenance("java/lang/Short", "(S)Ljava/lang/Short;", Opcodes.ILOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Short.valueOf with new Short"); return; @@ -100,6 +133,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Byte", "", "(B)V", false); + recordBoxedProvenance("java/lang/Byte", "(B)Ljava/lang/Byte;", Opcodes.ILOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Byte.valueOf with new Byte"); return; @@ -115,6 +149,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Character", "", "(C)V", false); + recordBoxedProvenance("java/lang/Character", "(C)Ljava/lang/Character;", Opcodes.ILOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Character.valueOf with new Character"); return; @@ -130,6 +165,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Boolean", "", "(Z)V", false); + recordBoxedProvenance("java/lang/Boolean", "(Z)Ljava/lang/Boolean;", Opcodes.ILOAD, localVarIndex); NoCacheTransformer.getPrintBox() .addMsg("Replacing Boolean.valueOf with new Boolean"); return; @@ -157,35 +193,47 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri */ private void deInternReturn(Type returnType) { if ("Ljava/lang/String;".equals(returnType.getDescriptor())) { - // String has a copy constructor: reorder the on-stack reference into new String(ref). + // String has a copy constructor. Keep the original in a local so we can both build + // new String(original) AND record provenance (copy -> original) afterward. Label done = new Label(); mv.visitInsn(Opcodes.DUP); mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap + int origLocal = newLocal(Type.getObjectType("java/lang/String")); + mv.visitVarInsn(Opcodes.ASTORE, origLocal); mv.visitTypeInsn(Opcodes.NEW, "java/lang/String"); - mv.visitInsn(Opcodes.DUP_X1); - mv.visitInsn(Opcodes.SWAP); + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(Opcodes.ALOAD, origLocal); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/String", "", - "(Ljava/lang/String;)V", false); + "(Ljava/lang/String;)V", false); // [copy] + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(Opcodes.ALOAD, origLocal); + emitProvenanceRecord(); // record(copy, original) -> [copy] mv.visitLabel(done); return; } Boxed boxed = Boxed.forDescriptor(returnType.getDescriptor()); if (boxed != null) { - // Boxed wrappers have no copy constructor: unbox to the primitive, then rebox into a fresh - // instance (same shape as the valueOf de-interning above). A local holds the primitive - // across the NEW (required for the category-2 long; harmless for the others). + // Boxed wrappers have no copy constructor: keep the original boxed in a local, unbox it to + // the primitive, rebox into a fresh instance, then record provenance (copy -> original). + // The primitive local across the NEW is required for the category-2 long. Label done = new Label(); mv.visitInsn(Opcodes.DUP); mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap + int origLocal = newLocal(Type.getObjectType(boxed.owner)); + mv.visitVarInsn(Opcodes.ASTORE, origLocal); + mv.visitVarInsn(Opcodes.ALOAD, origLocal); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, boxed.owner, boxed.unboxMethod, boxed.unboxDescriptor, false); - int local = newLocal(boxed.primType); - mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ISTORE), local); + int primLocal = newLocal(boxed.primType); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ISTORE), primLocal); mv.visitTypeInsn(Opcodes.NEW, boxed.owner); mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ILOAD), local); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ILOAD), primLocal); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, boxed.owner, "", - boxed.ctorDescriptor, false); + boxed.ctorDescriptor, false); // [copy] + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(Opcodes.ALOAD, origLocal); + emitProvenanceRecord(); // record(copy, original) -> [copy] mv.visitLabel(done); } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 55c8adf..77d9c8c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -34,6 +34,12 @@ public class InvocationHandler { "java/io/PrintStream/println", "de/uzl/its/swat/instrument/Intrinsics", "de/uzl/its/swat/common/UtilInstrumented", + // G3-B: refEquals's body (stepped, since UtilInstrumented is instrumented) + // calls these with a possibly-symbolic operand; ignore them so a reference + // comparison does not record spurious context loss. Both are pure/identity + // and their concretized results are all refEquals needs. + "de/uzl/its/swat/common/Util/shouldUseValueEquality", + "de/uzl/its/swat/common/Provenance", "de/uzl/its/swat/witness/Witness", "de/uzl/its/swat/instrument/svcomp/Verifier", "java/io/PrintStream", diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy new file mode 100644 index 0000000..8fda842 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy @@ -0,0 +1,77 @@ +package de.uzl.its.swat.common + +import spock.lang.See +import spock.lang.Specification + +/** + * G3-B unit test: {@link UtilInstrumented#refEquals} models reference equality by ORIGINAL identity via + * the {@link Provenance} map. Two de-interned copies that root to the same canonical compare equal; + * distinct canonicals compare unequal; non-de-interned classes fall back to plain reference equality. + * This pins the executor-independent core of G3-B (the de-intern bytecode that populates the map is + * exercised at L2). See docs/heap-redesign-g3-design.md. + */ +class ProvenanceRefEqualsSpec extends Specification { + + @See("docs/heap-redesign-g3-design.md") + def "de-interned Strings rooting to the same interned canonical compare equal"() { + given: "two distinct de-interned copies of the same literal, both rooted to the interned canonical" + String canonical = "g3b-abc".intern() + String a = new String("g3b-abc") + String b = new String("g3b-abc") + Provenance.record(a, canonical) + Provenance.record(b, canonical) + + expect: "they are distinct objects (so a==b reference would be false)" + !a.is(b) + and: "but refEquals compares roots -> same canonical -> equal (matches real interned ==)" + UtilInstrumented.refEquals(a, b) + } + + @See("docs/heap-redesign-g3-design.md") + def "a de-interned copy vs a same-valued object with a different root compares unequal"() { + given: "a rooted to the interned canonical; b has no provenance entry (root(b)=b)" + String a = new String("g3b-x") + Provenance.record(a, "g3b-x".intern()) + String b = new String("g3b-x") + + expect: "distinct roots -> unequal (matches real new String(\"x\") == \"x\" -> false)" + !UtilInstrumented.refEquals(a, b) + } + + @See("docs/heap-redesign-g3-design.md") + def "boxed copies rooting to the cached canonical compare equal (cache range)"() { + given: + Integer a = new Integer(100) + Integer b = new Integer(100) + Provenance.record(a, Integer.valueOf(100)) + Provenance.record(b, Integer.valueOf(100)) + + expect: "both root to the one cached Integer(100) -> equal (matches real valueOf(100)==valueOf(100))" + !a.is(b) + UtilInstrumented.refEquals(a, b) + } + + @See("docs/heap-redesign-g3-design.md") + def "non-de-interned classes use plain reference equality"() { + given: + Object a = new Object() + Object b = new Object() + + expect: "Object is not a de-interned class -> refEquals falls back to a==b" + !UtilInstrumented.refEquals(a, b) + UtilInstrumented.refEquals(a, a) + } + + @See("docs/heap-redesign-g3-design.md") + def "root collapses chains at insert"() { + given: "c rooted to b, b rooted to canonical -> root(c) must resolve to canonical" + String canonical = "g3b-chain".intern() + String b = new String("g3b-chain") + Provenance.record(b, canonical) + String c = new String("g3b-chain") + Provenance.record(c, b) + + expect: + Provenance.root(c).is(canonical) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy new file mode 100644 index 0000000..76852fb --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy @@ -0,0 +1,30 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G3-B end-to-end acceptance (Level L2): a de-interned {@code ==} (symbolic String vs a literal) is + * modeled via Provenance.root as a reference comparison, and must fire NEITHER soundness flag. This + * pins the round-2 mechanism correction: the stepped {@code refEquals} body's {@code root}/ + * {@code shouldUseValueEquality} calls are IGNORED (no context loss), and {@code refEquals} no longer + * routes through {@code Objects.equals} (no reference-semantic-change). The old value-equality + * {@code refEquals} fired both flags here. Mirrors PureFunctionUFAgentSpec. + */ +class StringRefEqAgentSpec extends Specification { + + @See("docs/heap-redesign-g3-design.md") + def "G3-B (L2): de-interned == is modeled by provenance root and fires neither soundness flag"() { + when: + TraceObservation obs = AgentRun.run("targets/StringRefEqTarget.java", "StringRefEqTarget") + + then: "the symbolic input is designated" + obs.inputNames.any { it.startsWith("java/lang/String") } + + and: "modeling == via root fires NEITHER flag (the over-firing fix; old refEquals fired both)" + !obs.symbolicContextLoss + !obs.referenceSemanticChange + } +} diff --git a/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java b/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java new file mode 100644 index 0000000..96e047e --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java @@ -0,0 +1,22 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 acceptance for G3-B: a symbolic String compared to a literal with {@code ==} (rewritten to + * UtilInstrumented.refEquals, now root-based). The de-interned input and the literal have different + * roots, so {@code ==} is reference-false - and, crucially, modeling it through Provenance.root must + * fire NEITHER soundness flag (no symbolic-context-loss from the stepped refEquals body, no + * reference-semantic-change), unlike the old value-equality refEquals which fired both. See G3-B. + */ +public class StringRefEqTarget { + + public static void main(String[] args) { + test("seed"); + } + + public static String test(@Symbolic String s) { + if (s == "MATCH") { // de-interned ==: root(s) vs interned "MATCH" -> distinct -> false, no flags + return "eq"; + } + return "ne"; + } +} From 86fdb06391e7dc22b40c13cc964530ac84fe41c1 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 12:54:37 +0000 Subject: [PATCH 14/33] refactor(heap): G3-B2 - delete the superseded reference_semantic_change flag + userDeInterned The G3-B core (59ed40d) made `==` exact via provenance and routed refEquals through root() instead of Objects.equals, so the `==` path no longer reaches the reference_semantic_change flag. Its only remaining firing site was explicit Objects.equals(deInterned,...) - which is value-equality and de-intern-invariant, so the flag there was spurious (only ever downgraded VIOLATION->UNKNOWN unnecessarily). This removes the dead conservatism across all three languages. Java: drop the ObjectsInvocation `instanceof StringValue` block (+ its now-unused imports), StringValue.userDeInterned (field + invokeInit assignment), SymbolicTraceHandler.record/ isReferenceSemanticChange, the SymbolicTrace field (lombok accessors go with it), and the TraceDTO field/ctor-param/assignment + the DTOBuilder ctor arg. symbolicContextLoss and symbolicPrecisionLoss are preserved (context-loss is still the load-bearing soundness flag). Explorer: delete the full chain - DataTransferObjects DTO field, ConstraintController parse + kwarg, ConstraintService param/docstring/add_trace arg, Database param + record call, Tree field + record_reference_semantic_change, and the SVCompDriver VIOLATION->UNKNOWN downgrade. pydantic v2 default extra='ignore' makes the DTO drop order-independent vs the Java side. Also targets/.../analysis/failures.py - the now-dead 'Found reference semantic change' log-grep stat category. Tests: TraceObservation field+parse, the executeBoundaryRecovery result-map key, and StringRefEqAgentSpec drops its !referenceSemanticChange assertion (flag gone) while keeping !symbolicContextLoss - which still validates the round-2 mechanism (the stepped refEquals body's IGNORED Provenance.root/shouldUseValueEquality suppress context loss). Sound: removing a VIOLATION->UNKNOWN downgrade can only reduce conservatism, never create a wrong verdict; the `==` path is now exact and the explicit-Objects.equals path never diverged. Converged via a 3-auditor proposal review (soundness / explorer-chain / test-impact+completeness; the completeness sweep added the failures.py + unused-import items). 53 L0/L1 + 6 L2 green; explorer verified by py_compile + chain-arity trace (no runnable explorer test in this env). Co-Authored-By: Claude Opus 4.8 --- docs/heap-redesign-g3-design.md | 78 +++++++++++++++++++ .../invoke/java/util/ObjectsInvocation.java | 17 ---- .../its/swat/symbolic/trace/DTOBuilder.java | 2 +- .../swat/symbolic/trace/SymbolicTrace.java | 3 - .../symbolic/trace/SymbolicTraceHandler.java | 21 ----- .../its/swat/symbolic/trace/dto/TraceDTO.java | 6 +- .../value/reference/lang/StringValue.java | 13 ---- .../symbolic/heap/StringRefEqAgentSpec.groovy | 3 +- ...aseSymbolicInstructionProcessorSpec.groovy | 7 +- .../testsupport/agent/TraceObservation.groovy | 2 - .../constraint/ConstraintController.py | 4 +- .../constraint/ConstraintService.py | 6 +- .../data/BinaryExecutionTree/Tree.py | 5 -- symbolic-explorer/data/Database.py | 4 +- symbolic-explorer/driver/SVCompDriver.py | 4 - .../parse/DataTransferObjects.py | 1 - .../sv-comp/scripts/lib/analysis/failures.py | 5 -- 17 files changed, 88 insertions(+), 93 deletions(-) diff --git a/docs/heap-redesign-g3-design.md b/docs/heap-redesign-g3-design.md index 4e38830..54de38f 100644 --- a/docs/heap-redesign-g3-design.md +++ b/docs/heap-redesign-g3-design.md @@ -456,3 +456,81 @@ This is simpler (no literal special-casing, no "is it safe to stop de-interning heap-isolation-for-interned-literals concern) and is exactly uniform across String + boxed: every value type is de-interned and carries provenance to its canonical/original. Cost: a `record` call per de-intern site (incl. per literal - bytecode/64KB watch), plus the extra `valueOf` at boxed sites. + +--- + +# G3-B2 — Delete the superseded reference_semantic_change flag (proposal for audit) + +The G3-B core (committed 59ed40d) makes `==` exact via provenance, and `refEquals` no longer routes +through `Objects.equals`, so the `==` path can no longer reach the `reference_semantic_change` flag. The +flag now fires ONLY on explicit user `Objects.equals(deInternedString, ...)` in `ObjectsInvocation` - +and there it is **spurious**: `Objects.equals` is value-equality (`a==b || a.equals(b)`; the `.equals` +dominates), which is de-intern-invariant, so de-interning never changes its outcome. So the flag (and +its `userDeInterned` marker) is now dead weight that only ever downgrades VIOLATION->UNKNOWN +unnecessarily. G3-B2 removes it. Soundness: removing a conservative downgrade is sound IFF the case it +guarded is now handled exactly (it is - `==` via provenance) or never diverged (explicit Objects.equals). + +## Proposed deletions (complete map, current line numbers) + +**Java (symbolic-executor):** +1. `invoke/java/util/ObjectsInvocation.java:42-49` - delete the `instanceof StringValue` block that calls + `recordReferenceSemanticChange`. invokeEquals keeps: arg check, NULL guard, `a.invokeMethod("equals", + ..., [b])`. KEEP the ObjectsInvocation class (explicit Objects.equals stays value-equality - correct). +2. `value/reference/lang/StringValue.java:40` (`userDeInterned` field + its `@Getter`) + `:225` + (`this.userDeInterned = true` in invokeInit). Update the invokeInit comment (no longer sets a flag). + `isUserDeInterned()`'s only caller was #1. +3. `trace/SymbolicTraceHandler.java:216-219` (`recordReferenceSemanticChange()`) + `:237-239` + (`isReferenceSemanticChange()`). Only callers: #1 and #6. +4. `trace/SymbolicTrace.java:29` - the `referenceSemanticChange` field (+ lombok-generated get/set: + `setReferenceSemanticChange` was used only by #3; `isReferenceSemanticChange` by #3/#6). +5. `trace/dto/TraceDTO.java:20` (field), `:22` (ctor param), `:28` (assignment). Update the ctor. +6. `trace/DTOBuilder.java:111` - drop the `symbolicTrace.isReferenceSemanticChange()` argument from the + TraceDTO ctor call. + +**Explorer (symbolic-explorer, Python) - full chain delete (no silently-dead downgrade left behind):** +7. `driver/SVCompDriver.py:316-318` - the `VIOLATION -> UNKNOWN` downgrade gated on + `...reference_semantic_change`. This is the actual verdict effect. +8. `parse/DataTransferObjects.py:35` - the `referenceSemanticChange: bool = False` DTO field. +9. `constraint/ConstraintController.py:43,54` - parse `request.referenceSemanticChange` + pass it on. +10. `constraint/ConstraintService.py:26,42,54` - the `reference_semantic_change` param + docstring + the + `Database.add_trace(...)` argument. +11. `data/Database.py:117,129` - the `reference_semantic_change` param + the + `tree[...].record_reference_semantic_change()` call. +12. `data/BinaryExecutionTree/Tree.py:36` (field init) + `:71-73` (`record_reference_semantic_change`). + +**Groovy tests:** +13. `testsupport/agent/TraceObservation.groovy:14` (field) + `:22` (parse). Once the Java DTO drops the + field, the JSON no longer carries it. +14. `processor/BaseSymbolicInstructionProcessorSpec.groovy:296` (doc) + `:329` + (`referenceSemanticChange: traceHandler.isReferenceSemanticChange()` in the executeBoundaryRecovery + result map). Verify no spec reads `result.referenceSemanticChange`. +15. `heap/StringRefEqAgentSpec.groovy:28` - drop the `!obs.referenceSemanticChange` assertion (the flag no + longer exists); KEEP `!obs.symbolicContextLoss` (context-loss is NOT deleted and still validates the + round-2 IGNORED suppression). + +## Open questions for the audit +- **Completeness:** are there any references the grep missed (reflective/string-keyed access, the + explorer's JSON contract, other tests reading `referenceSemanticChange`/`userDeInterned`)? +- **Soundness:** confirm deleting the flag cannot turn a real VIOLATION into SAFE: the `==` path is now + exact (provenance), and explicit `Objects.equals` never diverged under de-interning. Any third path? +- **Explorer correctness (untestable here - no runnable explorer test in this env):** does removing the + chain (param threading through Controller/Service/Database/Tree + the SVCompDriver downgrade) leave the + remaining call sites consistent (arity, kwargs)? Is full deletion safer than leaving it inert+commented? +- **Test impact:** does removing `referenceSemanticChange` from the executeBoundaryRecovery result map + break any L1 spec? Does the JSON-field removal break TraceObservation parsing (it uses `.path(...)` + which tolerates a missing field -> false, so order matters: ok)? + +## G3-B2 audit outcome — CONVERGED (3 auditors): sound, verdict-safe, green; 2 additions + +- **Soundness CONFIRMED (all 3).** After G3-B core, `refEquals` is `root(a)==root(b)` (no `Objects.equals`), so the `==` path never reaches `ObjectsInvocation`; the flag's sole remaining firing site is explicit `Objects.equals(deInterned,...)`, which is value-equality (de-intern-invariant) and so spurious. No third firing path. The deleted `SVCompDriver` block is a VIOLATION->UNKNOWN downgrade (never creates a verdict), so removal can only reduce conservatism -> cannot turn a real VIOLATION into SAFE. Sound. +- **Java #1-#6 complete + dangling-free.** Lombok: `SymbolicTrace` is class-level `@Getter@Setter`, so removing the field removes both accessors cleanly. `new TraceDTO(...)` has EXACTLY ONE call site (DTOBuilder:111) -> arity change consistent. +- **Explorer #7-#12: full delete, complete + verdict-safe.** Grep surface == the 12 sites; trailing-param/kwarg/single-call drops, no arity skew. pydantic v2 default `extra='ignore'` -> the DTO-field removal is tolerant of BOTH sequencings (Java-first or Python-first) -> no cross-language ordering hazard. Full-delete recommended over inert (inert leaves a permanently-False downgrade = the silently-dead code we want gone). +- **Groovy #13-#15: green.** Only `StringRefEqAgentSpec` asserts the flag; no spec reads `result.referenceSemanticChange` (the 5 executeBoundaryRecovery callers read only `.contextLoss`/`.recovered`); `TraceObservation.parse` uses `.path(...)` (tolerates missing). The `StringRefEqAgentSpec` reduction (keep `!symbolicContextLoss`) still validates the round-2 mechanism (context-loss is the load-bearing flag). + +### ADDITIONS to the deletion list (from the audit) +- **#1b — drop 3 now-unused imports in `ObjectsInvocation.java`** after removing the block: `StringValue` (:13), `ThreadHandler` (:14), and the static `currentThread` (:17). (`SymbolicTraceHandler` import stays — it's the `invokeStaticMethod` param type.) +- **#16 — `targets/sv-comp/scripts/lib/analysis/failures.py:74,102,127`** (MISSED by the original map): the `'reference_semantic_change'` stat bucket + the `'Found reference semantic change'` log-grep (that exact string is emitted only by the deleted `SVCompDriver:317` log) + the print category. Delete these or the analysis category goes permanently dead (the "no silently-dead reference" principle applies here too). + +### EXPLICIT GUARD +- **Preserve `symbolicPrecisionLoss`** — it sits adjacent to `referenceSemanticChange` in the `TraceDTO` ctor (`TraceDTO.java:18,22,27`) and is NOT part of this deletion. The reduced ctor keeps `symbolicContextLoss` + `symbolicPrecisionLoss`, drops only `referenceSemanticChange`. +- Cosmetic: reword `StringRefEqAgentSpec`'s "old refEquals fired both" comment (only one flag remains). diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/java/util/ObjectsInvocation.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/java/util/ObjectsInvocation.java index 2f6cf86..a8a60ac 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/java/util/ObjectsInvocation.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/java/util/ObjectsInvocation.java @@ -10,12 +10,8 @@ import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.CharValue; import de.uzl.its.swat.symbolic.value.reference.ObjectValue; import de.uzl.its.swat.symbolic.value.reference.lang.CharacterObjectValue; -import de.uzl.its.swat.symbolic.value.reference.lang.StringValue; -import de.uzl.its.swat.thread.ThreadHandler; import org.objectweb.asm.Type; -import static java.lang.Thread.currentThread; - public class ObjectsInvocation { public static Value invokeStaticMethod( @@ -35,19 +31,6 @@ public class ObjectsInvocation { ObjectValue a = args[0].asObjectValue(); ObjectValue b = args[1].asObjectValue(); - // Check for user-de-interned strings with different addresses. - // This detects when refEquals (which calls Objects.equals) compares strings - // that were explicitly created with new String() in user code. - // In such cases, reference equality semantics may differ from what the user intended. - if (a instanceof StringValue s1 && b instanceof StringValue s2) { - if ((s1.isUserDeInterned() || s2.isUserDeInterned()) - && s1.getAddress() != s2.getAddress()) { - // Record that reference equality semantics may have changed - ThreadHandler.getSymbolicTraceHandler(currentThread().getId()) - .recordReferenceSemanticChange(); - } - } - if(a.getAddress() == ObjectValue.ADDRESS_NULL || b.getAddress() == ObjectValue.ADDRESS_NULL) { return PlaceHolder.instance; } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java index c09d3be..35caaa9 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java @@ -108,7 +108,7 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre trace.add(new BranchDTO(se.getIid(), se.getInst())); } } - return new TraceDTO(inputs, trace, ufs, symbolicTrace.isSymbolicContextLoss(), symbolicPrecisionLoss, symbolicTrace.isReferenceSemanticChange()); + return new TraceDTO(inputs, trace, ufs, symbolicTrace.isSymbolicContextLoss(), symbolicPrecisionLoss); } /** diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTrace.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTrace.java index 4e980f4..1ee960c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTrace.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTrace.java @@ -25,9 +25,6 @@ class SymbolicTrace { // If true, an invocation occurred that was not instrumented but received symbolic arguments. private boolean symbolicContextLoss = false; - // If true, reference equality semantics may have changed due to comparing user-de-interned strings. - private boolean referenceSemanticChange = false; - /** Creates a new SymbolicTrace. */ public SymbolicTrace() { this.inputs = new ArrayList<>(); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java index 6e0c691..c491e75 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/SymbolicTraceHandler.java @@ -207,17 +207,6 @@ public void recordSymbolicContextLoss() { symbolicTrace.setSymbolicContextLoss(true); } - /** - * Records that reference equality semantics may have changed. This happens when Objects.equals - * is called (via refEquals transformation) on strings where at least one was explicitly created - * with new String() in user code. In such cases, the original reference equality check would - * return false for different objects, but value equality returns true for equal content. - */ - public void recordReferenceSemanticChange() { - logger.warn("Reference semantic change detected: user-de-interned strings compared via Objects.equals"); - symbolicTrace.setReferenceSemanticChange(true); - } - /** * Whether a symbolic context loss was recorded on this trace. Read-only accessor for the * package-private trace flag; used by tests to observe soundness without parsing the TraceDTO. @@ -227,14 +216,4 @@ public void recordReferenceSemanticChange() { public boolean isSymbolicContextLoss() { return symbolicTrace.isSymbolicContextLoss(); } - - /** - * Whether a reference-semantic change was recorded on this trace. Read-only accessor for the - * package-private trace flag; used by tests to observe soundness without parsing the TraceDTO. - * - * @return true if a reference-semantic change occurred. - */ - public boolean isReferenceSemanticChange() { - return symbolicTrace.isReferenceSemanticChange(); - } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/TraceDTO.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/TraceDTO.java index 2f8b394..78a343f 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/TraceDTO.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/TraceDTO.java @@ -16,17 +16,13 @@ public class TraceDTO { private boolean symbolicContextLoss = false; @SuppressWarnings("unused") private boolean symbolicPrecisionLoss = false; - @SuppressWarnings("unused") - private boolean referenceSemanticChange = false; - public TraceDTO(ArrayList inputs, ArrayList trace, ArrayList ufs, boolean symbolicContextLoss, boolean symbolicPrecisionLoss, boolean referenceSemanticChange) { + public TraceDTO(ArrayList inputs, ArrayList trace, ArrayList ufs, boolean symbolicContextLoss, boolean symbolicPrecisionLoss) { this.trace = trace; this.inputs = inputs; this.ufs = ufs; this.symbolicContextLoss = symbolicContextLoss; this.symbolicPrecisionLoss = symbolicPrecisionLoss; - this.referenceSemanticChange = referenceSemanticChange; - } @SuppressWarnings("unused") diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java index 327a6c8..2b0d17c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/reference/lang/StringValue.java @@ -30,15 +30,6 @@ public class StringValue extends ObjectValue { static final int REPLACE_COUNT = 10; private StringFormulaManager smgr; - /** - * Flag indicating this string was explicitly created via new String() in user code, - * as opposed to being de-interned by SWAT's NoCacheMethodAdapter. - * When true, reference equality comparisons on this string may have different - * semantics than the original code intended. - */ - @Getter - private boolean userDeInterned = false; - public StringValue(SolverContext context, String concrete, int address) { super(context, address); this.smgr = context.getFormulaManager().getStringFormulaManager(); @@ -219,10 +210,6 @@ public BooleanFormula getBounds(boolean upper) { this.concrete = s.concrete; this.formula = s.formula; } - // Mark this string as de-interned: it was created via a `new String(...)` constructor, which - // gives it a fresh identity. This covers both user `new String()` and SWAT's own de-interning - // (NoCacheMethodAdapter: LDC literals and G3 output-boundary return wrapping). - this.userDeInterned = true; return VoidValue.instance; } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy index 76852fb..ce0086c 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy @@ -23,8 +23,7 @@ class StringRefEqAgentSpec extends Specification { then: "the symbolic input is designated" obs.inputNames.any { it.startsWith("java/lang/String") } - and: "modeling == via root fires NEITHER flag (the over-firing fix; old refEquals fired both)" + and: "modeling == via root suppresses context-loss (IGNORED Provenance.root + shouldUseValueEquality)" !obs.symbolicContextLoss - !obs.referenceSemanticChange } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy index ea51ee6..0f7ac86 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/processor/BaseSymbolicInstructionProcessorSpec.groovy @@ -293,7 +293,7 @@ abstract class BaseSymbolicInstructionProcessorSpec extends Specification { * on a value-type {@code getConcrete()} (a StringValue returns its String, so registration keys on * that String); a plain ObjectValue receiver would key on its address and never recover. * - * @return a map [recovered: Value, contextLoss: boolean, referenceSemanticChange: boolean] + * @return a map [recovered: Value, contextLoss: boolean] */ def executeBoundaryRecovery(ObjectValue receiver, String owner, String name, String desc, Object resultObject) { @@ -324,9 +324,8 @@ abstract class BaseSymbolicInstructionProcessorSpec extends Specification { def traceHandler = ThreadHandler.getSymbolicTraceHandler(threadId) return [ - recovered : visitor.getStack().getActiveFrame().peek(), - contextLoss : traceHandler.isSymbolicContextLoss(), - referenceSemanticChange: traceHandler.isReferenceSemanticChange() + recovered : visitor.getStack().getActiveFrame().peek(), + contextLoss: traceHandler.isSymbolicContextLoss() ] } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy index 46f282f..90f3203 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/testsupport/agent/TraceObservation.groovy @@ -11,7 +11,6 @@ class TraceObservation { boolean symbolicContextLoss boolean symbolicPrecisionLoss - boolean referenceSemanticChange List inputNames = [] List branchConstraints = [] @@ -19,7 +18,6 @@ class TraceObservation { TraceObservation o = new TraceObservation() o.symbolicContextLoss = root.path("symbolicContextLoss").asBoolean() o.symbolicPrecisionLoss = root.path("symbolicPrecisionLoss").asBoolean() - o.referenceSemanticChange = root.path("referenceSemanticChange").asBoolean() root.path("inputs").each { o.inputNames << it.path("name").asText() } root.path("trace").each { JsonNode c = it.path("constraint") diff --git a/symbolic-explorer/constraint/ConstraintController.py b/symbolic-explorer/constraint/ConstraintController.py index afe20f7..624c37a 100755 --- a/symbolic-explorer/constraint/ConstraintController.py +++ b/symbolic-explorer/constraint/ConstraintController.py @@ -40,7 +40,6 @@ def post(request: ConstraintRequest, endpointID: str = Query(...), traceID: str ufs = request.ufs symbolicContextLoss = request.symbolicContextLoss symbolicPrecisionLoss = request.symbolicPrecisionLoss - referenceSemanticChange = request.referenceSemanticChange # Start a new thread to add constraints thread = threading.Thread(target=ConstraintService.add_constraints, kwargs={ @@ -50,8 +49,7 @@ def post(request: ConstraintRequest, endpointID: str = Query(...), traceID: str 'inputs': inputs, 'ufs': ufs, 'symbolic_context_loss': symbolicContextLoss, - 'symbolic_precision_loss': symbolicPrecisionLoss, - 'reference_semantic_change': referenceSemanticChange}) + 'symbolic_precision_loss': symbolicPrecisionLoss}) thread.start() thread.join() # To ensure trace is added in SV-Comp mode # Return a response indicating that the request has been accepted diff --git a/symbolic-explorer/constraint/ConstraintService.py b/symbolic-explorer/constraint/ConstraintService.py index 89831e6..0fd0c84 100755 --- a/symbolic-explorer/constraint/ConstraintService.py +++ b/symbolic-explorer/constraint/ConstraintService.py @@ -22,8 +22,7 @@ class ConstraintService: @staticmethod def add_constraints(endpoint_id: str, trace_id: str, trace: List[TraceItem], inputs: List[InputItem], ufs: List[UFItem], - symbolic_context_loss: bool, symbolic_precision_loss: bool, - reference_semantic_change: bool = False): + symbolic_context_loss: bool, symbolic_precision_loss: bool): """ Adds constraints to the database. @@ -39,7 +38,6 @@ def add_constraints(endpoint_id: str, trace_id: str, trace: List[TraceItem], inp ufs (list): Definition of all UFs that are used symbolic_context_loss (bool): A flag indicating whether the symbolic context was lost. symbolic_precision_loss (bool): A flag indicating whether the symbolic precision was lost (UFs introduced). - reference_semantic_change (bool): A flag indicating whether reference equality semantics changed. Returns: None: The result is the side effect of adding data to the database. @@ -51,5 +49,5 @@ def add_constraints(endpoint_id: str, trace_id: str, trace: List[TraceItem], inp inputs_parsed: List[Input] = Parser.parse_inputs(inputs) ufs_parsed: List[UF] = Parser.parse_ufs(ufs) # Adding the trace and inputs to the database for the specified endpoint. - Database.instance().add_trace(endpoint_id, trace_id, trace_parsed, inputs_parsed, ufs_parsed, symbolic_context_loss, symbolic_precision_loss, reference_semantic_change) + Database.instance().add_trace(endpoint_id, trace_id, trace_parsed, inputs_parsed, ufs_parsed, symbolic_context_loss, symbolic_precision_loss) logger.info(f'[CONSTRAINT SERVICE] Added trace {trace_id} to endpoint {endpoint_id}') diff --git a/symbolic-explorer/data/BinaryExecutionTree/Tree.py b/symbolic-explorer/data/BinaryExecutionTree/Tree.py index 6636395..3aa0f61 100755 --- a/symbolic-explorer/data/BinaryExecutionTree/Tree.py +++ b/symbolic-explorer/data/BinaryExecutionTree/Tree.py @@ -33,7 +33,6 @@ def __init__(self, endpoint_id: Union[str, int]): self.endpoint_id = endpoint_id self.symbolic_context_loss = False self.symbolic_precision_loss = False - self.reference_semantic_change = False self.uncaught_exceptions: int = 0 self.symbolic_vars: Set = set() self.ufs: Set = set() @@ -68,10 +67,6 @@ def record_precision_loss(self): logger.warning("Precision loss recorded!") self.symbolic_precision_loss = True - def record_reference_semantic_change(self): - logger.warning("Reference semantic change recorded: user-de-interned strings compared via Objects.equals") - self.reference_semantic_change = True - def add(self, trace: list[Branch | Special], inputs: List[Input], ufs: List[UF]): """ Adds a branch to the tree based on the provided trace and inputs. diff --git a/symbolic-explorer/data/Database.py b/symbolic-explorer/data/Database.py index e4866a8..a52752c 100755 --- a/symbolic-explorer/data/Database.py +++ b/symbolic-explorer/data/Database.py @@ -113,8 +113,7 @@ def get_endpoints(self): return endpoints def add_trace(self, endpoint_id: Union[str, int], trace_id: str, trace: list[Branch | Special], inputs: List[Input], ufs: List[UF], - symbolic_context_loss: bool, symbolic_precision_loss: bool, - reference_semantic_change: bool = False): + symbolic_context_loss: bool, symbolic_precision_loss: bool): endpoint_id = str(endpoint_id) lock.acquire() @@ -126,7 +125,6 @@ def add_trace(self, endpoint_id: Union[str, int], trace_id: str, trace: list[Bra self.tree[endpoint_id].record_ufs(ufs) self.tree[endpoint_id].record_context_loss() if symbolic_context_loss else None self.tree[endpoint_id].record_precision_loss() if symbolic_precision_loss else None - self.tree[endpoint_id].record_reference_semantic_change() if reference_semantic_change else None lock.release() diff --git a/symbolic-explorer/driver/SVCompDriver.py b/symbolic-explorer/driver/SVCompDriver.py index a0081e8..3683f35 100644 --- a/symbolic-explorer/driver/SVCompDriver.py +++ b/symbolic-explorer/driver/SVCompDriver.py @@ -313,10 +313,6 @@ def run(self): logger.warning(f'[SVCOMP] Found uncaught exceptions during symbolic execution') verdict = Verdict.UNKNOWN - if (verdict == Verdict.VIOLATION) and Database.instance().get_tree(ENDPOINT_ID).reference_semantic_change: - logger.warning(f'[SVCOMP] Found reference semantic change (user-de-interned strings compared via Objects.equals) - downgrading VIOLATION to UNKNOWN') - verdict = Verdict.UNKNOWN - if verdict == Verdict.NO_SYMBOLIC_VARS: verdict = Verdict.SAFE verdict_logger.info(f'[VERDICT {self.verification_category.value}] {verdict.value}') diff --git a/symbolic-explorer/parse/DataTransferObjects.py b/symbolic-explorer/parse/DataTransferObjects.py index 11f80e8..af61241 100644 --- a/symbolic-explorer/parse/DataTransferObjects.py +++ b/symbolic-explorer/parse/DataTransferObjects.py @@ -32,7 +32,6 @@ class ConstraintRequest(BaseModel): ufs: List[UFItem] symbolicContextLoss: bool symbolicPrecisionLoss: bool - referenceSemanticChange: bool = False class CoverageRequest(BaseModel): diff --git a/targets/sv-comp/scripts/lib/analysis/failures.py b/targets/sv-comp/scripts/lib/analysis/failures.py index 136469b..35bc11a 100644 --- a/targets/sv-comp/scripts/lib/analysis/failures.py +++ b/targets/sv-comp/scripts/lib/analysis/failures.py @@ -71,7 +71,6 @@ def analyze_failures(log_base_dir: Path = None) -> Dict: 'symbolic_context_loss': [], 'symbolic_precision_loss': [], 'uncaught_exceptions': [], - 'reference_semantic_change': [], 'internal_errors': [], 'total_analyzed': 0 } @@ -98,9 +97,6 @@ def analyze_failures(log_base_dir: Path = None) -> Dict: if 'Found uncaught exceptions' in content: failure_stats['uncaught_exceptions'].append(testcase_name) - if 'Found reference semantic change' in content: - failure_stats['reference_semantic_change'].append(testcase_name) - if '[SWAT Assertion failed]' in content or 'java.lang.AssertionError: [SWAT]' in content: failure_stats['internal_errors'].append(testcase_name) @@ -124,7 +120,6 @@ def print_failure_statistics(log_base_dir: Path = None): ('Symbolic Context Loss', 'symbolic_context_loss'), ('Symbolic Precision Loss', 'symbolic_precision_loss'), ('Uncaught Exceptions', 'uncaught_exceptions'), - ('Reference Semantic Change', 'reference_semantic_change'), ('Internal SWAT Errors', 'internal_errors') ] From 7bb72f4b2c3a9333d9923aa5e58a533093bfb405 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 13:27:05 +0000 Subject: [PATCH 15/33] fix(common): checkClassName no longer rejects L-prefixed class names The class-name guard behind Util.formatClassName carried a `&& !className.startsWith("L")` clause intended to catch object type descriptors (Lxxx;). But object descriptors always end in ';', which the existing ';' clause already rejects, so the term was redundant. Because it keys on the first character it spuriously rejected every legitimate class name beginning with 'L' (default-package targets/benchmarks such as LitSymTarget), tripping SWATAssert and crashing instrumentation. Drop the term and clarify the message. The guard still rejects every real descriptor: object (';'), method ('('/')'); arrays are skipped via the existing '[' early-return. Add UtilClassNameSpec (L0) pinning both halves of the contract. Co-Authored-By: Claude Opus 4.8 --- .../java/de/uzl/its/swat/common/Util.java | 5 +- .../its/swat/common/UtilClassNameSpec.groovy | 62 +++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java index 2c36d92..e01479e 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java @@ -238,9 +238,8 @@ private static void checkClassName(String className) { return; // array class, skip check for now } SWATAssert.check( - !className.contains(";") && !className.contains("(") && !className.contains(")") - && !className.startsWith("L"), - "Class name '{}' should not contain ';' or brackets", className); + !className.contains(";") && !className.contains("(") && !className.contains(")"), + "Class name '{}' should not be a type descriptor (contains ';' or brackets)", className); } /** diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy new file mode 100644 index 0000000..1cc586e --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy @@ -0,0 +1,62 @@ +package de.uzl.its.swat.common + +import de.uzl.its.swat.common.exceptions.SWATAssert +import spock.lang.Specification +import spock.lang.Unroll + +/** + * Unit test for {@link Util#formatClassName} and the private {@code checkClassName} guard behind it. + * + *

The guard's sole job is to reject type descriptors (object {@code Lxxx;}, method + * {@code (..)X}) passed where a class name is expected — in either spelling: dotted/binary + * ({@code a.b.C}) or internal/slashed ({@code a/b/C}). {@code formatClassName} canonicalizes to the + * internal form. + * + *

A regression (commit a80dc60, incidental to an unrelated UF commit) added + * {@code && !className.startsWith("L")} to the guard. Because the predicate keys on the first + * character, it spuriously rejected every legitimate class name beginning with 'L' — notably + * default-package targets/benchmarks named {@code L...} (e.g. {@code LitSymTarget}) — crashing + * instrumentation through {@link SWATAssert}. Object descriptors are already caught by the {@code ';'} + * clause, so the term was pure false-positive surface and was removed. This pins both halves of the + * contract: L-prefixed class names round-trip, real descriptors still throw. + */ +class UtilClassNameSpec extends Specification { + + def setup() { + // checkClassName routes failures through SWATAssert; make the guard deterministically active + // regardless of any earlier spec that may have toggled it. The test task sets exitOnError=false, + // so a failed assertion rethrows (catchable) rather than halting the JVM. + SWATAssert.setEnabled(true) + } + + @Unroll + def "formatClassName accepts class name '#name' and canonicalizes to '#expected'"() { + expect: + Util.formatClassName(name) == expected + + where: + name || expected + "LitSymTarget" || "LitSymTarget" // the regression case: default-package, L-prefixed + "Long" || "Long" + "List" || "List" + "java.lang.Long" || "java/lang/Long" // dotted -> slashed + "java/lang/String" || "java/lang/String" // already internal -> idempotent + } + + @Unroll + def "formatClassName still rejects the type descriptor '#descriptor'"() { + when: + Util.formatClassName(descriptor) + + then: + thrown(AssertionError) + + where: + descriptor << ["Ljava/lang/String;", "Lfoo/Bar;", "(I)V", "(Ljava/lang/String;)V"] + } + + def "formatClassName skips the check for array descriptors (early return, not rejected)"() { + expect: "arrays are intentionally skipped by checkClassName, so they pass through unchanged" + Util.formatClassName("[I") == "[I" + } +} From 5bf958e510e6397e49d9be1ffb63c8fdfb261fc3 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 16:14:53 +0000 Subject: [PATCH 16/33] refactor(nocache): table-drive the six valueOf rewrites off the Boxed enum The six near-identical valueOf-rewrite blocks in visitMethodInsn each hardcoded owner, the valueOf/ctor descriptors, the load/store opcodes and the primitive Type - all of which the Boxed enum already carried for deInternReturn. Two parallel tables to keep in sync by hand (drift risk). Make Boxed the single source of truth: store one primDescriptor per wrapper and derive boxedDescriptor/valueOfDescriptor/ctorDescriptor/ unboxDescriptor from it (primType stays explicit so short/byte/char keep their int-category stack slot). The six blocks collapse to one enum-driven path (forValueOf -> rewriteValueOf), and the rebox sequence shared with deInternReturn's boxed branch is extracted into reboxFreshFromPrimitive. Behavior-preserving: emitted bytecode is instruction- and descriptor-identical (independently reviewed); adding a 7th wrapper is now a one-line enum row. Broaden BoxedReturnTarget/BoxedDeInternAgentSpec (L2) to drive the valueOf rewrite for all six wrappers through the JVM verifier (previously only Integer+Long were exercised), closing the coverage gap on the derived short/byte/char descriptors. Co-Authored-By: Claude Opus 4.8 --- .../nocache/NoCacheMethodAdapter.java | 229 ++++++++---------- .../heap/BoxedDeInternAgentSpec.groovy | 19 +- .../resources/targets/BoxedReturnTarget.java | 43 +++- 3 files changed, 146 insertions(+), 145 deletions(-) diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java index c771e34..4ca4e5a 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java @@ -61,6 +61,36 @@ private void recordBoxedProvenance(String owner, String valueOfDescriptor, int p emitProvenanceRecord(); } + /** + * Consume the primitive on top of the stack and leave a fresh, distinctly-identified boxed instance + * in its place ({@code [prim] -> [new (prim)]}). The primitive is parked in a fresh local + * (returned) because it is needed again after the {@code NEW} - both to feed the constructor and, + * for {@link #rewriteValueOf}, to materialize the cached canonical. The local is required for the + * category-2 {@code long}. Shared by the {@code valueOf} rewrite and {@link #deInternReturn}. + */ + private int reboxFreshFromPrimitive(Boxed boxed) { + int primLocal = newLocal(boxed.primType); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ISTORE), primLocal); + mv.visitTypeInsn(Opcodes.NEW, boxed.owner); + mv.visitInsn(Opcodes.DUP); + mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ILOAD), primLocal); + mv.visitMethodInsn(Opcodes.INVOKESPECIAL, boxed.owner, "", boxed.ctorDescriptor(), false); + return primLocal; + } + + /** + * Replace {@code .valueOf(prim)} (the primitive is on top of the stack) with + * {@code new (prim)}, giving the produced box a distinct identity, and record provenance to + * the real cached canonical so reference {@code ==} on cache hits still models real Java. + */ + private void rewriteValueOf(Boxed boxed) { + int primLocal = reboxFreshFromPrimitive(boxed); + recordBoxedProvenance(boxed.owner, boxed.valueOfDescriptor(), + boxed.primType.getOpcode(Opcodes.ILOAD), primLocal); + NoCacheTransformer.getPrintBox() + .addMsg("Replacing " + boxed.simpleName() + ".valueOf with new " + boxed.simpleName()); + } + // Intercept method calls to disable interning and caching. @Override public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { @@ -74,101 +104,13 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri .addMsg("Removing String.intern() call"); return; } - // Replace Integer.valueOf(int) with new Integer(int) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Integer") && - name.equals("valueOf") && - descriptor.equals("(I)Ljava/lang/Integer;")) { - int localVarIndex = newLocal(Type.INT_TYPE); - mv.visitVarInsn(Opcodes.ISTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Integer"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Integer", "", "(I)V", false); - recordBoxedProvenance("java/lang/Integer", "(I)Ljava/lang/Integer;", Opcodes.ILOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Integer.valueOf with new Integer"); - return; - } - // Replace Long.valueOf(long) with new Long(long) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Long") && - name.equals("valueOf") && - descriptor.equals("(J)Ljava/lang/Long;")) { - int localVarIndex = newLocal(Type.LONG_TYPE); - mv.visitVarInsn(Opcodes.LSTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Long"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.LLOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Long", "", "(J)V", false); - recordBoxedProvenance("java/lang/Long", "(J)Ljava/lang/Long;", Opcodes.LLOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Long.valueOf with new Long"); - return; - } - // Replace Short.valueOf(short) with new Short(short) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Short") && - name.equals("valueOf") && - descriptor.equals("(S)Ljava/lang/Short;")) { - int localVarIndex = newLocal(Type.INT_TYPE); - mv.visitVarInsn(Opcodes.ISTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Short"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Short", "", "(S)V", false); - recordBoxedProvenance("java/lang/Short", "(S)Ljava/lang/Short;", Opcodes.ILOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Short.valueOf with new Short"); - return; - } - // Replace Byte.valueOf(byte) with new Byte(byte) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Byte") && - name.equals("valueOf") && - descriptor.equals("(B)Ljava/lang/Byte;")) { - int localVarIndex = newLocal(Type.INT_TYPE); - mv.visitVarInsn(Opcodes.ISTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Byte"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Byte", "", "(B)V", false); - recordBoxedProvenance("java/lang/Byte", "(B)Ljava/lang/Byte;", Opcodes.ILOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Byte.valueOf with new Byte"); - return; - } - // Replace Character.valueOf(char) with new Character(char) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Character") && - name.equals("valueOf") && - descriptor.equals("(C)Ljava/lang/Character;")) { - int localVarIndex = newLocal(Type.INT_TYPE); - mv.visitVarInsn(Opcodes.ISTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Character"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Character", "", "(C)V", false); - recordBoxedProvenance("java/lang/Character", "(C)Ljava/lang/Character;", Opcodes.ILOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Character.valueOf with new Character"); - return; - } - // Replace Boolean.valueOf(boolean) with new Boolean(boolean) - if (opcode == Opcodes.INVOKESTATIC && - owner.equals("java/lang/Boolean") && - name.equals("valueOf") && - descriptor.equals("(Z)Ljava/lang/Boolean;")) { - int localVarIndex = newLocal(Type.INT_TYPE); - mv.visitVarInsn(Opcodes.ISTORE, localVarIndex); - mv.visitTypeInsn(Opcodes.NEW, "java/lang/Boolean"); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(Opcodes.ILOAD, localVarIndex); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Boolean", "", "(Z)V", false); - recordBoxedProvenance("java/lang/Boolean", "(Z)Ljava/lang/Boolean;", Opcodes.ILOAD, localVarIndex); - NoCacheTransformer.getPrintBox() - .addMsg("Replacing Boolean.valueOf with new Boolean"); - return; + // Replace .valueOf(prim) with new (prim) to defeat the wrapper cache (see Boxed). + if (opcode == Opcodes.INVOKESTATIC && name.equals("valueOf")) { + Boxed boxed = Boxed.forValueOf(owner, descriptor); + if (boxed != null) { + rewriteValueOf(boxed); + return; + } } // For all other method calls, proceed normally. mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface); @@ -211,11 +153,10 @@ private void deInternReturn(Type returnType) { mv.visitLabel(done); return; } - Boxed boxed = Boxed.forDescriptor(returnType.getDescriptor()); + Boxed boxed = Boxed.forReturnDescriptor(returnType.getDescriptor()); if (boxed != null) { // Boxed wrappers have no copy constructor: keep the original boxed in a local, unbox it to // the primitive, rebox into a fresh instance, then record provenance (copy -> original). - // The primitive local across the NEW is required for the category-2 long. Label done = new Label(); mv.visitInsn(Opcodes.DUP); mv.visitJumpInsn(Opcodes.IFNULL, done); // null result: leave it, skip the wrap @@ -223,14 +164,8 @@ private void deInternReturn(Type returnType) { mv.visitVarInsn(Opcodes.ASTORE, origLocal); mv.visitVarInsn(Opcodes.ALOAD, origLocal); mv.visitMethodInsn(Opcodes.INVOKEVIRTUAL, boxed.owner, boxed.unboxMethod, - boxed.unboxDescriptor, false); - int primLocal = newLocal(boxed.primType); - mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ISTORE), primLocal); - mv.visitTypeInsn(Opcodes.NEW, boxed.owner); - mv.visitInsn(Opcodes.DUP); - mv.visitVarInsn(boxed.primType.getOpcode(Opcodes.ILOAD), primLocal); - mv.visitMethodInsn(Opcodes.INVOKESPECIAL, boxed.owner, "", - boxed.ctorDescriptor, false); // [copy] + boxed.unboxDescriptor(), false); // [prim] + reboxFreshFromPrimitive(boxed); // [copy] mv.visitInsn(Opcodes.DUP); mv.visitVarInsn(Opcodes.ALOAD, origLocal); emitProvenanceRecord(); // record(copy, original) -> [copy] @@ -238,40 +173,78 @@ private void deInternReturn(Type returnType) { } } - /** The six cached boxed wrappers G3 de-interns, with their unbox method and primitive constructor. */ + /** + * The six cached boxed wrappers G3 de-interns. Each carries the JVM primitive type descriptor + * ({@code primDescriptor}, e.g. {@code "I"}, {@code "J"}, {@code "S"}) from which all method + * descriptors are derived, so this enum is the single source of truth for both the {@code valueOf} + * rewrite and the return de-intern. {@code primType} is the stack type used for the load/store + * opcodes and {@code newLocal}: {@code short}/{@code byte}/{@code char} live on the operand stack + * as {@code int}, hence {@link Type#INT_TYPE} for all of them and {@link Type#LONG_TYPE} only for + * {@code long}. + */ private enum Boxed { - INTEGER("java/lang/Integer", "intValue", "()I", "(I)V", Type.INT_TYPE), - LONG("java/lang/Long", "longValue", "()J", "(J)V", Type.LONG_TYPE), - SHORT("java/lang/Short", "shortValue", "()S", "(S)V", Type.INT_TYPE), - BYTE("java/lang/Byte", "byteValue", "()B", "(B)V", Type.INT_TYPE), - CHARACTER("java/lang/Character", "charValue", "()C", "(C)V", Type.INT_TYPE), - BOOLEAN("java/lang/Boolean", "booleanValue", "()Z", "(Z)V", Type.INT_TYPE); + INTEGER("java/lang/Integer", "intValue", "I", Type.INT_TYPE), + LONG("java/lang/Long", "longValue", "J", Type.LONG_TYPE), + SHORT("java/lang/Short", "shortValue", "S", Type.INT_TYPE), + BYTE("java/lang/Byte", "byteValue", "B", Type.INT_TYPE), + CHARACTER("java/lang/Character", "charValue", "C", Type.INT_TYPE), + BOOLEAN("java/lang/Boolean", "booleanValue", "Z", Type.INT_TYPE); final String owner; final String unboxMethod; - final String unboxDescriptor; - final String ctorDescriptor; + final String primDescriptor; final Type primType; - Boxed(String owner, String unboxMethod, String unboxDescriptor, String ctorDescriptor, - Type primType) { + Boxed(String owner, String unboxMethod, String primDescriptor, Type primType) { this.owner = owner; this.unboxMethod = unboxMethod; - this.unboxDescriptor = unboxDescriptor; - this.ctorDescriptor = ctorDescriptor; + this.primDescriptor = primDescriptor; this.primType = primType; } - static Boxed forDescriptor(String descriptor) { - return switch (descriptor) { - case "Ljava/lang/Integer;" -> INTEGER; - case "Ljava/lang/Long;" -> LONG; - case "Ljava/lang/Short;" -> SHORT; - case "Ljava/lang/Byte;" -> BYTE; - case "Ljava/lang/Character;" -> CHARACTER; - case "Ljava/lang/Boolean;" -> BOOLEAN; - default -> null; - }; + /** The wrapper's own type descriptor, e.g. {@code Ljava/lang/Integer;}. */ + String boxedDescriptor() { + return "L" + owner + ";"; + } + + /** {@code valueOf} factory descriptor, e.g. {@code (I)Ljava/lang/Integer;}. */ + String valueOfDescriptor() { + return "(" + primDescriptor + ")" + boxedDescriptor(); + } + + /** Primitive constructor descriptor, e.g. {@code (I)V}. */ + String ctorDescriptor() { + return "(" + primDescriptor + ")V"; + } + + /** Unbox accessor descriptor, e.g. {@code ()I} for {@code intValue}. */ + String unboxDescriptor() { + return "()" + primDescriptor; + } + + /** Simple class name for log messages, e.g. {@code Integer}. */ + String simpleName() { + return owner.substring(owner.lastIndexOf('/') + 1); + } + + /** The wrapper whose {@code valueOf(prim)} this call site invokes, or {@code null}. */ + static Boxed forValueOf(String owner, String descriptor) { + for (Boxed b : values()) { + if (b.owner.equals(owner) && b.valueOfDescriptor().equals(descriptor)) { + return b; + } + } + return null; + } + + /** The wrapper matching a value-typed return descriptor, or {@code null}. */ + static Boxed forReturnDescriptor(String descriptor) { + for (Boxed b : values()) { + if (b.boxedDescriptor().equals(descriptor)) { + return b; + } + } + return null; } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy index eaa2a27..8d9cb48 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy @@ -6,20 +6,23 @@ import spock.lang.See import spock.lang.Specification /** - * G3-A2 end-to-end anchor (Level L2): the REAL agent runs a program that receives boxed wrappers from - * un-instrumented java/lang methods (Integer.valueOf(String), Long.valueOf(String)), which trigger the - * unbox+rebox de-intern wrap - including the category-2 (long) wide-local path. The run completing - * (AgentRun asserts exit == 0 plus a parsed TraceDTO) is the bytecode-validity oracle: a malformed wrap - * would VerifyError. Object-identity (the actual de-intern effect) is pinned at L1 by OutputDeInternSpec; - * this anchors that the boxed bytecode is real-JVM-valid. Mirrors PureFunctionUFAgentSpec. + * G3-A2 end-to-end anchor (Level L2): the REAL agent runs a program that drives the boxed de-intern + * instrumentation through both emitted-bytecode paths - the unbox+rebox de-intern wrap on + * un-instrumented valueOf(String) returns for Integer (category-1) and Long (category-2 wide-local), + * and the valueOf(primitive)->new rewrite via autoboxing for ALL SIX wrappers (Integer/Long/Short/Byte/ + * Character/Boolean), the path whose per-wrapper descriptors are DERIVED from the Boxed enum. The run + * completing (AgentRun asserts exit == 0 plus a parsed TraceDTO) is the bytecode-validity oracle: a + * malformed wrap or rewrite would VerifyError. Object-identity (the actual de-intern effect) is pinned + * at L1 by OutputDeInternSpec, the boxed-cache == semantics at L0 by ProvenanceRefEqualsSpec; this + * anchors that the boxed bytecode is real-JVM-valid for every wrapper. Mirrors PureFunctionUFAgentSpec. * * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. */ class BoxedDeInternAgentSpec extends Specification { @See("docs/heap-redesign-g3-design.md") - def "G3-A2 (L2): boxed returns (Integer + Long) de-intern, load, verify, and run"() { - when: "the agent runs a program taking boxed wrappers from un-instrumented methods" + def "G3-A2 (L2): all six wrappers' valueOf-rewrite (+ Integer/Long de-intern) load, verify, and run"() { + when: "the agent runs a program driving the boxed de-intern bytecode paths" TraceObservation obs = AgentRun.run("targets/BoxedReturnTarget.java", "BoxedReturnTarget") then: "the run completed (exit 0 + parsed trace) - the unbox+rebox wrap is verifier-valid" diff --git a/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java index 613339f..f5b847e 100644 --- a/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java +++ b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java @@ -1,12 +1,26 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for G3-A2 (boxed output-boundary de-interning): an instrumented method calls - * un-instrumented (java/lang) methods whose DECLARED return type is a boxed wrapper NOT matched by the - * existing valueOf(primitive) rewrites - Integer.valueOf(String) (category-1) and Long.valueOf(String) - * (category-2, the wide-local rebox path). Under the real agent these trigger the unbox+rebox de-intern - * wrap; the program must load, verify, and run to completion - a malformed wrap would VerifyError into a - * non-zero exit, which AgentRun asserts against. See docs/heap-redesign-g3-design.md (Level L2). + * Level-2 target for G3-A2 (boxed output-boundary de-interning), driving the de-intern instrumentation + * for the wrapper types under the real JVM verifier (the run completing at exit 0 with a parsed TraceDTO + * is the bytecode-validity oracle; a malformed wrap/rewrite would VerifyError). + * + *

    + *
  • deInternReturn (unbox+rebox) branch - declared-wrapper returns from un-instrumented + * {@code valueOf(String)} factories: Integer (category-1) and Long (category-2 wide-local).
  • + *
  • valueOf rewrite branch - autoboxing emits {@code .valueOf(primitive)}, which the + * adapter rewrites to {@code new (primitive)} for ALL SIX wrappers (Integer/Long/Short/ + * Byte/Character/Boolean). Per-wrapper descriptors are DERIVED from the {@code Boxed} enum, so + * verifier coverage of every type - especially the short/byte/char descriptors paired with + * int-category opcodes - is the point of constructing all six here.
  • + *
+ * + *

The autoboxed wrappers are kept CONCRETE and consumed via string concatenation ({@code toString}), + * and the {@code valueOf(String)} factories are limited to Integer/Long. This is deliberate: feeding a + * symbolic value through Short/Byte/Character/Boolean parsing or unboxing trips unrelated gaps in the + * symbolic modeling of those wrappers, which are out of scope for this de-intern bytecode-validity test. + * Object-identity (the de-intern effect) is pinned at L1 by OutputDeInternSpec, the boxed-cache + * {@code ==} semantics at L0 by ProvenanceRefEqualsSpec. See docs/heap-redesign-g3-design.md (Level L2). */ public class BoxedReturnTarget { @@ -15,10 +29,21 @@ public static void main(String[] args) { } public static String test(@Symbolic int x) { - Integer i = Integer.valueOf(Integer.toString(x)); // Integer return -> category-1 unbox+rebox - Long l = Long.valueOf(Long.toString((long) x)); // Long return -> category-2 wide unbox+rebox + // deInternReturn branch: declared-wrapper returns from un-instrumented valueOf(String) factories. + Integer i = Integer.valueOf(Integer.toString(x)); // category-1 unbox+rebox + Long l = Long.valueOf(Long.toString((long) x)); // category-2 wide unbox+rebox + + // valueOf rewrite branch: autoboxing -> .valueOf(primitive) -> new (primitive), + // for all six wrappers. Concrete; consumed via string concatenation below (no symbolic unbox). + Integer ai = 11; + Long al = 12L; + Short ash = (short) 13; + Byte aby = (byte) 14; + Character ach = 'c'; + Boolean abo = true; + if (i.intValue() + l.longValue() > 0) { - return "pos"; + return "pos:" + ai + al + ash + aby + ach + abo; } return "nonpos"; } From 3ac8f10f96735786d590eb1bbeb99995a64e4de7 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 19:37:54 +0000 Subject: [PATCH 17/33] feat(skills): add jdk-source skill for fetching JDK method source scripts/jmethod.py extracts method/class source from the active JDK's lib/src.zip (version-exact, offline): a method/ctor signature index, the brace-matched full source of any method's overloads, its callees, or a field declaration. SKILL.md documents it plus the six-point purity rubric for deciding PureMethods.WHITELIST (G4) eligibility - the tooling for the broad whitelist audit. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/jdk-source/SKILL.md | 69 ++++++++ .claude/skills/jdk-source/scripts/jmethod.py | 158 +++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 .claude/skills/jdk-source/SKILL.md create mode 100755 .claude/skills/jdk-source/scripts/jmethod.py diff --git a/.claude/skills/jdk-source/SKILL.md b/.claude/skills/jdk-source/SKILL.md new file mode 100644 index 0000000..d32b00e --- /dev/null +++ b/.claude/skills/jdk-source/SKILL.md @@ -0,0 +1,69 @@ +--- +name: jdk-source +description: Fetch the exact source of a JDK method/class from the active JDK's lib/src.zip (version-exact, no network), and audit a java.lang method for SWAT purity-whitelist eligibility. Use when deciding whether a JDK method may be added to PureMethods.WHITELIST (G4 pure-function UF modeling), or when you need to read what a JDK method actually does. +--- + +# JDK source + purity-whitelist audit + +Two jobs: (1) cheaply read JDK source, (2) decide if a method may join `PureMethods.WHITELIST` +(`symbolic-executor/.../symbolic/UFs/PureMethods.java`). Background: `docs/heap-redesign-g4-whitelist-survey.md`. + +## Fetching source — `scripts/jmethod.py` + +Reads the active JDK's `lib/src.zip` (resolved from `$JAVA_HOME`, else `java -XshowSettings:properties`). +Version-exact, offline. + +```bash +python3 .claude/skills/jdk-source/scripts/jmethod.py java.lang.Math # index: every method signature + line +python3 .claude/skills/jdk-source/scripts/jmethod.py java.lang.Math floorDiv # full source of all overloads +python3 .claude/skills/jdk-source/scripts/jmethod.py java.lang.Math floorDiv --callees # + the methods each overload calls +python3 .claude/skills/jdk-source/scripts/jmethod.py java.lang.Integer --field digits # a field declaration +``` + +`` is dotted (`java.lang.String`). Nested types live in their top-level file. Follow +`--callees` transitively (call the script again on each callee's class) to establish purity through +the call graph. + +## Whitelist eligibility — the rubric + +A method may be added to `WHITELIST` **iff ALL six hold**. The whitelist is a soundness precondition: +an unsound entry can make the engine model a non-deterministic/side-effecting call as a referentially +transparent UF, which can corrupt a verdict. When unsure, EXCLUDE. + +1. **Pure / deterministic / side-effect-free.** Same inputs always give the same output; no observable + effect. Read the source (transitively via `--callees`): reject any `PUTSTATIC`/`PUTFIELD` to shared + mutable state, I/O, synchronization-for-effect, or argument mutation. Reading `static final` + constant tables (e.g. `Integer.digits`) is fine. +2. **No locale / time / random / environment dependence.** Reject no-arg `String.toLowerCase()`/ + `toUpperCase()` (default locale), `String.format`, property/env readers, `Math.random`, + `System.currentTimeMillis`, etc. NB: `Character.toLowerCase(char)` IS locale-independent (Unicode + table) and allowed — unlike the `String` no-arg forms. +3. **No identity / interning semantics.** Reject `intern`, identity-hashing, or methods whose result + identity matters. (Value equality of the result is fine; the UF models the value.) +4. **UNMODELED by SWAT.** If SWAT already models the method, the UF never fires (dead entry). Check the + matching handler in `symbolic-executor/.../symbolic/invoke/java/lang/Invocation.java`: the + method's name must NOT be in its dispatch (it must fall to `default -> PlaceHolder.instance`). + `String` methods: check `StringInvocation` + the `StringValue` model methods. +5. **Supported return sort.** The return type must be `String` or a primitive (`boolean`, `byte`, + `short`, `char`, `int`, `long`, `float`, `double`). Object/array/`void`/collection/stream returns + are NOT supported (`buildPureUF` returns null → falls back to concretization). The UF return sort is + set by `InvocationHandler#pureUFReturnType`. +6. **Value-typed inputs only.** Every parameter (and, for an instance method, the receiver) must be a + String or boxed primitive — i.e. captured by a formula (`Util.isValueType`). Reject methods taking + arrays, `CharSequence`, collections, `Object`, `Function`, etc. (their formula can't capture the + input). Prefer the STATIC equivalent over a receiver-keyed instance accessor: `ufName` keys only on + argument descriptors, so two distinct receivers of an instance method (`intValue()`, instance + `toString()`/`hashCode()`) would collide to one UF term — EXCLUDE those until receiver-keyed UFs exist. + +## Entry format + +`WHITELIST` keys are `owner + "/" + name + descriptor`, e.g. `java/lang/Math/floorDiv(II)I`. The +descriptor disambiguates overloads. Verify it against the index (`jmethod.py `). + +## Verifying an addition + +After adding, drive it through the real agent (an L2 `*AgentSpec` calling the method on a `@Symbolic` +input into a branch) and assert: a `pure_` UF appears in the branch constraints, and +`symbolicContextLoss == false`, `symbolicPrecisionLoss == false`. See `PureFunctionUFAgentSpec` (String) +and `PureFunctionPrimitiveAgentSpec` (primitive). The L0 `PureUFPrimitiveRecoverySpec` pins the +per-sort recovery construction. Use the `swat-test` skill for the harness details. diff --git a/.claude/skills/jdk-source/scripts/jmethod.py b/.claude/skills/jdk-source/scripts/jmethod.py new file mode 100755 index 0000000..010d035 --- /dev/null +++ b/.claude/skills/jdk-source/scripts/jmethod.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Cheaply fetch JDK source from the active JDK's lib/src.zip (version-exact, no network). + +Usage: + jmethod.py # index: every method/ctor signature + line no. + jmethod.py # full source of all overloads of + jmethod.py --callees # also list the methods each overload calls + jmethod.py --field # a field declaration (for static-final purity checks) + + is dotted, e.g. java.lang.String. Nested classes: java.lang.Integer (the file is the +top-level class; nested types appear within it). The JDK is resolved from $JAVA_HOME, else from +`java -XshowSettings:properties`. Override the module with --module (default: search all). +""" +import os +import re +import subprocess +import sys +import zipfile + + +def java_home() -> str: + jh = os.environ.get("JAVA_HOME") + if jh and os.path.exists(os.path.join(jh, "lib", "src.zip")): + return jh + out = subprocess.run(["java", "-XshowSettings:properties"], + capture_output=True, text=True).stderr + m = re.search(r"java\.home\s*=\s*(.+)", out) + if not m: + sys.exit("Could not resolve java.home (set $JAVA_HOME).") + return m.group(1).strip() + + +def load_source(fqn: str, module: str | None): + zp = os.path.join(java_home(), "lib", "src.zip") + if not os.path.exists(zp): + sys.exit(f"No src.zip at {zp} — this JDK ships no sources.") + rel = fqn.replace(".", "/") + ".java" + with zipfile.ZipFile(zp) as z: + cands = [n for n in z.namelist() if n.endswith(rel) + and (module is None or n.startswith(module + "/"))] + if not cands: + sys.exit(f"Not found in src.zip: {rel}" + + (f" (module {module})" if module else "")) + entry = cands[0] + return entry, z.read(entry).decode("utf-8", "replace").splitlines() + + +# A modest scanner: yields the source with strings/chars/comments blanked, so brace counting and +# signature detection ignore braces and identifiers inside literals/comments. Good enough for JDK src. +def blank_noncode(src: list[str]) -> list[str]: + out, in_block = [], False + for line in src: + res, i, n = [], 0, len(line) + while i < n: + c = line[i] + if in_block: + if c == "*" and i + 1 < n and line[i + 1] == "/": + in_block = False + res.append(" "); i += 2; continue + res.append(" "); i += 1; continue + if c == "/" and i + 1 < n and line[i + 1] == "/": + res.append(" " * (n - i)); break + if c == "/" and i + 1 < n and line[i + 1] == "*": + in_block = True + res.append(" "); i += 2; continue + if c in "\"'": + q = c; res.append(" "); i += 1 + while i < n: + if line[i] == "\\": + res.append(" "); i += 2; continue + if line[i] == q: + res.append(" "); i += 1; break + res.append(" "); i += 1 + continue + res.append(c); i += 1 + out.append("".join(res)) + return out + + +SIG = re.compile(r"^\s{1,8}(?:(?:public|private|protected|static|final|native|synchronized|" + r"abstract|default|strictfp)\s+)+[\w$.<>\[\]?,\s]*?\b(\w+)\s*\(") + + +def signatures(code: list[str]): + """(line_index, method_name) for lines that look like a method/ctor declaration.""" + for i, line in enumerate(code): + m = SIG.search(line) + if m and "=" not in line.split("(")[0]: + yield i, m.group(1) + + +def brace_extent(code: list[str], start: int): + """From a signature at line `start`, return (end_index) of the matching close brace, or None + for abstract/interface methods that end in ';' before any '{'.""" + depth, seen = 0, False + for i in range(start, len(code)): + for c in code[i]: + if c == "{": + depth += 1; seen = True + elif c == "}": + depth -= 1 + if seen and depth == 0: + return i + if not seen and ";" in code[i]: + return None # no body (abstract/native-decl) + return len(code) - 1 + + +def main(): + args = [a for a in sys.argv[1:]] + module = None + if "--module" in args: + k = args.index("--module"); module = args[k + 1]; del args[k:k + 2] + callees = "--callees" in args + if callees: + args.remove("--callees") + field = None + if "--field" in args: + k = args.index("--field"); field = args[k + 1]; del args[k:k + 2] + if not args: + sys.exit(__doc__) + fqn = args[0] + method = args[1] if len(args) > 1 else None + + entry, src = load_source(fqn, module) + code = blank_noncode(src) + print(f"// {entry} ({len(src)} lines)") + + if field: + for i, line in enumerate(code): + if re.search(r"\b" + re.escape(field) + r"\b", line) and ("=" in line or ";" in line) \ + and SIG.search(line) is None: + print(f"{i+1}: {src[i].rstrip()}") + return + + if method is None: + print(f"// method/ctor index for {fqn}:") + for i, name in signatures(code): + print(f"{i+1}: {src[i].strip()}") + return + + hits = [i for i, name in signatures(code) if name == method] + if not hits: + sys.exit(f"No declaration of {method}(...) found in {fqn}.") + for start in hits: + end = brace_extent(code, start) + end = start if end is None else end + print(f"\n// ---- {fqn}.{method} (lines {start+1}-{end+1}) ----") + print("\n".join(src[start:end + 1])) + if callees: + body = "\n".join(code[start:end + 1]) + names = sorted(set(re.findall(r"\b([a-zA-Z_]\w*)\s*\(", body)) + - {method, "if", "for", "while", "switch", "catch", "return", "new"}) + print(f"// callees: {', '.join(names)}") + + +if __name__ == "__main__": + main() From 24118fed85671f9a24d9c1a38ffa8b9c646237b6 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 19:38:06 +0000 Subject: [PATCH 18/33] feat(heap): G4 - extend pure-function UF modeling to primitive returns The G4 purity whitelist modeled a pure unmodeled method's result as a generic axiom-free UF (pure_(inputs)) only for String returns. Extend it to all primitive returns so numeric/boolean/char pure methods (Math.*, Character predicates/conversions, etc.) preserve precision instead of losing symbolic context. - InvocationHandler.buildPureUF: drop the String-only gate; pureUFReturnType maps the return descriptor to the exact shadow sort (bool->Boolean, byte/short/char/int/long->bitvector 8/16/16/32/64, float/double->FP single/double, String->String) and declares the UF with it. The step-2 observed pair stays gated to String returns (the only path with a recovery-side consumer); String behavior is unchanged. - ValueFactory: createNumericalValue(type, concrete, formula) overload constructs each primitive value carrying a symbolic formula. - visitGETVALUE_primitive: an UNMODELED_RETURN placeholder carrying a UF result installs it as the value's formula (concrete = observed), mirroring the String path; no MAKE_SYMBOLIC (the UF already carries the inputs). - PureMethods: hand-audited primitive starter set (Math.floorDiv/floorMod/ cbrt, Float.intBitsToFloat, Character.toLowerCase/isDigit) - each verified unmodeled + side-effect-free; covers int/long/double/float/char/boolean. Soundness reviewed (axiom-free UF over-approximates any deterministic function; FP congruence is structural over arg terms, the safe direction). Tests: PureUFPrimitiveRecoverySpec (L0, all 8 sorts incl. short/byte) and PureFunctionPrimitiveAgentSpec (L2, 6 sorts end-to-end, UFs ride branches, no context/precision loss). Co-Authored-By: Claude Opus 4.8 --- .../symbolic/SymbolicInstructionVisitor.java | 19 +++++- .../its/swat/symbolic/UFs/PureMethods.java | 27 ++++++--- .../symbolic/invoke/InvocationHandler.java | 59 +++++++++++++++---- .../its/swat/symbolic/value/PlaceHolder.java | 6 +- .../its/swat/symbolic/value/ValueFactory.java | 27 +++++++++ .../PureFunctionPrimitiveAgentSpec.groovy | 41 +++++++++++++ .../heap/PureUFPrimitiveRecoverySpec.groovy | 48 +++++++++++++++ .../targets/PurePrimReturnTarget.java | 36 +++++++++++ 8 files changed, 240 insertions(+), 23 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 7d5775f..bdd2644 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -3653,9 +3653,22 @@ private void visitGETVALUE_primitive(GETVALUE_primitive inst, ValueType type) th } else { stack.popOperand(); } - Value v = ValueFactory.createNumericalValue(type, inst.v); - if (isSymbolic) { - v.MAKE_SYMBOLIC(); + Value v; + if (placeHolder.origin == PlaceHolder.ValueOrigin.UNMODELED_RETURN + && placeHolder.recoveredFormula != null) { + // G4: a whitelisted pure method with a primitive return - model the result as the + // carried generic UF over the inputs (concrete = observed), preserving the relational + // fact (equal inputs => equal outputs) instead of concretizing. Mirrors the String + // path in visitGETVALUE_Object. No MAKE_SYMBOLIC: the UF formula already carries the + // symbolic inputs (isSymbolic() is true iff the formula has free variables). + v = ValueFactory.createNumericalValue(type, inst.v, placeHolder.recoveredFormula); + ThreadHandler.getShadowStateLogger(currentThread().getId()) + .info("Modeled unmodeled pure primitive result as a generic UF: {}", v); + } else { + v = ValueFactory.createNumericalValue(type, inst.v); + if (isSymbolic) { + v.MAKE_SYMBOLIC(); + } } if (cat2) { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java index de80e10..e88e180 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java @@ -13,22 +13,27 @@ *

Membership is the soundness precondition: only genuinely pure + deterministic methods may * appear here. Exclude locale-dependent (no-arg {@code toLowerCase}/{@code toUpperCase}), * environment/property readers, argument-mutating, identity/{@code intern}, and nondeterministic - * (random/time) methods. v1 starter set is tiny and hand-audited (String returns only); it is later - * scaled by a per-class survey of {@code java.lang}. + * (random/time) methods. The starter set is tiny and hand-audited; it is later scaled by a per-class + * survey of {@code java.lang}. String and all primitive return types are supported (the UF's return + * sort is the method's return type); the method must also be UNMODELED by SWAT, else the UF never + * fires. */ public final class PureMethods { private PureMethods() {} /** * Keys are {@code owner + "/" + name + desc} (descriptor included to disambiguate overloads). - * Entries are pure, deterministic, side-effect-free, String-RETURNING, and UNMODELED by SWAT (so - * the generic UF actually fires). Curated from the java.lang purity survey + * Entries are pure, deterministic, side-effect-free, and UNMODELED by SWAT (so the generic UF + * actually fires). Curated from the java.lang purity survey * (docs/heap-redesign-g4-whitelist-survey.md); the boxed types' toString-family is intentionally - * absent (already modeled -> a UF would never fire), and cross-class static String methods - * (Float/Double/Character.toString) are a documented backlog pending static-invoke test support. + * absent (already modeled -> a UF would never fire). String and primitive returns are both + * supported; the primitive block is the hand-audited starter set that doubles as the regression + * set for the primitive-return engine support (each is verified absent from its Invocation + * handler, so the UF fires). The broad agent-driven survey scales this set later. */ private static final Set WHITELIST = Set.of( + // String returns (instance methods on String): "java/lang/String/trim()Ljava/lang/String;", "java/lang/String/strip()Ljava/lang/String;", "java/lang/String/stripLeading()Ljava/lang/String;", @@ -37,7 +42,15 @@ private PureMethods() {} "java/lang/String/substring(II)Ljava/lang/String;", "java/lang/String/repeat(I)Ljava/lang/String;", "java/lang/String/replace(CC)Ljava/lang/String;", - "java/lang/String/indent(I)Ljava/lang/String;"); + "java/lang/String/indent(I)Ljava/lang/String;", + // Primitive returns (static, value-typed inputs; all unmodeled + side-effect-free): + "java/lang/Math/floorDiv(II)I", + "java/lang/Math/floorMod(II)I", + "java/lang/Math/floorDiv(JJ)J", + "java/lang/Math/cbrt(D)D", + "java/lang/Float/intBitsToFloat(I)F", + "java/lang/Character/toLowerCase(C)C", + "java/lang/Character/isDigit(C)Z"); public static boolean isWhitelisted(String owner, String name, String desc) { return WHITELIST.contains(owner + "/" + name + desc); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 77d9c8c..8769b22 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -166,17 +166,20 @@ private record PureUFModel(Formula result, Formula observedApplication) {} /** * Build the generic UF {@code pure_(inputs)} for a whitelisted pure call, or null to fall - * back to G2 concretization. v1 handles String returns only; the inputs (receiver + args) must - * all be value-typed so their formula fully captures the input (sound; no stateful receivers). - * Also builds the same UF applied to the CONSTANT inputs (step 2's observed-pair application), - * using the SAME cached declaration; that is null unless every input is a String (v1: only String - * concretes can be turned into constant formulas here). + * back to G2 concretization. Handles String and all primitive returns (the return sort is + * {@link #pureUFReturnType}); the inputs (receiver + args) must all be value-typed so their + * formula fully captures the input (sound; no stateful receivers). Also builds the same UF applied + * to the CONSTANT inputs (step 2's observed-pair application) using the SAME cached declaration; + * that is null unless the return is a String and every input is a String (the only case whose + * recovery side asserts the observed pair, and where String concretes become constant formulas). */ private static PureUFModel buildPureUF( String owner, String name, String desc, List> inputs) throws NoThreadContextException { - // v1 scope: only String-returning methods are materialized as UFs. - if (!"java.lang.String".equals(Type.getReturnType(desc).getClassName())) { + // The UF's return sort is the method's return type - String or any primitive. Unsupported + // returns (void, arrays, non-String objects) yield null and fall back to G2 concretization. + FormulaType returnType = pureUFReturnType(desc); + if (returnType == null) { return null; } StringFormulaManager smgr = @@ -199,11 +202,45 @@ private static PureUFModel buildPureUF( } PureFunctionUF uf = ThreadHandler.getUFHandler(Thread.currentThread().getId()).getPureFunctionUF(); String ufName = PureMethods.ufName(owner, name, desc); - Formula result = uf.apply(ufName, FormulaType.StringType, symbolicArgs); - // Same cached declaration applied to the constant inputs, so the observed pair constrains the - // very symbol used in `result`. - Formula observed = observable ? uf.apply(ufName, FormulaType.StringType, constArgs) : null; + Formula result = uf.apply(ufName, returnType, symbolicArgs); + // G4 step 2 observed pair: built only for String returns, the sole case whose recovery side + // (visitGETVALUE_Object) asserts the (constant inputs -> observed output) equality. The same + // cached declaration is applied to the constant inputs so the pair constrains the very symbol + // used in `result`. Primitive observed pairs are future work (with the explorer step-2 side). + Formula observed = (observable && FormulaType.StringType.equals(returnType)) + ? uf.apply(ufName, returnType, constArgs) : null; return new PureUFModel(result, observed); } + /** + * The SMT return sort for a whitelisted pure method, matching the shadow value sorts exactly: + * String; boolean; bitvectors of width 8/16/16/32/64 for byte/short/char/int/long; and + * floating-point (single for float, double for double). Returns null for unsupported returns + * (void, arrays, non-String objects), which fall back to G2 concretization. + */ + private static FormulaType pureUFReturnType(String desc) { + Type ret = Type.getReturnType(desc); + switch (ret.getSort()) { + case Type.BOOLEAN: + return FormulaType.BooleanType; + case Type.BYTE: + return FormulaType.getBitvectorTypeWithSize(8); + case Type.SHORT: + case Type.CHAR: + return FormulaType.getBitvectorTypeWithSize(16); + case Type.INT: + return FormulaType.getBitvectorTypeWithSize(32); + case Type.LONG: + return FormulaType.getBitvectorTypeWithSize(64); + case Type.FLOAT: + return FormulaType.getSinglePrecisionFloatingPointType(); + case Type.DOUBLE: + return FormulaType.getDoublePrecisionFloatingPointType(); + case Type.OBJECT: + return "java.lang.String".equals(ret.getClassName()) ? FormulaType.StringType : null; + default: + return null; // void, array, other reference types + } + } + } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index ac169ae..0e1a1ee 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -36,8 +36,10 @@ public enum ValueOrigin { DATABASE, GETFIELD, GETSTATIC, - // The return value of an unmodeled method (tagged in InvocationHandler). At recovery, a - // value-typed result with this origin is concretized rather than identity-recovered (G2). + // The return value of an unmodeled method (tagged in InvocationHandler). At recovery, a result + // with this origin is NOT identity-recovered (which would re-bind the receiver). If it carries a + // pure_ UF (recoveredFormula, whitelisted pure method - String or primitive) it is modeled + // as that UF (G4); otherwise a value-typed result is concretized (G2). UNMODELED_RETURN } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java index fe32e31..fd4e89c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java @@ -20,6 +20,10 @@ import java.util.*; +import org.sosy_lab.java_smt.api.BitvectorFormula; +import org.sosy_lab.java_smt.api.BooleanFormula; +import org.sosy_lab.java_smt.api.FloatingPointFormula; +import org.sosy_lab.java_smt.api.Formula; import org.sosy_lab.java_smt.api.SolverContext; public class ValueFactory { @@ -41,6 +45,29 @@ public class ValueFactory { }; } + /** + * Build a primitive value of {@code type} carrying an explicit symbolic {@code formula} (its + * concrete is the observed result). Used by G4 to model a whitelisted pure primitive return as a + * generic UF over its inputs. The formula's sort MUST match the value's sort (see + * {@code InvocationHandler#pureUFReturnType}): bitvector of width 8/16/16/32/64 for + * byte/short/char/int/long, floating-point for float/double, boolean for boolean. + */ + public static NumericalValue createNumericalValue(ValueType type, Object concrete, Formula formula) + throws NoThreadContextException, TypeException { + SolverContext context = ThreadHandler.getSolverContext(Thread.currentThread().getId()); + return switch (type) { + case intValue -> new IntValue(context, (int) concrete, (BitvectorFormula) formula); + case longValue -> new LongValue(context, (long) concrete, (BitvectorFormula) formula); + case shortValue -> new ShortValue(context, (short) concrete, (BitvectorFormula) formula); + case byteValue -> new ByteValue(context, (byte) concrete, (BitvectorFormula) formula); + case charValue -> new CharValue(context, (char) concrete, (BitvectorFormula) formula); + case booleanValue -> new BooleanValue(context, (boolean) concrete, (BooleanFormula) formula); + case floatValue -> new FloatValue(context, (float) concrete, (FloatingPointFormula) formula); + case doubleValue -> new DoubleValue(context, (double) concrete, (FloatingPointFormula) formula); + default -> throw new TypeException(type); + }; + } + public static AbstractArrayValue createArrayValue( ValueType type, IntValue size, int address) throws NoThreadContextException, TypeException { SolverContext context = ThreadHandler.getSolverContext(Thread.currentThread().getId()); diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy new file mode 100644 index 0000000..ce58c39 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy @@ -0,0 +1,41 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G4 primitive-return end-to-end anchor (Level L2): the REAL agent runs a program that takes pure, + * unmodeled, PRIMITIVE-returning java.lang methods (Math.floorDiv/floorMod/cbrt, Float.intBitsToFloat, + * Character.toLowerCase/isDigit) on symbolic inputs and branches on each result. Each result must be + * modeled as a generic {@code pure_} UF over its inputs (the int/long/double/float/char/boolean + * return sorts), so the run preserves SAFE: NO context loss, NO precision loss, and the UFs actually + * ride into the branch constraints. Mirrors PureFunctionUFAgentSpec (the String-return analogue). + * + * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + */ +class PureFunctionPrimitiveAgentSpec extends Specification { + + @See("docs/heap-redesign-g4-whitelist-survey.md") + def "G4 (L2): pure primitive-returning methods are modeled as UFs (no context/precision loss)"() { + when: "the agent runs a program branching on pure unmodeled primitive returns over symbolic inputs" + TraceObservation obs = AgentRun.run("targets/PurePrimReturnTarget.java", "PurePrimReturnTarget") + + then: "the run completed and a symbolic input was designated" + obs != null + !obs.inputNames.isEmpty() + + and: "modeling the pure returns as UFs loses neither context nor precision" + !obs.symbolicContextLoss + !obs.symbolicPrecisionLoss + + and: "the generic UFs actually entered the branch constraints (precision genuinely preserved)" + obs.anyBranchReferences("pure_Math_floorDiv_int_int") // int return + obs.anyBranchReferences("pure_Math_floorDiv_long_long") // long return + obs.anyBranchReferences("pure_Math_cbrt_double") // double return + obs.anyBranchReferences("pure_Float_intBitsToFloat_int") // float return + obs.anyBranchReferences("pure_Character_toLowerCase_char") // char return + obs.anyBranchReferences("pure_Character_isDigit_char") // boolean return + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy new file mode 100644 index 0000000..7702918 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy @@ -0,0 +1,48 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.symbolic.value.ValueFactory +import de.uzl.its.swat.symbolic.value.ValueType +import de.uzl.its.swat.symbolic.value.primitive.numeric.floatingpoint.DoubleValue +import de.uzl.its.swat.symbolic.value.primitive.numeric.floatingpoint.FloatValue +import spock.lang.See +import spock.lang.Unroll + +/** + * G4 primitive-return recovery unit (Level L0): {@link ValueFactory#createNumericalValue(ValueType, + * Object, org.sosy_lab.java_smt.api.Formula)} builds a primitive value carrying an explicit symbolic + * formula (concrete = observed), for EVERY primitive sort. This is the construction the primitive + * GETVALUE recovery uses to install a {@code pure_} UF result. It pins the per-sort cast and that + * a carried symbolic formula makes the value symbolic - including short and byte, which have no pure + * unmodeled JDK method to exercise at L2. The sort matching of int/long/double/float/char/boolean is + * additionally anchored end-to-end by PureFunctionPrimitiveAgentSpec. + */ +class PureUFPrimitiveRecoverySpec extends BaseValueSpec { + + @See("docs/heap-redesign-g4-whitelist-survey.md") + @Unroll + def "createNumericalValue installs a symbolic #type formula (concrete=#concrete)"() { + given: "a free variable formula of the matching sort (stands in for a pure_ UF application)" + def formula = mk.call(context) + + when: "the value is built from the observed concrete + the symbolic formula" + def v = ValueFactory.createNumericalValue(type, concrete, formula) + + then: "the concrete is the observed value" + v.concrete == concrete + + and: "the carried formula makes the value symbolic and exposes the input variable" + v.isSymbolic() + v.getSymbolicVariables().contains(varName) + + where: + type | concrete | varName | mk + ValueType.intValue | (5 as Integer) | "v_int" | { c -> c.formulaManager.bitvectorFormulaManager.makeVariable(32, "v_int") } + ValueType.longValue | (5L as Long) | "v_long" | { c -> c.formulaManager.bitvectorFormulaManager.makeVariable(64, "v_long") } + ValueType.shortValue | (5 as Short) | "v_short" | { c -> c.formulaManager.bitvectorFormulaManager.makeVariable(16, "v_short") } + ValueType.byteValue | (5 as Byte) | "v_byte" | { c -> c.formulaManager.bitvectorFormulaManager.makeVariable(8, "v_byte") } + ValueType.charValue | ('a' as Character) | "v_char" | { c -> c.formulaManager.bitvectorFormulaManager.makeVariable(16, "v_char") } + ValueType.booleanValue | (true as Boolean) | "v_bool" | { c -> c.formulaManager.booleanFormulaManager.makeVariable("v_bool") } + ValueType.floatValue | (1.5f as Float) | "v_float" | { c -> c.formulaManager.floatingPointFormulaManager.makeVariable("v_float", FloatValue.precision) } + ValueType.doubleValue | (2.5d as Double) | "v_dbl" | { c -> c.formulaManager.floatingPointFormulaManager.makeVariable("v_dbl", DoubleValue.precision) } + } +} diff --git a/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java b/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java new file mode 100644 index 0000000..43d4198 --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java @@ -0,0 +1,36 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 target for G4 primitive-return UF support: an instrumented method calls un-instrumented, + * pure, UNMODELED java.lang methods whose return type is a PRIMITIVE, on symbolic inputs, and branches + * on each result. Under the real agent each result is modeled as a generic {@code pure_} UF over + * its inputs (not concretized), so the branches stay symbolic with NO context loss and NO precision + * loss. Covers int, long, double, float, char and boolean returns (short/byte have no pure unmodeled + * JDK method and are pinned at L0 by PureUFPrimitiveRecoverySpec). See docs/heap-redesign-g4-* . + */ +public class PurePrimReturnTarget { + + public static void main(String[] args) { + test(7, 3.0); + } + + public static int test(@Symbolic int x, @Symbolic double d) { + int fd = Math.floorDiv(x, 3); // int + int fm = Math.floorMod(x, 3); // int + long fdl = Math.floorDiv((long) x, 3L); // long + double cb = Math.cbrt(d); // double + float fb = Float.intBitsToFloat(x); // float + char lc = Character.toLowerCase((char) x); // char + boolean dig = Character.isDigit((char) x); // boolean + + int r = 0; + if (fd > 0) r++; + if (fm > 0) r++; + if (fdl > 0L) r++; + if (cb > 0.0) r++; + if (fb > 0.0f) r++; + if (lc > 0) r++; + if (dig) r++; + return r; + } +} From 050310b27081f660174b9c64b9f28907d64ee1b7 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Tue, 30 Jun 2026 22:28:34 +0000 Subject: [PATCH 19/33] feat(heap): G4 - broaden the pure-function whitelist via the java.lang audit Scale PureMethods.WHITELIST from the starter set to 240 entries using the agent-driven purity audit (jdk-source skill rubric, one agent per class). Non-String (214): pure, deterministic, side-effect-free, UNMODELED methods from Math/StrictMath (transcendentals, exact arithmetic, floorDiv/Mod, fma, bit ops), Character (predicates/conversions), Double/Float (bits, parse, toString, max/min/sum where unmodeled), Byte/Integer (hashCode/bitCount), and Objects (checkIndex/checkFromToIndex/checkFromIndexSize). The audit correctly rejected the only genuinely-impure candidates (Math.random; Character.getName/codePointOf, which do uniName.dat I/O). StrictMath routes to MathInvocation, so its modeled names (sin/cos/sqrt/round/abs/max/min) are excluded as inert. String (26): the StringValue stubs (invokeX returning PlaceHolder) that are genuinely unmodeled, determined empirically since the StringValue model is method-by-method (invokeTrim is a stub, invokeSubstring is a real model). Soundness-filtered to drop locale-dependent (no-arg toLowerCase/toUpperCase), identity (intern), and toString. Drops 3 inert prior entries (substring x2, replace) that are actually modeled - behaviour-neutral. All entries are sound as axiom-free UFs (per the primitive-return soundness review). Verified across every owner + return sort + the String->float parse bridge; WhitelistAuditAgentSpec + StringWhitelistAgentSpec (L2). 9 L2 + 71 L0/L1 green. Co-Authored-By: Claude Opus 4.8 --- .../its/swat/symbolic/UFs/PureMethods.java | 255 ++++++++++++++++-- .../heap/StringWhitelistAgentSpec.groovy | 40 +++ .../heap/WhitelistAuditAgentSpec.groovy | 43 +++ .../targets/StringWhitelistTarget.java | 30 +++ .../targets/WhitelistAuditAgentTarget.java | 64 +++++ 5 files changed, 417 insertions(+), 15 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java create mode 100644 symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java index e88e180..d7308f1 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java @@ -27,30 +27,255 @@ private PureMethods() {} * actually fires). Curated from the java.lang purity survey * (docs/heap-redesign-g4-whitelist-survey.md); the boxed types' toString-family is intentionally * absent (already modeled -> a UF would never fire). String and primitive returns are both - * supported; the primitive block is the hand-audited starter set that doubles as the regression - * set for the primitive-return engine support (each is verified absent from its Invocation - * handler, so the UF fires). The broad agent-driven survey scales this set later. + * supported. Membership combines the String-return starter set with the java.lang/util purity audit + * (per-class agents over Math/StrictMath/Character/Integer/Byte/Float/Double/Objects applying the + * jdk-source skill's rubric); every entry is pure, deterministic, side-effect-free, and UNMODELED + * (absent from its Invocation handler / a StringValue stub), so the generic UF actually fires. */ private static final Set WHITELIST = Set.of( - // String returns (instance methods on String): - "java/lang/String/trim()Ljava/lang/String;", + // String/primitive returns on String - UF-firing (StringValue stubs), pure + locale-independent: + "java/lang/String/codePointAt(I)I", + "java/lang/String/codePointBefore(I)I", + "java/lang/String/codePointCount(II)I", + "java/lang/String/compareTo(Ljava/lang/String;)I", + "java/lang/String/compareToIgnoreCase(Ljava/lang/String;)I", + "java/lang/String/hashCode()I", + "java/lang/String/indent(I)Ljava/lang/String;", + "java/lang/String/isBlank()Z", + "java/lang/String/isEmpty()Z", + "java/lang/String/lastIndexOf(I)I", + "java/lang/String/lastIndexOf(II)I", + "java/lang/String/lastIndexOf(Ljava/lang/String;)I", + "java/lang/String/lastIndexOf(Ljava/lang/String;I)I", + "java/lang/String/matches(Ljava/lang/String;)Z", + "java/lang/String/offsetByCodePoints(II)I", + "java/lang/String/regionMatches(ILjava/lang/String;II)Z", + "java/lang/String/regionMatches(ZILjava/lang/String;II)Z", + "java/lang/String/repeat(I)Ljava/lang/String;", + "java/lang/String/replaceAll(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", + "java/lang/String/replaceFirst(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;", "java/lang/String/strip()Ljava/lang/String;", + "java/lang/String/stripIndent()Ljava/lang/String;", "java/lang/String/stripLeading()Ljava/lang/String;", "java/lang/String/stripTrailing()Ljava/lang/String;", - "java/lang/String/substring(I)Ljava/lang/String;", - "java/lang/String/substring(II)Ljava/lang/String;", - "java/lang/String/repeat(I)Ljava/lang/String;", - "java/lang/String/replace(CC)Ljava/lang/String;", - "java/lang/String/indent(I)Ljava/lang/String;", - // Primitive returns (static, value-typed inputs; all unmodeled + side-effect-free): + "java/lang/String/translateEscapes()Ljava/lang/String;", + "java/lang/String/trim()Ljava/lang/String;", + // Pure, unmodeled, value-typed methods from the java.lang/util purity audit: + "java/lang/Math/IEEEremainder(DD)D", + "java/lang/Math/absExact(I)I", + "java/lang/Math/absExact(J)J", + "java/lang/Math/acos(D)D", + "java/lang/Math/addExact(II)I", + "java/lang/Math/addExact(JJ)J", + "java/lang/Math/asin(D)D", + "java/lang/Math/atan(D)D", + "java/lang/Math/atan2(DD)D", + "java/lang/Math/cbrt(D)D", + "java/lang/Math/ceil(D)D", + "java/lang/Math/copySign(DD)D", + "java/lang/Math/copySign(FF)F", + "java/lang/Math/cosh(D)D", + "java/lang/Math/decrementExact(I)I", + "java/lang/Math/decrementExact(J)J", + "java/lang/Math/exp(D)D", + "java/lang/Math/expm1(D)D", + "java/lang/Math/floor(D)D", "java/lang/Math/floorDiv(II)I", - "java/lang/Math/floorMod(II)I", + "java/lang/Math/floorDiv(JI)J", "java/lang/Math/floorDiv(JJ)J", - "java/lang/Math/cbrt(D)D", - "java/lang/Float/intBitsToFloat(I)F", + "java/lang/Math/floorMod(II)I", + "java/lang/Math/floorMod(JI)I", + "java/lang/Math/floorMod(JJ)J", + "java/lang/Math/fma(DDD)D", + "java/lang/Math/fma(FFF)F", + "java/lang/Math/getExponent(D)I", + "java/lang/Math/getExponent(F)I", + "java/lang/Math/hypot(DD)D", + "java/lang/Math/incrementExact(I)I", + "java/lang/Math/incrementExact(J)J", + "java/lang/Math/log(D)D", + "java/lang/Math/log10(D)D", + "java/lang/Math/log1p(D)D", + "java/lang/Math/multiplyExact(II)I", + "java/lang/Math/multiplyExact(JI)J", + "java/lang/Math/multiplyExact(JJ)J", + "java/lang/Math/multiplyFull(II)J", + "java/lang/Math/multiplyHigh(JJ)J", + "java/lang/Math/negateExact(I)I", + "java/lang/Math/negateExact(J)J", + "java/lang/Math/nextAfter(DD)D", + "java/lang/Math/nextAfter(FD)F", + "java/lang/Math/nextDown(D)D", + "java/lang/Math/nextDown(F)F", + "java/lang/Math/nextUp(D)D", + "java/lang/Math/nextUp(F)F", + "java/lang/Math/pow(DD)D", + "java/lang/Math/rint(D)D", + "java/lang/Math/scalb(DI)D", + "java/lang/Math/scalb(FI)F", + "java/lang/Math/signum(D)D", + "java/lang/Math/signum(F)F", + "java/lang/Math/sinh(D)D", + "java/lang/Math/subtractExact(II)I", + "java/lang/Math/subtractExact(JJ)J", + "java/lang/Math/tan(D)D", + "java/lang/Math/tanh(D)D", + "java/lang/Math/toDegrees(D)D", + "java/lang/Math/toIntExact(J)I", + "java/lang/Math/toRadians(D)D", + "java/lang/Math/ulp(D)D", + "java/lang/Math/ulp(F)F", + "java/lang/StrictMath/IEEEremainder(DD)D", + "java/lang/StrictMath/absExact(I)I", + "java/lang/StrictMath/absExact(J)J", + "java/lang/StrictMath/acos(D)D", + "java/lang/StrictMath/addExact(II)I", + "java/lang/StrictMath/addExact(JJ)J", + "java/lang/StrictMath/asin(D)D", + "java/lang/StrictMath/atan(D)D", + "java/lang/StrictMath/atan2(DD)D", + "java/lang/StrictMath/cbrt(D)D", + "java/lang/StrictMath/ceil(D)D", + "java/lang/StrictMath/copySign(DD)D", + "java/lang/StrictMath/copySign(FF)F", + "java/lang/StrictMath/cosh(D)D", + "java/lang/StrictMath/decrementExact(I)I", + "java/lang/StrictMath/decrementExact(J)J", + "java/lang/StrictMath/exp(D)D", + "java/lang/StrictMath/expm1(D)D", + "java/lang/StrictMath/floor(D)D", + "java/lang/StrictMath/floorDiv(II)I", + "java/lang/StrictMath/floorDiv(JI)J", + "java/lang/StrictMath/floorDiv(JJ)J", + "java/lang/StrictMath/floorMod(II)I", + "java/lang/StrictMath/floorMod(JI)I", + "java/lang/StrictMath/floorMod(JJ)J", + "java/lang/StrictMath/fma(DDD)D", + "java/lang/StrictMath/fma(FFF)F", + "java/lang/StrictMath/getExponent(D)I", + "java/lang/StrictMath/getExponent(F)I", + "java/lang/StrictMath/hypot(DD)D", + "java/lang/StrictMath/incrementExact(I)I", + "java/lang/StrictMath/incrementExact(J)J", + "java/lang/StrictMath/log(D)D", + "java/lang/StrictMath/log10(D)D", + "java/lang/StrictMath/log1p(D)D", + "java/lang/StrictMath/multiplyExact(II)I", + "java/lang/StrictMath/multiplyExact(JI)J", + "java/lang/StrictMath/multiplyExact(JJ)J", + "java/lang/StrictMath/multiplyFull(II)J", + "java/lang/StrictMath/multiplyHigh(JJ)J", + "java/lang/StrictMath/negateExact(I)I", + "java/lang/StrictMath/negateExact(J)J", + "java/lang/StrictMath/nextAfter(DD)D", + "java/lang/StrictMath/nextAfter(FD)F", + "java/lang/StrictMath/nextDown(D)D", + "java/lang/StrictMath/nextDown(F)F", + "java/lang/StrictMath/nextUp(D)D", + "java/lang/StrictMath/nextUp(F)F", + "java/lang/StrictMath/pow(DD)D", + "java/lang/StrictMath/rint(D)D", + "java/lang/StrictMath/scalb(DI)D", + "java/lang/StrictMath/scalb(FI)F", + "java/lang/StrictMath/signum(D)D", + "java/lang/StrictMath/signum(F)F", + "java/lang/StrictMath/sinh(D)D", + "java/lang/StrictMath/subtractExact(II)I", + "java/lang/StrictMath/subtractExact(JJ)J", + "java/lang/StrictMath/tan(D)D", + "java/lang/StrictMath/tanh(D)D", + "java/lang/StrictMath/toDegrees(D)D", + "java/lang/StrictMath/toIntExact(J)I", + "java/lang/StrictMath/toRadians(D)D", + "java/lang/StrictMath/ulp(D)D", + "java/lang/StrictMath/ulp(F)F", + "java/lang/Character/digit(CI)I", + "java/lang/Character/digit(II)I", + "java/lang/Character/forDigit(II)C", + "java/lang/Character/getDirectionality(C)B", + "java/lang/Character/getDirectionality(I)B", + "java/lang/Character/getNumericValue(C)I", + "java/lang/Character/getNumericValue(I)I", + "java/lang/Character/getType(C)I", + "java/lang/Character/getType(I)I", + "java/lang/Character/hashCode(C)I", + "java/lang/Character/highSurrogate(I)C", + "java/lang/Character/isAlphabetic(I)Z", + "java/lang/Character/isDefined(C)Z", + "java/lang/Character/isDefined(I)Z", + "java/lang/Character/isDigit(C)Z", + "java/lang/Character/isDigit(I)Z", + "java/lang/Character/isHighSurrogate(C)Z", + "java/lang/Character/isISOControl(C)Z", + "java/lang/Character/isISOControl(I)Z", + "java/lang/Character/isIdentifierIgnorable(C)Z", + "java/lang/Character/isIdentifierIgnorable(I)Z", + "java/lang/Character/isIdeographic(I)Z", + "java/lang/Character/isJavaIdentifierPart(C)Z", + "java/lang/Character/isJavaIdentifierPart(I)Z", + "java/lang/Character/isJavaIdentifierStart(C)Z", + "java/lang/Character/isJavaIdentifierStart(I)Z", + "java/lang/Character/isJavaLetter(C)Z", + "java/lang/Character/isJavaLetterOrDigit(C)Z", + "java/lang/Character/isLetter(C)Z", + "java/lang/Character/isLetter(I)Z", + "java/lang/Character/isLetterOrDigit(C)Z", + "java/lang/Character/isLetterOrDigit(I)Z", + "java/lang/Character/isLowSurrogate(C)Z", + "java/lang/Character/isLowerCase(C)Z", + "java/lang/Character/isLowerCase(I)Z", + "java/lang/Character/isMirrored(C)Z", + "java/lang/Character/isMirrored(I)Z", + "java/lang/Character/isSpace(C)Z", + "java/lang/Character/isSpaceChar(C)Z", + "java/lang/Character/isSpaceChar(I)Z", + "java/lang/Character/isSurrogate(C)Z", + "java/lang/Character/isSurrogatePair(CC)Z", + "java/lang/Character/isTitleCase(C)Z", + "java/lang/Character/isTitleCase(I)Z", + "java/lang/Character/isUnicodeIdentifierPart(C)Z", + "java/lang/Character/isUnicodeIdentifierPart(I)Z", + "java/lang/Character/isUnicodeIdentifierStart(C)Z", + "java/lang/Character/isUnicodeIdentifierStart(I)Z", + "java/lang/Character/isUpperCase(C)Z", + "java/lang/Character/isUpperCase(I)Z", + "java/lang/Character/isWhitespace(C)Z", + "java/lang/Character/isWhitespace(I)Z", + "java/lang/Character/lowSurrogate(I)C", + "java/lang/Character/reverseBytes(C)C", "java/lang/Character/toLowerCase(C)C", - "java/lang/Character/isDigit(C)Z"); + "java/lang/Character/toLowerCase(I)I", + "java/lang/Character/toString(C)Ljava/lang/String;", + "java/lang/Character/toString(I)Ljava/lang/String;", + "java/lang/Character/toTitleCase(C)C", + "java/lang/Character/toTitleCase(I)I", + "java/lang/Character/toUpperCase(C)C", + "java/lang/Character/toUpperCase(I)I", + "java/lang/Integer/bitCount(I)I", + "java/lang/Byte/compareUnsigned(BB)I", + "java/lang/Byte/hashCode(B)I", + "java/lang/Float/hashCode(F)I", + "java/lang/Float/intBitsToFloat(I)F", + "java/lang/Float/parseFloat(Ljava/lang/String;)F", + "java/lang/Float/toHexString(F)Ljava/lang/String;", + "java/lang/Float/toString(F)Ljava/lang/String;", + "java/lang/Double/doubleToLongBits(D)J", + "java/lang/Double/doubleToRawLongBits(D)J", + "java/lang/Double/hashCode(D)I", + "java/lang/Double/longBitsToDouble(J)D", + "java/lang/Double/max(DD)D", + "java/lang/Double/min(DD)D", + "java/lang/Double/parseDouble(Ljava/lang/String;)D", + "java/lang/Double/sum(DD)D", + "java/lang/Double/toHexString(D)Ljava/lang/String;", + "java/lang/Double/toString(D)Ljava/lang/String;", + "java/util/Objects/checkFromIndexSize(III)I", + "java/util/Objects/checkFromIndexSize(JJJ)J", + "java/util/Objects/checkFromToIndex(III)I", + "java/util/Objects/checkFromToIndex(JJJ)J", + "java/util/Objects/checkIndex(II)I", + "java/util/Objects/checkIndex(JJ)J"); public static boolean isWhitelisted(String owner, String name, String desc) { return WHITELIST.contains(owner + "/" + name + desc); diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy new file mode 100644 index 0000000..e34ebcf --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy @@ -0,0 +1,40 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G4 String-whitelist anchor (Level L2): the REAL agent runs a program calling pure String methods that + * the purity audit confirmed are UNMODELED stubs in StringValue (so the generic pure_ UF fires) - + * across String, int and boolean returns - on a symbolic String receiver. Each must be modeled as a UF, + * preserving SAFE (no context loss, no precision loss) with the UFs in the branch constraints. The + * modeled String methods (substring, charAt, length, ...) are intentionally NOT whitelisted; they stay + * precise and are exercised by PureFunctionUFAgentSpec. + * + * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + */ +class StringWhitelistAgentSpec extends Specification { + + @See("docs/heap-redesign-g4-whitelist-survey.md") + def "G4 (L2): unmodeled pure String methods are modeled as UFs (no context/precision loss)"() { + when: "the agent runs a program calling pure unmodeled String stubs on a symbolic receiver" + TraceObservation obs = AgentRun.run("targets/StringWhitelistTarget.java", "StringWhitelistTarget") + + then: "the run completed and the symbolic input was designated" + obs != null + !obs.inputNames.isEmpty() + + and: "modeling the pure results as UFs loses neither context nor precision" + !obs.symbolicContextLoss + !obs.symbolicPrecisionLoss + + and: "the generic UFs entered the branch constraints, across return sorts" + obs.anyBranchReferences("pure_String_hashCode") // int + obs.anyBranchReferences("pure_String_matches_String") // boolean + obs.anyBranchReferences("pure_String_compareTo_String") // int + obs.anyBranchReferences("pure_String_repeat_int") // String + obs.anyBranchReferences("pure_String_isBlank") // boolean + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy new file mode 100644 index 0000000..af639b6 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy @@ -0,0 +1,43 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.See +import spock.lang.Specification + +/** + * G4 broadened-whitelist anchor (Level L2): the REAL agent runs a program exercising the java.lang/util + * purity-audit additions across all eight audited owners (Math, StrictMath, Character, Integer, Byte, + * Float, Double, Objects) and every return sort, including the String->float parse bridge. Every result + * must be modeled as a generic pure_ UF, so the run preserves SAFE: no context loss, no precision + * loss, and the UFs ride into the branch constraints. Companion to PureFunctionPrimitiveAgentSpec. + * + * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + */ +class WhitelistAuditAgentSpec extends Specification { + + @See("docs/heap-redesign-g4-whitelist-survey.md") + def "G4 (L2): audited whitelist additions are modeled as UFs across all classes (no context/precision loss)"() { + when: "the agent runs a program calling pure unmodeled JDK methods from the audit on symbolic inputs" + TraceObservation obs = AgentRun.run("targets/WhitelistAuditAgentTarget.java", "WhitelistAuditAgentTarget") + + then: "the run completed and symbolic inputs were designated" + obs != null + !obs.inputNames.isEmpty() + + and: "modeling the pure results as UFs loses neither context nor precision" + !obs.symbolicContextLoss + !obs.symbolicPrecisionLoss + + and: "the generic UFs entered the branch constraints, one representative per audited owner + sort" + obs.anyBranchReferences("pure_Math_pow_double_double") // Math, double + obs.anyBranchReferences("pure_StrictMath_cbrt_double") // StrictMath, double + obs.anyBranchReferences("pure_Math_addExact_int_int") // Math, int + obs.anyBranchReferences("pure_Integer_bitCount_int") // Integer, int + obs.anyBranchReferences("pure_Byte_hashCode_byte") // Byte, int + obs.anyBranchReferences("pure_Double_doubleToLongBits_double") // Double, long + obs.anyBranchReferences("pure_Character_isLetter_char") // Character, boolean + obs.anyBranchReferences("pure_Objects_checkIndex_int_int") // Objects, int + obs.anyBranchReferences("pure_Float_parseFloat_String") // Float, String->float bridge + } +} diff --git a/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java b/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java new file mode 100644 index 0000000..7a775e0 --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java @@ -0,0 +1,30 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 regression target for the String purity-audit additions (the StringValue stubs that the + * audit confirmed are unmodeled, so the generic pure_ UF fires). Drives a representative few - + * String, int and boolean returns - on a symbolic String receiver into branches. Single String + * parameter only (a category-2 double followed by a reference param trips an unrelated frame-analysis + * fragility in the instrumenter). See docs/heap-redesign-g4-whitelist-survey.md (Level L2). + */ +public class StringWhitelistTarget { + + public static void main(String[] args) { + test("hello world 42"); + } + + public static int test(@Symbolic String s) { + int h = s.hashCode(); // int + boolean m = s.matches(".*"); // boolean + int c = s.compareTo("x"); // int + String rp = s.repeat(2); // String + boolean b = s.isBlank(); // boolean + int r = 0; + if (h != 0) r++; + if (m) r++; + if (c != 0) r++; + if (rp.length() > 0) r++; + if (b) r++; + return r; + } +} diff --git a/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java b/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java new file mode 100644 index 0000000..51a8853 --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java @@ -0,0 +1,64 @@ +import de.uzl.its.swat.annotations.Symbolic; + +/** + * Level-2 regression target for the broadened G4 purity whitelist (the java.lang/util audit additions). + * Each method drives a few pure, unmodeled, value-typed JDK methods - across all eight audited owners + * and every return sort, including the String->float parse bridge - on a symbolic input into a branch. + * Under the real agent every result must be modeled as a generic pure_ UF, so the run preserves + * SAFE (no context loss, no precision loss). Methods are kept small/separate to avoid an unrelated + * frame-analysis fragility in the instrumenter on large mixed-type method bodies. + */ +public class WhitelistAuditAgentTarget { + + public static void main(String[] args) { + ma(2.0); + mb(5); + mc(5, 2.0); + md("12.5"); + } + + // Math/StrictMath, double returns. + public static int ma(@Symbolic double d) { + double a = Math.tan(d), b = Math.pow(d, 2.0), c = Math.log(d), e = StrictMath.cbrt(d), f = Math.exp(d); + int r = 0; + if (a > 0) r++; + if (b > 0) r++; + if (c > 0) r++; + if (e > 0) r++; + if (f > 0) r++; + return r; + } + + // Math exact-arithmetic + Integer/Byte, int returns. + public static int mb(@Symbolic int x) { + int a = Math.addExact(x, 3), b = Math.multiplyExact(x, 2), c = Math.floorMod(x, 3), + e = Integer.bitCount(x), f = Byte.hashCode((byte) x); + int r = 0; + if (a > 0) r++; + if (b > 0) r++; + if (c > 0) r++; + if (e > 0) r++; + if (f > 0) r++; + return r; + } + + // Double / Character (boolean) / Objects, mixed return sorts. + public static int mc(@Symbolic int x, @Symbolic double d) { + long a = Double.doubleToLongBits(d); + double b = Double.max(d, 2.0); + boolean c = Character.isLetter((char) x); + int e = java.util.Objects.checkIndex(x, 100); + int r = 0; + if (a != 0) r++; + if (b > 0) r++; + if (c) r++; + if (e >= 0) r++; + return r; + } + + // String -> float parse bridge. + public static int md(@Symbolic String s) { + float p = Float.parseFloat(s); + return p > 0 ? 1 : 0; + } +} From 851925376b677500fd2b03ea9a67e6ca7a4de379 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:32:58 +0000 Subject: [PATCH 20/33] refactor(heap): remove unused shadow-registry inspection accessors ShadowContext.heapLookup/heapEntries and JVMHeap.values() had no callers; only heapSize() is used (by the identity-registry tests). Drop them and the now-unused Collection import. Co-Authored-By: Claude Opus 4.8 --- .../uzl/its/swat/symbolic/shadow/JVMHeap.java | 10 ---------- .../swat/symbolic/shadow/ShadowContext.java | 19 ------------------- 2 files changed, 29 deletions(-) diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java index 923b1d5..73f1ffb 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/JVMHeap.java @@ -3,7 +3,6 @@ import com.google.common.collect.MapMaker; import de.uzl.its.swat.symbolic.value.Value; -import java.util.Collection; import java.util.Map; /** @@ -53,13 +52,4 @@ public void put(Object ref, Value value) { public int size() { return objects.size(); } - - /** - * All registered shadow values, for "one wrapper per identity" inspection. - * - * @return the registered values. - */ - public Collection> values() { - return objects.values(); - } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java index b722d83..dd008de 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowContext.java @@ -61,25 +61,6 @@ public int heapSize() { return heap.size(); } - /** - * Looks up a registered shadow value by its concrete object reference, without mutating state. - * - * @param ref The concrete object (identity key). - * @return The registered value, or null if none. - */ - public Value heapLookup(Object ref) { - return heap.get(ref); - } - - /** - * All registered shadow values, for "one wrapper per identity" inspection. - * - * @return the registered values. - */ - public Collection> heapEntries() { - return heap.values(); - } - /** * Pushes a new frame onto the stack and makes it the active frame. A new frame on this stack * represents a new method invocation. From cd45979762bf281851de48b149cdd544c503cf36 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:32:58 +0000 Subject: [PATCH 21/33] test(heap): remove @See doc-link annotations Strip all @See annotations and the spock.lang.See imports from the heap specs. Linking tests to design docs is worthwhile but needs a considered scheme; removing the ad-hoc links for now. Co-Authored-By: Claude Opus 4.8 --- .../de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy | 6 ------ .../its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy | 2 -- .../de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy | 3 --- .../de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy | 3 --- .../its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy | 3 --- .../de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy | 5 ----- .../de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy | 6 ------ .../swat/symbolic/heap/PrecisionLossExemptionSpec.groovy | 6 ------ .../symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy | 2 -- .../its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy | 3 --- .../de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy | 6 ------ .../swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy | 2 -- .../uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy | 2 -- .../its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy | 2 -- .../de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy | 3 --- .../de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy | 4 ---- .../its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy | 2 -- 17 files changed, 60 deletions(-) diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy index 8fda842..1f7a8b1 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy @@ -1,6 +1,5 @@ package de.uzl.its.swat.common -import spock.lang.See import spock.lang.Specification /** @@ -12,7 +11,6 @@ import spock.lang.Specification */ class ProvenanceRefEqualsSpec extends Specification { - @See("docs/heap-redesign-g3-design.md") def "de-interned Strings rooting to the same interned canonical compare equal"() { given: "two distinct de-interned copies of the same literal, both rooted to the interned canonical" String canonical = "g3b-abc".intern() @@ -27,7 +25,6 @@ class ProvenanceRefEqualsSpec extends Specification { UtilInstrumented.refEquals(a, b) } - @See("docs/heap-redesign-g3-design.md") def "a de-interned copy vs a same-valued object with a different root compares unequal"() { given: "a rooted to the interned canonical; b has no provenance entry (root(b)=b)" String a = new String("g3b-x") @@ -38,7 +35,6 @@ class ProvenanceRefEqualsSpec extends Specification { !UtilInstrumented.refEquals(a, b) } - @See("docs/heap-redesign-g3-design.md") def "boxed copies rooting to the cached canonical compare equal (cache range)"() { given: Integer a = new Integer(100) @@ -51,7 +47,6 @@ class ProvenanceRefEqualsSpec extends Specification { UtilInstrumented.refEquals(a, b) } - @See("docs/heap-redesign-g3-design.md") def "non-de-interned classes use plain reference equality"() { given: Object a = new Object() @@ -62,7 +57,6 @@ class ProvenanceRefEqualsSpec extends Specification { UtilInstrumented.refEquals(a, a) } - @See("docs/heap-redesign-g3-design.md") def "root collapses chains at insert"() { given: "c rooted to b, b rooted to canonical -> root(c) must resolve to canonical" String canonical = "g3b-chain".intern() diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy index 8d9cb48..78e257a 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -20,7 +19,6 @@ import spock.lang.Specification */ class BoxedDeInternAgentSpec extends Specification { - @See("docs/heap-redesign-g3-design.md") def "G3-A2 (L2): all six wrappers' valueOf-rewrite (+ Integer/Long de-intern) load, verify, and run"() { when: "the agent runs a program driving the boxed de-intern bytecode paths" TraceObservation obs = AgentRun.run("targets/BoxedReturnTarget.java", "BoxedReturnTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy index 3c29acc..36cc9e8 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy @@ -3,7 +3,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.common.Util import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue -import spock.lang.See /** * Context-loss flag policy at Level L1: the flag fires iff symbolic data flowed into the unmodeled @@ -15,7 +14,6 @@ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { private static final String STRING = "java/lang/String" private static final String TO_LOWER = "()Ljava/lang/String;" - @See("docs/heap-redesign-tests.md") def "F-1: an unmodeled call with a concrete receiver raises no context-loss flag"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -28,7 +26,6 @@ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { !result.contextLoss } - @See("docs/heap-redesign-tests.md") def "F-2: an unmodeled call with a symbolic receiver raises a context-loss flag"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy index b92f020..ad3fa04 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy @@ -12,7 +12,6 @@ import de.uzl.its.swat.symbolic.processor.SymbolicInstructionProcessor import de.uzl.its.swat.symbolic.shadow.ShadowDivergence import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.thread.ThreadHandler -import spock.lang.See /** * E-1 / E-2 (Level L1): out-of-band change detection at a primitive GETVALUE sync. When the value the @@ -47,7 +46,6 @@ class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { } } - @See("docs/heap-redesign-tests.md") def "E-1: under FLAG a diverging primitive GETVALUE is detected (flag + re-ground), not crashed"() { given: "a tracked int shadow with concrete 10, under the FLAG policy" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -63,7 +61,6 @@ class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { visitor.getStack().getActiveFrame().peek().concrete == 20 } - @See("docs/heap-redesign-tests.md") def "E-2: under FLAG a matching primitive GETVALUE records no flag (no false positive)"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy index 0798ab7..cf4c263 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -16,7 +15,6 @@ import spock.lang.Specification */ class HeapRecoveryV1AgentSpec extends Specification { - @See("docs/test-architecture.md") def "L2 soundness anchor: real toLowerCase on a symbolic string flags context loss"() { when: TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") @@ -26,7 +24,6 @@ class HeapRecoveryV1AgentSpec extends Specification { obs.symbolicContextLoss } - @See("docs/heap-redesign-tests.md") def "V-1 (L2): a branch after unmodeled toLowerCase must not reference the symbolic input"() { when: TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy index 11c219c..594877e 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy @@ -3,7 +3,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.symbolic.shadow.ShadowContext import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.symbolic.value.reference.ObjectValue -import spock.lang.See /** * O-4 / O-5 — object reference-equality and the canonical registry. Level L0, Phase 1 (G1). @@ -21,7 +20,6 @@ class ObjectIdentitySpec extends BaseValueSpec { return new ObjectValue(context, "de/uzl/its/swat/test/Obj", new IntValue(context, 1), address) } - @See("docs/heap-redesign-tests.md") def "O-4: two references to distinct objects compare reference-unequal"() { given: ObjectValue a = objectAt(0x2000) @@ -31,7 +29,6 @@ class ObjectIdentitySpec extends BaseValueSpec { isUnsatisfiable(a.IF_ACMPEQ(c)) } - @See("docs/heap-redesign-tests.md") def "O-4: the same concrete object recovers the same canonical wrapper (reference-equal)"() { given: "a shadow registered under a concrete object" Object obj = new Object() @@ -48,7 +45,6 @@ class ObjectIdentitySpec extends BaseValueSpec { isValid((a as ObjectValue).IF_ACMPEQ(b as ObjectValue)) } - @See("docs/heap-redesign-tests.md") def "O-5: distinct objects with a colliding identity hash compare reference-unequal"() { given: "two distinct objects whose identity hash (address) collides" ObjectValue a = objectAt(0x5000) @@ -58,7 +54,6 @@ class ObjectIdentitySpec extends BaseValueSpec { isUnsatisfiable(a.IF_ACMPEQ(b)) } - @See("docs/heap-redesign-tests.md") def "O-5: distinct concrete objects are stored without merging (reference keying)"() { given: "two distinct concrete objects, with shadows that happen to share an address" Object o1 = new Object() diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy index f2df8fa..bdfb1a9 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy @@ -14,7 +14,6 @@ import de.uzl.its.swat.symbolic.value.reference.ObjectValue import de.uzl.its.swat.symbolic.value.reference.lang.IntegerObjectValue import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import de.uzl.its.swat.thread.ThreadHandler -import spock.lang.See /** * G3 register-only-non-constant (Level L1). At the value-typed GETVALUE recovery, a de-interned value @@ -44,7 +43,6 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { } } - @See("docs/heap-redesign-g3-design.md") def "G3: a symbolic String shadow IS heap-registered (round-trip enabled)"() { given: "a symbolic String shadow awaiting its address (ADDRESS_UNKNOWN)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -59,7 +57,6 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null } - @See("docs/heap-redesign-g3-design.md") def "G3: a constant String shadow is NOT heap-registered (reconstructible; no leak)"() { given: "a pure-constant String shadow (formula = makeString) awaiting its address" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -73,7 +70,6 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null } - @See("docs/heap-redesign-g3-design.md") def "G3-A2: a symbolic boxed shadow IS heap-registered (round-trip enabled)"() { given: "a symbolic Integer shadow awaiting its address (formula carried by the inner IntValue)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -89,7 +85,6 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null } - @See("docs/heap-redesign-g3-design.md") def "G3-A2: a constant boxed shadow is NOT heap-registered (reconstructible; no leak)"() { given: "a pure-constant Integer shadow awaiting its address" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -104,7 +99,6 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null } - @See("docs/heap-redesign-g3-design.md") def "G3-A2: a mutable (non-value-type) object is ALWAYS registered (shared path unaffected)"() { given: "a plain mutable object whose class is not de-interned" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy index dd264ee..99aec7e 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy @@ -5,7 +5,6 @@ import org.sosy_lab.java_smt.api.BooleanFormula import org.sosy_lab.java_smt.api.Formula import org.sosy_lab.java_smt.api.FormulaType import org.sosy_lab.java_smt.api.StringFormula -import spock.lang.See /** * G4 exemption controls (Level L0): {@link DTOBuilder#isPrecisionLoss} must treat a whitelisted @@ -33,7 +32,6 @@ class PrecisionLossExemptionSpec extends BaseValueSpec { return fmgr.getStringFormulaManager().equal(s, fmgr.getStringFormulaManager().makeString("abc")) } - @See("docs/heap-redesign-g4-design.md") def "a designated input variable alone is not precision loss (incl. a String input the regex rejected)"() { given: def v = inputVar() @@ -41,13 +39,11 @@ class PrecisionLossExemptionSpec extends BaseValueSpec { !DTOBuilder.isPrecisionLoss(eqAbc(v), fmgr, inputs(v)) } - @See("docs/heap-redesign-g4-design.md") def "a non-input variable is precision loss"() { expect: DTOBuilder.isPrecisionLoss(eqAbc(otherVar()), fmgr, inputs(inputVar())) } - @See("docs/heap-redesign-g4-design.md") def "a pure_ UF over an input variable is NOT precision loss (the exemption)"() { given: def v = inputVar() @@ -55,13 +51,11 @@ class PrecisionLossExemptionSpec extends BaseValueSpec { !DTOBuilder.isPrecisionLoss(eqAbc(ufOver("pure_String_trim", v)), fmgr, inputs(v)) } - @See("docs/heap-redesign-g4-design.md") def "a pure_ UF over a NON-input variable is still precision loss (transitivity)"() { expect: DTOBuilder.isPrecisionLoss(eqAbc(ufOver("pure_String_trim", otherVar())), fmgr, inputs(inputVar())) } - @See("docs/heap-redesign-g4-design.md") def "a bespoke (non-pure_) UF stays non-exempt (precision loss)"() { given: def v = inputVar() diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy index ce58c39..3d3fa00 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -17,7 +16,6 @@ import spock.lang.Specification */ class PureFunctionPrimitiveAgentSpec extends Specification { - @See("docs/heap-redesign-g4-whitelist-survey.md") def "G4 (L2): pure primitive-returning methods are modeled as UFs (no context/precision loss)"() { when: "the agent runs a program branching on pure unmodeled primitive returns over symbolic inputs" TraceObservation obs = AgentRun.run("targets/PurePrimReturnTarget.java", "PurePrimReturnTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy index 3ed97fe..a4df8fe 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -18,7 +17,6 @@ import spock.lang.Specification */ class PureFunctionUFAgentSpec extends Specification { - @See("docs/heap-redesign-g4-design.md") def "G4 (L2): a whitelisted pure trim into a branch preserves SAFE (no context/precision loss)"() { when: TraceObservation obs = AgentRun.run("targets/TrimTarget.java", "TrimTarget") @@ -35,7 +33,6 @@ class PureFunctionUFAgentSpec extends Specification { !obs.symbolicPrecisionLoss } - @See("docs/heap-redesign-g4-whitelist-survey.md") def "G4 (L2): a whitelisted pure substring (arg-taking, mixed-sort UF) into a branch preserves SAFE"() { when: TraceObservation obs = AgentRun.run("targets/SubstringTarget.java", "SubstringTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy index 407b97d..3fd8b9e 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy @@ -7,7 +7,6 @@ import de.uzl.its.swat.thread.ThreadHandler import org.sosy_lab.java_smt.api.BooleanFormula import org.sosy_lab.java_smt.api.Formula import org.sosy_lab.java_smt.api.StringFormula -import spock.lang.See /** * G4 generic-UF mechanism (Level L1). A whitelisted, pure, UNMODELED value-returning call @@ -45,7 +44,6 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { } finally { p.close() } } - @See("docs/heap-redesign-tests.md") def "U-5: trim (whitelisted, unmodeled) models its result as a UF over the input"() { given: "a symbolic String receiver" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -63,7 +61,6 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { result.recovered.concrete == "abc" } - @See("docs/heap-redesign-tests.md") def "U-4: equal inputs yield equal results (UF congruence / determinism)"() { given: "two independently symbolic String receivers" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -82,7 +79,6 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { isValid(bmgr.implication(premise, conclusion)) } - @See("docs/heap-redesign-tests.md") def "U-soundness: the axiom-free UF excludes no real behavior (result can equal any value)"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -97,7 +93,6 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { isSat(smgr.equal(result.recovered.formula as StringFormula, smgr.makeString("a totally different value"))) } - @See("docs/heap-redesign-g4-design.md") def "U-7: a whitelisted pure call emits its observed (input->output) pair as a ground UF constraint"() { given: "a symbolic String receiver with a known concrete value" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -117,7 +112,6 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { fm.extractVariables(pair).isEmpty() } - @See("docs/heap-redesign-tests.md") def "U-6: a whitelisted pure call is modeled (UF), so it does NOT flag context loss"() { given: "a symbolic String receiver" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy index 7702918..0be6835 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy @@ -4,7 +4,6 @@ import de.uzl.its.swat.symbolic.value.ValueFactory import de.uzl.its.swat.symbolic.value.ValueType import de.uzl.its.swat.symbolic.value.primitive.numeric.floatingpoint.DoubleValue import de.uzl.its.swat.symbolic.value.primitive.numeric.floatingpoint.FloatValue -import spock.lang.See import spock.lang.Unroll /** @@ -18,7 +17,6 @@ import spock.lang.Unroll */ class PureUFPrimitiveRecoverySpec extends BaseValueSpec { - @See("docs/heap-redesign-g4-whitelist-survey.md") @Unroll def "createNumericalValue installs a symbolic #type formula (concrete=#concrete)"() { given: "a free variable formula of the matching sort (stands in for a pure_ UF application)" diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy index ce0086c..128d3a9 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -15,7 +14,6 @@ import spock.lang.Specification */ class StringRefEqAgentSpec extends Specification { - @See("docs/heap-redesign-g3-design.md") def "G3-B (L2): de-interned == is modeled by provenance root and fires neither soundness flag"() { when: TraceObservation obs = AgentRun.run("targets/StringRefEqTarget.java", "StringRefEqTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy index e34ebcf..af29b1e 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -17,7 +16,6 @@ import spock.lang.Specification */ class StringWhitelistAgentSpec extends Specification { - @See("docs/heap-redesign-g4-whitelist-survey.md") def "G4 (L2): unmodeled pure String methods are modeled as UFs (no context/precision loss)"() { when: "the agent runs a program calling pure unmodeled String stubs on a symbolic receiver" TraceObservation obs = AgentRun.run("targets/StringWhitelistTarget.java", "StringWhitelistTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy index 2f40c58..d950a70 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -4,7 +4,6 @@ import de.uzl.its.swat.common.Util import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import org.sosy_lab.java_smt.api.Formula -import spock.lang.See /** * Value-typed boundary recovery at Level L1 (the workhorse fixture). An unmodeled value-returning @@ -22,7 +21,6 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { return solverContext.getFormulaManager().extractVariables(value.formula as Formula).keySet() } - @See("docs/heap-redesign-tests.md") def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { given: "a symbolic, already-lowercase String receiver registered on the heap" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") @@ -47,7 +45,6 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { varsOf(result.recovered).disjoint(receiverVars) } - @See("docs/heap-redesign-tests.md") def "V-2: a new-object transform return does not alias the receiver (consistent with V-1)"() { given: "a symbolic, concretely upper-case String receiver (toLowerCase returns a NEW object)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy index a345b24..0ce436f 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy @@ -5,7 +5,6 @@ import de.uzl.its.swat.symbolic.value.reference.ObjectValue import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import org.objectweb.asm.Type import org.sosy_lab.java_smt.api.Formula -import spock.lang.See /** * Value-typed semantics at Level L0 — String identity/value rules that hold by construction @@ -19,7 +18,6 @@ class ValueSemanticsSpec extends BaseValueSpec { return fmgr.extractVariables(v.formula as Formula).keySet() } - @See("docs/heap-redesign-tests.md") def "V-5: new String(s) keeps the source's symbolic formula (committed copy-ctor model)"() { given: "a symbolic source string and a fresh target" StringValue s = new StringValue(context, "abc", ObjectValue.ADDRESS_UNKNOWN) @@ -35,7 +33,6 @@ class ValueSemanticsSpec extends BaseValueSpec { varsOf(t) == varsOf(s) } - @See("docs/heap-redesign-tests.md") def "V-6: reusing the same literal yields the same constant formula with no spurious vars"() { given: StringValue a = new StringValue(context, "lit", ObjectValue.ADDRESS_UNKNOWN) @@ -47,7 +44,6 @@ class ValueSemanticsSpec extends BaseValueSpec { isValid(a.IF_ACMPEQ(b)) } - @See("docs/heap-redesign-tests.md") def "V-9: making a value symbolic preserves its concrete grounding"() { given: StringValue s = new StringValue(context, "seed", ObjectValue.ADDRESS_UNKNOWN) diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy index af639b6..dbc2a31 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy @@ -2,7 +2,6 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.testsupport.agent.AgentRun import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.See import spock.lang.Specification /** @@ -16,7 +15,6 @@ import spock.lang.Specification */ class WhitelistAuditAgentSpec extends Specification { - @See("docs/heap-redesign-g4-whitelist-survey.md") def "G4 (L2): audited whitelist additions are modeled as UFs across all classes (no context/precision loss)"() { when: "the agent runs a program calling pure unmodeled JDK methods from the audit on symbolic inputs" TraceObservation obs = AgentRun.run("targets/WhitelistAuditAgentTarget.java", "WhitelistAuditAgentTarget") From b62bb5e4c7b07bd775351ee1b990c216847b8d4b Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:32:58 +0000 Subject: [PATCH 22/33] docs(heap): consolidate heap tracking into a single reference Add docs/heap-tracking.md: a plain-language, present-tense reference for how the executor tracks values and objects (registry, boundary recovery, out-of-band detection, de-interning and reference equality, pure-function UFs, soundness flags) with code pointers and the two not-yet-implemented mechanisms. Delete the point-in-time process docs it supersedes. Fix stale references to the removed reference-semantic-change flag and heap accessors in test-architecture.md, the swat-test skill, and the sv-comp failure analyzer. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/swat-test/SKILL.md | 4 +- docs/heap-redesign-implementation-handoff.md | 142 -------- docs/heap-redesign-plan.md | 72 ---- docs/heap-redesign-testing-setup.md | 83 ----- docs/heap-tracking.md | 316 ++++++++++++++++++ docs/test-architecture.md | 8 +- .../sv-comp/scripts/lib/analysis/failures.py | 2 - 7 files changed, 322 insertions(+), 305 deletions(-) delete mode 100644 docs/heap-redesign-implementation-handoff.md delete mode 100644 docs/heap-redesign-plan.md delete mode 100644 docs/heap-redesign-testing-setup.md create mode 100644 docs/heap-tracking.md diff --git a/.claude/skills/swat-test/SKILL.md b/.claude/skills/swat-test/SKILL.md index d9b089f..d39676d 100644 --- a/.claude/skills/swat-test/SKILL.md +++ b/.claude/skills/swat-test/SKILL.md @@ -45,7 +45,7 @@ with `context`. Example: `ObjectIdentitySpec` (O-4). `setupTestContext(className, method)` first. For an unmodeled-call recovery, use the fixture: ```groovy def result = executeBoundaryRecovery(receiver, owner, name, desc, concreteResult, resultAddress) -// result.recovered (Value), result.contextLoss, result.referenceSemanticChange +// result.recovered (Value), result.contextLoss // resultAddress == receiver.address -> this-return; fresh address -> new-object return ``` Example: `HeapRecoveryV1Spec` (V-1). @@ -55,7 +55,7 @@ Example: `HeapRecoveryV1Spec` (V-1). `*AgentSpec` and use the harness: ```groovy TraceObservation obs = AgentRun.run("targets/.java", "") -// obs.symbolicContextLoss / symbolicPrecisionLoss / referenceSemanticChange +// obs.symbolicContextLoss / symbolicPrecisionLoss // obs.inputNames ; obs.anyBranchReferences(inputVar) ``` `AgentRun` compiles against the agent jar, forks a JVM with `solver.mode=PRINT`, and parses the diff --git a/docs/heap-redesign-implementation-handoff.md b/docs/heap-redesign-implementation-handoff.md deleted file mode 100644 index b328514..0000000 --- a/docs/heap-redesign-implementation-handoff.md +++ /dev/null @@ -1,142 +0,0 @@ -# Shadow Heap Redesign — Implementation Handoff - -You are implementing the shadow-heap redesign (G1, G2, G_oob, G3, G4). The **tests are already -written** and act as your acceptance criteria: a small set is intentionally *red* (failing) and -encodes the behavior you must produce. This doc tells you what exists, what to make pass, where the -bugs live, and the invariants you must not break. - -Branch: **`fix/heap-design`** (off `dev`). Two commits: `a7557fa` (harness foundation), -`b19df47` (case-matrix fan-out). - -Read first: `docs/heap-redesign-plan.md` (the goals G1–G4 + phase order), -`docs/heap-redesign-tests.md` (behavioral cases), `docs/test-architecture.md` (the test levels). - -## The test contract (how to work) - -- Tests live in `symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/` (L0/L1) and - `.../testsupport/agent/` + `.../heap/*AgentSpec` (L2). -- **Red cases are marked `@PendingFeature(reason=…)`.** They run and assert the *desired* behavior; - while failing they report as *skipped* (the suite stays green). **When your change makes one - pass, `@PendingFeature` turns it into a BUILD FAILURE** that says "remove the annotation" — that - is your signal the fix landed. Then delete the `@PendingFeature` line and commit. -- **Green tests are regression guards — they must stay green.** -- Current status: **33 tests, 29 passed, 4 pending, 0 failed.** - -Run: -```bash -# L0 + L1 (fast, in-process). Scope to these packages — the legacy de/uzl/its/value/** suite -# (~1670 rows) fails for unrelated reasons; do NOT run the whole `test` task. -./gradlew :symbolic-executor:test --tests "de.uzl.its.swat.symbolic.heap.*" \ - --tests "de.uzl.its.swat.symbolic.processor.*" -# L2 (forked real agent; builds the jar first) -./gradlew :symbolic-executor:agentTest -``` -Build the agent jar with `:symbolic-executor:copyJar`. **Never run `spotlessApply`/`spotlessCheck`** -— it reformats the whole module and buries your diff; CI does not gate on it. - -## What to implement, mapped to the red tests - -### Phase 1 — G1: canonical registry + faithful key + register-on-create -Makes these flip green: -- **O-4** (`ObjectIdentitySpec`, L0): two shadow wrappers for one identity must compare - reference-equal. Today `ObjectValue.IF_ACMPEQ` is `bfm.makeBoolean(this == o2)` on the *wrapper* - (`ObjectValue.java:152`), so duplicate wrappers compare unequal. Fix = one canonical wrapper per - identity (or compare by identity/address), so `IF_ACMPEQ` is correct. **This is a correctness fix** - (non-String `IF_ACMPEQ` drives real `==` semantics), not cleanup. -- **O-5 "heap stores colliding-hash objects without merging"** (`ObjectIdentitySpec`, L0): - two distinct objects sharing an identity hash must not collapse. Today `JVMHeap` is - `Map`, so a hash collision overwrites. Fix = a faithful, - collision-free, reference-keyed registry (weak `IdentityHashMap` is the default choice). - ⚠ **This test currently drives the legacy `ShadowContext.putToHeap(int)` API.** When you change - the registration API to find-or-create by faithful key, **update O-5's `when:` block** to register - via the new API — the *assertion* (`heapSize() == 2`) is the stable contract and must hold. - -Also covered by G1 but **not yet tested** (write these as you build the re-entry path): O-1 -(aliasing through fields), O-2 (re-entry recovers the same object), O-3 (object born in unmodeled -code), O-6 (identity reuse after death/eviction). The L1 boundary fixture (below) is the starting -point for O-2/O-3. - -### Phase 2 — G2: value-type boundary recovery (collapse policy v1) -Makes these flip green: -- **V-1** (`ValueRecoverySpec`, L1): a symbolic, already-lowercase String receiver of an unmodeled - `toLowerCase()` returns `this`; the recovered result must **not** carry the receiver's symbolic - formula (assert: recovered vars disjoint from receiver vars), must be concrete, and context loss - must be flagged (the flag already fires today). Root cause: `SymbolicInstructionVisitor` - `visitGETVALUE_Object` (~`:1265`) reconciles the `PlaceHolder` result by - `getFromHeap(inst.address)` and pushes the receiver's `StringValue` back — identity-recovery of a - value type. Fix (per plan §G2 v1 "collapse"): at a **mirrored invoke boundary of an unmodeled - value-returning method**, do NOT identity-recover for value types (`Util.deInternedClasses` = - String + 6 boxed wrappers); produce the concretized value + context-loss flag. -- **V-1 (L2)** (`HeapRecoveryV1AgentSpec`): the same scenario run through the REAL agent - (`ToLowerCaseTarget`); assert the branch on the result does not reference the symbolic input - variable. This is the faithful end-to-end check — it must flip together with the L1 V-1. - -Already green and must stay green: **V-2** (new-object transform doesn't alias — same fixture, fresh -result address), **V-5** (`new String(String)` copy ctor keeps φ — keep `StringValue.invokeInit` -copying the formula, `StringValue.java:217`), **V-6** (interned-literal reuse), **V-9** (concrete -grounding), **F-1/F-2** (context-loss flag fires iff symbolic data flows in). - -Not yet tested (Phase-2+): V-3 (single-φ round-trip — needs a field round-trip fixture, distinct -from the invoke fixture), V-4 (conflicting-φ set → realize but recover concrete), V-7 (boxed -analogue), V-8 (primitive context loss — needs a primitive-return fixture). - -### Later phases (no tests yet — add them when the mechanism exists) -G_oob out-of-band change detection (E-1, needs an L2 mutating target); G4a purity whitelist -(E-2, F-4); G3 output de-interning (D-1/D-2/D-3); G4b UF + soundness (U-1…U-5). - -## Invariants you must preserve (the harness depends on them) - -1. **Heap inspection view.** `ShadowContext.heapSize()/heapLookup(int)/heapEntries()` and - `JVMHeap.size()/values()` are the read-only seam the O-tests assert against. Your new - registry **must keep implementing these** (same semantics: `heapEntries()` = one entry per - canonical identity; `heapSize()` counts distinct identities). Tests flip red→green without - changing their assertions. -2. **Soundness flags + getters.** `SymbolicTraceHandler.isSymbolicContextLoss()` / - `isReferenceSemanticChange()` and the `TraceDTO` booleans (`symbolicContextLoss`, - `symbolicPrecisionLoss`, `referenceSemanticChange`) are read by L1 and L2 tests. Keep them. -3. **L1 fixture.** `BaseSymbolicInstructionProcessorSpec.executeBoundaryRecovery(receiver, owner, - name, desc, concreteResult, resultAddress)` drives INVOKEVIRTUAL→INVOKEMETHOD_END→GETVALUE_Object. - `resultAddress == receiver.address` = this-return; fresh address = new object. If you change the - recovery API/addresses, keep this fixture working (or update it in lockstep). -4. **L2 channel.** `solver.mode=PRINT` prints the `TraceDTO` JSON to stdout (`Intrinsics.terminate()` - PRINT branch). `AgentRun` parses it. Don't break PRINT mode. - -## Key code locations (audit-confirmed; verify line numbers, they drift) - -- Recovery cache: `symbolic/shadow/JVMHeap.java`, `symbolic/shadow/ShadowContext.java` - (`putToHeap`/`getFromHeap`). -- The V-1 recovery path: `symbolic/SymbolicInstructionVisitor.java` `visitGETVALUE_Object` - (~`:1242`–`1340`); the heap miss-create at ~`:1289`. -- The `==` bug: `symbolic/value/reference/ObjectValue.java` `IF_ACMPEQ` (`:152`), `equals` - (`:126` — note it NPEs when `fields == null`; tighten if you touch it). -- Unmodeled stub: `symbolic/value/reference/lang/StringValue.java` `invokeToLowerCase` (`:1125`, - returns `PlaceHolder.instance`); copy ctor `invokeInit` (`:217`). -- Flag decision: `symbolic/invoke/InvocationHandler.java` `invoke` (`:38`–`121`) — records context - loss when result is a `PlaceHolder` and a symbolic arg/receiver is present. -- Run lifecycle / solver modes / PRINT: `instrument/Intrinsics.java` `terminate()`; - `config/Config.java` `solver.mode` (default LOCAL). - -## Gotchas - -- The **legacy `de/uzl/its/value/**` suite (~1670 tests) fails** for unrelated API-migration reasons - — scope your runs to the `heap`/`processor` packages. -- `@Symbolic` on a **local variable** crashes the annotation transformer - (`AnnotationTransformer.transform`, `NoThreadContextException`) without `-g`; L2 targets use it on - a **parameter**. (Candidate hardening if you touch the transformer — not required.) -- We deliberately did **not** merge `feat/svcomp-testcase-metadata` (stats.json) — it's an L3 - enabler; L2 reads the `TraceDTO` directly. -- `@PendingFeature` masks *any* failure as pending; that's why each red spec asserts currently-true - preconditions first (or as a separate non-pending feature) so an infra break is a real failure. - Keep that convention when you add red tests. - -## Suggested order - -1. **G1** → make O-4 + O-5 green (canonical registry, faithful key, fix `IF_ACMPEQ`, update O-5's - registration call). Watch the two O reds flip; remove their `@PendingFeature`. -2. **G2** → make V-1 (L1) + V-1 (L2) green (collapse policy at the value-type invoke boundary). - Remove their `@PendingFeature`. Keep V-2/V-5/V-6/V-9/F-1/F-2 green. -3. Build the re-entry fixture and add O-1/O-2/O-3/O-6; then proceed through G_oob/G4a/G3/G4b, - adding the corresponding tests (E/F/D/U) as each mechanism lands. - -The `swat-test` skill (`.claude/skills/swat-test/SKILL.md`) has the per-level recipe for adding the -remaining tests. diff --git a/docs/heap-redesign-plan.md b/docs/heap-redesign-plan.md deleted file mode 100644 index 77f8977..0000000 --- a/docs/heap-redesign-plan.md +++ /dev/null @@ -1,72 +0,0 @@ -# Shadow Heap Redesign — High-Level Plan - -Status: design in progress (no code yet). This doc captures *what* we want, *in what order*, and *what to check along the way*. Failure-point enumeration and test design come next, in separate sections. - -## Purpose - -Redesign the shadow heap so it stops mis-tracking reference objects. The visible symptom is `String.toLowerCase()` returning `this` for no-op inputs, which causes the unmodeled result to be re-bound to its symbolic receiver's formula. A Phase-0 audit (below) showed this is one of a *cluster* of confirmed soundness bugs in the current identity-hash-keyed recovery cache. - -## Problem (Phase-0 audit, confirmed) - -The heap is `Map` used as a *recovery cache*, not a canonical store; frames hold direct `ObjectValue` references. Confirmed latent bugs: - -- **(a) Duplicate `ShadowObject` per concrete object** — `visitNEW` / `visitLDC_String` push without registering; canonical registration is incidental. **Kicker:** object `IF_ACMPEQ/NE` use `this == o2` on the *wrapper* (not the address), so a duplicate wrapper makes reference comparison **wrong** → canonical-heap is a *correctness* fix, not cleanup. -- **(b) Field-map desync on escape** — no havoc/invalidate exists; a tracked object mutated in unmodeled code keeps stale shadow fields. -- **(c) `identityHashCode` collision + no eviction** — 31-bit key, plain `HashMap`, never cleared → distinct objects merge; dead-hash reuse. -- **(d) Unregistered objects** — `NEW`/arrays/`LDC_String` register only incidentally. - -Performance: the GETVALUE/heap path is very hot (probe after every ref load/field-read/ref-returning-invoke); the common path does **no** heap lookup today. ⇒ Full frame-indirection is a **non-goal** (it would add a map lookup per access). No perf baseline exists in the repo. - -## Goals - -- **G1 — Canonical registry + cached pointers.** One `ShadowObject` per concrete identity via find-or-create at every introduce/recover site; a faithful, collision-free, GC-evicting key; register-on-create. Frames keep direct references as cached pointers (no per-access lookup). Fixes (a)(c)(d) + the `==` bug. -- **G_oob — Out-of-band change detection (no havoc in v1).** Detect when a tracked object's concrete state diverges from its shadow (e.g. mutated in unmodeled code) and flag it, **in all execution modes**. v1 detects only — no havoc/repair. Addresses (b). -- **G2 — Value-type boundary recovery (dealiasing model).** Each cell stores the *set* of formulas an identity has been associated with + the concrete. Recovery policy is swappable: **v1 = collapse** (single → use; multiple/none → concrete + context-loss flag); **v2 = branch over candidates** (needs explorer disjunction). Fixes the `toLowerCase` aliasing while preserving the legit single-φ round-trip. -- **G3 — Output-boundary de-interning.** De-intern value-typed method *returns* (not just literals) to shrink candidate-set sizes and remove this-return aliasing at instrumented call sites. -- **G4 — Purity whitelist + UF (feature).** - - **G4a** — a whitelist of stateless/deterministic library methods, used to (i) skip escape-havoc for pure calls and (ii) refine leak / context-loss detection. - - **G4b** — apply an (uninterpreted) function model for whitelisted pure methods at the boundary → a single precise, relational formula instead of concrete. - -**Removed from scope:** havoc as a recovery tier (boundary recovery is {modeled / UF-if-whitelisted / concrete} only); and havoc-on-escape (v1 *detects* out-of-band changes, does not havoc/repair); and branching over aliased φs (v1 realizes/records them but recovers concrete). - -### Methods-to-model policy - -Default for an unmodeled method = concrete + context-loss flag, **documented as missing — not modeled.** We model a method only when a test needs its deeper semantics. Committed methods-to-model list (grows only as deeper tests demand): -- `java.lang.String.(String)` — the copy constructor (test V-5): a content copy must keep the source's symbolic value. - -## Order of work + per-phase checks - -| Phase | Work | Checks before moving on | -|---|---|---| -| 0 ✓ | Audit current implicit handling | 4 bugs confirmed; perf characterized | -| 1 | **G1** canonical registry, faithful key, register-on-create | one wrapper per identity (assert); object `==` correctness; **establish perf baseline + measure delta**; existing suite green | -| 2 | **G2** cell stores φ-set; **v1 collapse** recovery | `toLowerCase` result no longer aliases receiver φ; single-φ round-trip still recovers; context-loss flagged on collapse; **log ambiguity frequency + candidates** (feeds v2 go/no-go) | -| 3 | **G_oob** out-of-band change detection (no havoc) | mutation in unmodeled code → divergence detected + flagged on next read, all modes; pure call → no false detection | -| 4 | **G4a** purity whitelist → wire into escape + leak/context-loss | pure calls don't over-flag / don't trigger escape-havoc; impure-with-symbolic arms contamination | -| 5 | **G3** output-boundary de-interning | candidate-set sizes shrink (measure); monitor `reference_semantic_change` cost | -| 6 | **G4b** UF application for whitelisted methods | whitelisted method recovers precise UF formula; UF-soundness tests (agreement / no-over-constraint / partiality) | -| v2 | explorer disjunction → branch-over-candidate recovery | decided by Phase-2 ambiguity measurements | - -## Cross-cutting checks - -- **Perf:** establish a per-instruction overhead baseline at Phase 1 (none exists); gate each phase on no material regression. -- **Soundness invariant:** every reachable shadow value's concrete component matches reality and its formula soundly abstracts it (over-approximate where flagged); context-loss is flagged whenever recovery is non-exact. -- **Faithful-key impl** (weak `IdentityHashMap` vs injected shadow-id field vs JVMTI tag): decided **within this PR**; default to weak `IdentityHashMap` unless perf dictates otherwise. - -## Notation (shared vocabulary) - -- `v = (c(v), φ(v))` — shadow value: concrete + formula (`φ = ⊥` ⇒ concrete-only). -- `id(r)` — concrete object identity (faithful key, not the 31-bit hash). -- `H : Id ⇀ Cell` — canonical heap. Cell content: stateful object = a `ShadowObject` (field map); value type = the φ-set + concrete. -- "structural" recovery = value still in stack/locals/field-map (exact, no heap); "boundary" = mirrored invoke of an unmodeled method; "re-entry" = a reference returns from unmodeled code as a placeholder. - -## Open questions - -- Faithful-key implementation choice (in-PR decision). -- De-intern aggressiveness vs the `reference_semantic_change` soundness cost. -- Whether/when to build v2 candidate-branching (informed by Phase-2 measurements). - -## Next - -1. Enumerate all failure points & possible failure points (the case taxonomy: object cases O1–O5, value cases V1–V7, escape/boundary cases M1–M9) and map each to a phase. -2. Design tests against the finalized notation (incl. the UF-soundness category). diff --git a/docs/heap-redesign-testing-setup.md b/docs/heap-redesign-testing-setup.md deleted file mode 100644 index 69e2a35..0000000 --- a/docs/heap-redesign-testing-setup.md +++ /dev/null @@ -1,83 +0,0 @@ -# Shadow Heap Redesign — Testing Setup Analysis - -Initial analysis of *how* to test the behavioral cases in `heap-redesign-tests.md` (the *what*). Produced as a starting point; the human will refine/develop tests from here. **Tests are pre-development (red-first/TDD): many SHOULD fail against current code — that is the expected signal that the fix is pending.** - -## Environment (verified) - -- Current branch is **`dev`**. `stats.json` (consolidated per-testcase stats) exists **only on `feat/svcomp-testcase-metadata`**, not `dev`. ⇒ For in-process tests, observe soundness flags directly off `SymbolicTrace`/`TraceDTO` rather than depending on `stats.json`. -- Z3 native libs present (`libs/java-library-path/`); `copyNativeLibs` already run. -- `sv-benchmarks` is checked out (R-1 candidates: `securibench/.../sanitizers/Sanitizers5.java`, `autostub/String_..._toLowerCase`). -- Tests = **Spock/Groovy only** (`symbolic-executor/src/test/groovy`, 15 specs). CI runs exactly `./gradlew copyNativeLibs` then `./gradlew test`. The `build` job uses `build -x test`. Avoid `spotless*`. -- Test JVM sets `exitOnError=false` (SWAT errors surface as catchable exceptions, not JVM halt) — `symbolic-executor/build.gradle:49-52`. - -## Robust model to emulate / anti-pattern to avoid - -- **Emulate:** the processor/shadow-state specs (`symbolic/processor/*Spec.groovy` + `BaseSymbolicInstructionProcessorSpec.groovy`). They drive the REAL `SymbolicInstructionProcessor.processInstruction()` over a constructed `ShadowContext` and assert on `Frame.operandStack`/`locals`/`ret`, `Value.concrete`, and symbolic VARIABLE NAMES via `extractVariables(formula).keySet()` (`InternalInvocationSpec.groovy:68`). `setupTestContext()` (`:54`) uniques method names per test — new specs MUST use it. -- **Avoid:** `de/uzl/its/value/**` (~1670 rows) assert on `.formula` via sort-specific managers (`bvmgr.equal`) → break en masse on representation churn. -- **Oracle rule:** assert on `Value.concrete`, `isSymbolic()`, variable *names*, boolean soundness flags, `Frame` contents, `IF_ACMPEQ`/`equals` booleans — NEVER on formula SMT sorts. - -## Where the bugs live (code under test) - -- Recovery cache `shadow/JVMHeap.java` — bare `HashMap`, only `put/get(hashCode)`, **no map/size/iteration getter, ZERO tests**. Via `ShadowContext.putToHeap/getFromHeap` (`:48-54`). -- **V-1 core bug path:** `SymbolicInstructionVisitor.visitGETVALUE_Object` (`:1265-1340`) — unmodeled result returns as `PlaceHolder`; `tmp = stack.getFromHeap(inst.address)` (`:1270`); if non-null, pushes the cached `Value` (`:1284`). `invokeToLowerCase` returns `PlaceHolder.instance` (`StringValue.java:1125`); `toLowerCase()` returns `this` for already-lowercase input ⇒ result shares receiver identity ⇒ recovers receiver's `StringValue` ⇒ aliases `s`. -- **`==` wrapper bug (O-4):** `ObjectValue.IF_ACMPEQ` uses `this == o2` on the wrapper (`~:153`); `equals()` compares `address == other.address && fields.length == other.fields.length` (`:131`). Duplicate wrappers ⇒ `IF_ACMPEQ` wrongly false; `equals` comparing only `fields.length` is a latent O-5 smell. -- **Soundness flags:** `trace/SymbolicTrace.java:26,29` booleans `symbolicContextLoss`/`referenceSemanticChange`; set via `SymbolicTraceHandler.recordSymbolicContextLoss()` (`:206`)/`recordReferenceSemanticChange()` (`:216`); mirrored on `TraceDTO.java:16,20`. -- **Flag-decision logic (F-1..F-4):** `invoke/InvocationHandler.invoke()` (`:38-121`) — `containsSymbolicArgument` (`:53-63`); records context-loss only when result is unmodeled placeholder AND a symbolic arg present (`:107-117`); missing invocations via `recordMissingInvocation` (`:99`). -- **E2E verdict:** scraped from STDOUT `[VERDICT ]` (`target_execution.py:116`). - -## Recommended architecture — three levels (stay on Spock for A/B) - -- **Level A — pure unit** (build `ObjectValue`/`StringValue`/heap directly). Cases: O-4, O-5, D-3, V-5, V-6, V-9, the canonical "one wrapper per identity" invariant. -- **Level B — processor-driven shadow-state spec** (the workhorse). Cases: V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1. Richest source of expected-reds. -- **Level C — whole-program E2E** (NOT in CI; STDOUT-scraping today). Cases: R-1, R-2, D-2, E-1's "all modes" clause. - -## New infrastructure to build (in order of blocking-ness) - -1. **Heap/registry inspection seam (blocks G1 tests).** `JVMHeap`/the new registry needs size/iteration/identity-count. Recommendation: define on the NEW registry API; write O-tests as expected-reds against it so they go green when Phase 1 lands. -2. **Boundary-recovery fixture (highest leverage).** Generalize `executeLiftInsnSeq` (`BaseSymbolicInstructionProcessorSpec.groovy:214`) to the object/recovery path: push a (symbolic) receiver, run unmodeled-invoke → `INVOKEMETHOD_END` → `GETVALUE_Object` for a given concrete result + address, return recovered `Value` + flag snapshot. Reused by V-1/V-2/V-3/V-4/O-2/O-3/F-3. -3. **Thin programmatic E2E harness (only for R/D-2/E-1-all-modes).** Run one target through agent+explorer, return `{verdict, contextLoss, referenceSemanticChange}` structured. Heaviest lift; possibly Python; not CI-gated. - -## Per-case mapping + expected status NOW - -| Case | Level | New infra | Status now | -|---|---|---|---| -| O-1 aliasing/field write | B | fixture | likely RED | -| O-2 re-entry same object | B | fixture | RED | -| O-3 born-in-unmodeled | B | heap getter + fixture | RED | -| O-4 ref-equality | A | — | **RED** (`this==o2`) | -| O-5 hash collision | A/B | heap getter | RED | -| O-6 identity reuse after death | B | heap getter (eviction) | RED | -| V-1 this-return no-alias (core) | B | fixture | **RED** | -| V-2 new-object consistency | B | fixture | RED | -| V-3 single-φ round-trip | B | fixture | possibly GREEN (verify) | -| V-4 conflicting φ → concrete | B | φ-set (Phase 2) | RED | -| V-5 `new String(String)` keeps φ | A | — | likely **GREEN** (`invokeInit` copies, `StringValue.java:218`) | -| V-6 interned literal reuse | A | — | verify (likely GREEN) | -| V-7 boxed analogue | B | fixture | RED | -| V-8 primitive context loss | B | fixture | partially GREEN | -| V-9 concrete grounding | A/B | — | GREEN | -| E-1 out-of-band detect (all modes) | C (+B) | E2E harness | RED | -| E-2 pure call no false flag | B | purity whitelist (Phase 4) | N/A yet | -| F-1 no symbolic → no flag | B | — | likely GREEN | -| F-2 symbolic → flag | B | — | likely GREEN | -| F-3 leak-then-retrieve | B | fixture | RED (flags at call, not retrieval) | -| F-4 pure symbolic no leak | B | purity whitelist | N/A yet | -| D-1 fresh identity on produced | A/B | — | RED (Phase 5) | -| D-2 de-intern verdict-neutral | C | E2E harness | needs E2E | -| D-3 ref-semantic-change only-when-warranted | A | — | verify | -| U-1..U-4 UF soundness | A/B | UF (Phase 6) | N/A yet | -| U-5 modeled-result precision | B | whitelist+UF | N/A yet | -| R-1 original failing case | C | E2E harness | **RED** | -| R-2 golden no-regression | C | E2E harness + golden set | baseline-dependent | - -Tag scheme: `@Tag` / naming `expected-red` vs `expected-green` + `phase-N`. Make expected-red tests fail on a SPECIFIC assertion (e.g. "result must not contain var X"), never on a setup exception — so expected-red is distinguishable from infra-broken. - -## Decisions for the human (ordered) - -1. Heap-inspection seam: add to legacy `JVMHeap` now, or define on the new registry API (recommended → write O-tests as expected-reds against it). -2. Build the boundary-recovery fixture before V-*/O-2/O-3/F-3 (shared by all). -3. E2E scope: build a structured harness vs defer Level C to a non-CI regression lane (recommended: keep CI to A/B). -4. Branch: observe flags in-process off `TraceDTO`/`SymbolicTrace` to avoid the `stats.json`/branch dependency (recommended). -5. Phase gating: author in-process expected-reds now (tagged by phase) vs defer until each phase begins (recommended: author now). - -Suggested start: heap/registry inspection seam → boundary-recovery fixture → V-1 as the first concrete expected-red. diff --git a/docs/heap-tracking.md b/docs/heap-tracking.md new file mode 100644 index 0000000..a5b3a70 --- /dev/null +++ b/docs/heap-tracking.md @@ -0,0 +1,316 @@ +# Heap & value tracking in the symbolic executor + +This is the reference for how the SWAT symbolic executor (the Java agent under +`symbolic-executor/`) tracks the values and objects a program manipulates. Read it before +changing anything under `symbolic/shadow/`, `symbolic/value/`, `symbolic/invoke/`, `symbolic/UFs/`, +or the `==`/de-interning instrumentation. It describes the system as it is today; it is not a +changelog. + +All paths below are relative to +`symbolic-executor/src/main/java/de/uzl/its/swat/` unless noted. Line numbers drift — the +**symbol names** (classes, methods, fields) are the stable anchors; grep for them. + +--- + +## Mental model (read this first) + +SWAT runs the real program on the real JVM and, in parallel, maintains a **shadow** interpretation +of it. Every value the program computes has a **shadow value**: the concrete runtime value it +actually holds, plus an optional **symbolic formula** (an SMT term) describing how it depends on the +designated symbolic inputs. Branches on symbolic values are recorded as path constraints; the +explorer later solves them to find new inputs or to prove a property. + +The hard part is **objects and library calls**. The JVM shares object identities (interned strings, +cached boxed integers, methods that return `this`), and most library methods are not modeled by +SWAT. The machinery here exists to keep the shadow world faithful across those boundaries: + +- a **registry** so each concrete object has exactly one shadow; +- **boundary recovery** so results of unmodeled calls re-enter the shadow world without corrupting + it; +- **de-intern + provenance** so reference equality (`==`) stays correct once we stop relying on JVM + interning; +- a **pure-function model** so we keep precision through side-effect-free library calls instead of + throwing it away; +- **soundness flags** so that whenever the shadow model is knowingly incomplete, a "safe" verdict is + downgraded rather than trusted. + +--- + +## Vocabulary + +- **Shadow value** — a tracked value: a *concrete* component (the observed runtime value) + an + optional *symbolic formula*. If the formula has no free variables/uninterpreted functions the + value is effectively concrete. +- **Symbolic input** — a value the user marked with `@Symbolic`; the free variables everything else + is expressed in terms of. +- **Value type** — a String or a boxed primitive wrapper — an immutable object with value semantics. + Contrast with a general mutable object. +- **Unmodeled method** — a library method SWAT does not simulate symbolically; it runs for real and + its result must be *recovered* into the shadow world. +- **Recovery / boundary** — reconciling the shadow stack with reality at a synchronization point + after an unmodeled call or a field/array read. +- **Uninterpreted function (UF)** — an SMT function symbol with no defining axioms except that equal + inputs give equal outputs. Used to model pure library calls. + +--- + +## Components + +### 1. The canonical shadow registry — one shadow per object + +Each tracked concrete object maps to exactly one shadow value. The map is keyed by the **object +itself, compared by reference identity (`==`)** — not by `System.identityHashCode`. Reference +keying is collision-free (two distinct objects stay distinct keys even if their identity hashes +collide) and yields one shadow per object. Keys are held **weakly**, so a plain object's or boxed +wrapper's shadow is evicted once the object is unreachable. + +- `symbolic/shadow/JVMHeap.java` — the map: `new MapMaker().weakKeys().makeMap()` in the + constructor; `put`/`get` (null-guarded); `size`. +- `symbolic/shadow/ShadowContext.java` — per-thread owner; delegators `putToHeap` / `getFromHeap` / + `heapSize`. One `JVMHeap` is created per `ShadowContext`. +- Read/write sites: `SymbolicInstructionVisitor.visitGETVALUE_Object` (and the primitive mirror). + +**String self-pinning caveat.** A `StringValue` stores its own concrete `String`, which is also its +key, so the weak key stays strongly reachable — String-keyed entries do **not** evict until the +whole `ShadowContext` is discarded. To bound this, recovery deliberately does not register +*constant* strings/boxed values in the heap (see §3). + +**Invariants** +- At most one shadow value per distinct concrete object. +- No identity-hash collision or reuse can alias two shadows. +- Non-String, non-self-referential entries are GC-evictable. + +### 2. What a shadow value is + +A shadow value pairs a concrete component with an optional formula whose SMT sort matches the Java +type exactly. + +- `symbolic/value/Value.java` — base class; fields `formula` and `concrete`; `isSymbolic()` + overridden per subtype. +- `symbolic/value/ValueFactory.java` — construction. `createNumericalValue` has a concrete-only + overload and one that carries an explicit `formula`; `createObjectValue` maps a concrete object to + the right subtype. +- Sort mapping: + - `int/long/short/byte/char` → bitvector of width **32/64/16/8/16** + (`value/primitive/numeric/integral/`). + - `boolean` → SMT boolean (`BooleanValue`, also in the `integral` package). + - `float/double` → single/double-precision floating point + (`value/primitive/numeric/floatingpoint/`). + - `String` → SMT string (`value/reference/lang/StringValue.java`). + - Boxed wrappers → `IntegerObjectValue` / `BooleanObjectValue` / … (`value/reference/lang/`). + +**Invariant** — a value's formula sort always matches its Java type; `concrete` always holds the +last observed real value. + +### 3. Boundary recovery of unmodeled returns + +When instrumented code calls a method SWAT does not model, the call runs for real and the executor +pushes a **placeholder** on its shadow stack. The real result is reconciled at the next `GETVALUE` +synchronization point, where the executor learns the concrete value the JVM produced. + +The key rule: a **value-typed return (String or boxed primitive) is not identity-recovered from the +registry** — it is turned into a fresh concrete value. Identity recovery would look up the +*receiver's* own shadow (because e.g. `String.toLowerCase()` can return `this`) and wrongly re-bind +the result to the receiver's formula. Concretizing instead breaks that aliasing. + +- `symbolic/value/PlaceHolder.java` — the placeholder; `enum ValueOrigin` (notably + `UNMODELED_RETURN`) tags where a placeholder came from; shared singletons `instance` / + `symbolicInstance`. +- `symbolic/invoke/InvocationHandler.java` — `invoke`: after the real call, if the result is a + placeholder and the call is not on `IGNORED_INVOCATIONS`, records a missing invocation and + re-wraps the result as an `UNMODELED_RETURN` placeholder. +- `SymbolicInstructionVisitor.visitGETVALUE_Object` — the `UNMODELED_RETURN && Util.isValueType(...)` + branch concretizes via `ValueFactory.createObjectValue` **without touching the registry** (so the + receiver's entry is untouched); non-value results fall through to normal registry recovery. +- `SymbolicInstructionVisitor.visitGETVALUE_primitive` — the primitive mirror. + +**Invariants** +- An unmodeled value-typed return never aliases the receiver's formula. +- Concrete recovery always adopts the JVM-observed value. + +### 4. Out-of-band change detection + +At a **primitive** `GETVALUE`, the executor compares the shadow value's `concrete` against what the +JVM actually produced. A mismatch means an *out-of-band change*: either a tracked object was mutated +inside unmodeled code, or the executor desynced internally. A configurable policy decides the +response. + +- `symbolic/shadow/ShadowDivergence.java` — the policy enum, two values: + - `CRASH` (default) — hard-fail via `SWATAssert`; catches executor desync bugs in dev/CI; this is + the historical behavior. + - `FLAG` — record a context-loss flag, adopt the observed concrete, and continue. Fully sound; + recommended for production/SV-COMP runs (no spurious crashes). +- `config/Config.java` — field `shadowDivergence`, default `CRASH`, read from the `shadow.divergence` + key. +- Decision site: the divergence branch in `SymbolicInstructionVisitor.visitGETVALUE_primitive`. Both + policies re-adopt the observed concrete afterward. + +**Invariants** +- Divergence detection currently covers only the **primitive** `GETVALUE` path. +- `FLAG` is sound: an adopted divergence downgrades a would-be safe verdict (via context loss, §7). + +### 5. De-intern + provenance — correct reference equality + +The reference-keyed registry (§1) needs distinct objects to have distinct identities. But the JVM +**shares** identities for interned strings, cached boxed wrappers, and `this`-returns, so two +logically-distinct shadow slots could collide on one shared object. To prevent that, value types +entering shadow space (string literals, boxed `valueOf`, and value-typed returns from +un-instrumented callees) are **de-interned**: replaced by a fresh copy with a unique identity. + +De-interning would then break `==`, so each fresh copy records the genuine original +("canonical root") it came from, and `==` is resolved by comparing roots — reproducing real Java's +`==` without relying on JVM interning. + +- `common/Provenance.java` — weak-keyed map from de-interned copy → canonical root; `record(copy, + canonical)` (stores the fully-resolved root, keeping chains depth-1); `root(x)` returns the + canonical or `x` itself. +- `common/UtilInstrumented.java` — `refEquals`: uses value equality + (`Provenance.root(a) == Provenance.root(b)`) when both operands are de-interned value types, else + plain `a == b`. +- `common/Util.java` — `shouldUseValueEquality`, `isDeInternedClass`, and the `deInternedClasses` + set: **String + Boolean/Byte/Short/Character/Integer/Long**. Float and Double are **not** + de-interned (they are uncached in real Java, so plain reference equality is already correct). + `isValueType` is broader (String + all `Number` + Boolean + Character) and drives §3's recovery, + not `==`. +- `instrument/nocache/NoCacheMethodAdapter.java` — the de-intern instrumentation: rewrites a String + literal to `new String(...)` + a provenance record; rewrites `.valueOf(prim)` to + `new (prim)` (`rewriteValueOf`); de-interns value-typed returns from un-instrumented + callees (`deInternReturn`). The `Boxed` enum is the single source of truth for the six cached + wrappers; `isDeInternSkippedOwner` excludes SWAT and sv-benchmarks intrinsics. +- `instrument/refequality/RefEqualityMethodAdapter.java` — rewrites `IF_ACMPEQ`/`IF_ACMPNE` bytecode + to call `UtilInstrumented.refEquals`. + +**Invariants** +- No two logically-distinct shadow slots share one interned/cached concrete key. +- `==` on de-interned value types matches real-Java `==`. +- Float/Double keep plain reference equality. + +**Known residual approximation** — `new String("x") == new String("x")` is modeled as `true` while +the real JVM yields `false`, and this is not flagged. It is a deliberate limitation of routing `==` +through value equality. + +### 6. Pure-function model — keeping precision through side-effect-free calls + +Some unmodeled library methods are pure: deterministic and side-effect-free. Rather than concretize +their result (and lose the symbolic dependency), SWAT models each such call as an **axiom-free +uninterpreted function** `pure_(inputs)`. An axiom-free UF asserts only "equal inputs ⇒ +equal outputs", which soundly over-approximates any deterministic function while preserving +relational facts (e.g. two calls on equal arguments return equal results). It fires only when at +least one input is symbolic. + +**Whitelist membership is a soundness precondition**: only genuinely pure, deterministic methods may +be listed — otherwise the UF's "equal inputs ⇒ equal outputs" assumption is false. + +- `symbolic/UFs/PureMethods.java` — `WHITELIST` (keyed `owner/name+descriptor`; String methods plus + an audited set from `java.lang`/`java.util`: Math, StrictMath, Character, Integer, Byte, Float, + Double, Objects). `isWhitelisted`; `ufName` builds `pure__[_]`. The + `pure_` prefix is the recognizer used by the precision-loss check (§7). +- `symbolic/UFs/PureFunctionUF.java` — per-thread UF registry; `apply(ufName, returnType, args)` + declares the UF lazily, caches it, and asserts the signature matches on reuse. Axiom-free by + construction. +- `symbolic/UFs/UFHandler.java` — `getPureFunctionUF` (lazy per-thread accessor). +- `symbolic/invoke/InvocationHandler.java` — `buildPureUF` constructs the result UF; the gate is + `containsSymbolicArgument && PureMethods.isWhitelisted(...)`. `pureUFReturnType` maps the return + descriptor to an SMT sort (String; boolean; bitvectors; floating point; else fall back to + concretize). A successful model also **suppresses the context-loss flag** (a modeled pure call + loses nothing). +- Recovery: `visitGETVALUE_Object`/`visitGETVALUE_primitive` install the carried UF as the recovered + value's formula. + +**Invariants** +- A whitelisted pure call preserves "equal inputs ⇒ equal outputs" instead of concretizing. +- The UF's return sort matches the recovered value's sort. +- Modeling requires all inputs to be value-typed with formulas; otherwise it falls back to + concretization. +- Only pure/deterministic methods may be whitelisted. + +--- + +## Lifecycle of a tracked value + +1. **Introduced** — a symbolic input (`@Symbolic` parameter) gets a free variable; a literal/boxed + constant is de-interned (§5) and given a concrete-only shadow; a normal computation produces a + shadow with a derived formula. +2. **Used** — arithmetic/logic combine formulas; a branch on a symbolic value records a path + constraint. +3. **Crosses a boundary** — an unmodeled call returns a placeholder (§3); at the next `GETVALUE` the + result is recovered as either a modeled UF (if whitelisted-pure, §6) or a concrete value + (otherwise, flagging context loss). +4. **Reference-compared** — `==` is resolved via provenance roots (§5). +5. **Re-synchronized** — at each primitive `GETVALUE`, the shadow concrete is checked against reality + (§4); a mismatch is crashed or flagged per policy. + +--- + +## Soundness model + +Two independent flags mark that the model may be incomplete; either one downgrades a **SAFE** verdict +to **UNKNOWN** (a VIOLATION is not downgraded — it is replay-witnessed). + +- **Context loss** — set when SWAT hits an unmodeled method with symbolic input it cannot model at + all (result discarded), or when an out-of-band divergence is adopted under `FLAG`. + - Set via `symbolic/trace/SymbolicTraceHandler.java` `recordSymbolicContextLoss` → + `SymbolicTrace.setSymbolicContextLoss`. Call sites: `InvocationHandler.invoke` and the `FLAG` + divergence branch. +- **Precision loss** — set when a branch constraint contains a symbol that is neither a designated + symbolic input, a recovery-named variable, nor a whitelisted `pure_` UF (i.e. a bespoke axiomatized + UF or an ungrounded variable that could make the constraint unsound). + - **Computed at trace-build time**, not recorded at runtime: `symbolic/trace/DTOBuilder.java` + `isPrecisionLoss(...)` walks each branch formula; there is deliberately no runtime recorder. +- Both flags ride on `symbolic/trace/dto/TraceDTO.java` and are emitted by `DTOBuilder`. +- Explorer side: `symbolic-explorer/data/BinaryExecutionTree/Tree.py` (`record_context_loss` / + `record_precision_loss`), populated by `data/Database.py` `add_trace`; the downgrade happens in + `driver/SVCompDriver.py` (`SAFE` + either flag ⇒ `UNKNOWN`). + +**Invariants** +- SAFE + (context loss OR precision loss) ⇒ UNKNOWN. Never a false SAFE from a knowingly-incomplete + model. +- A whitelisted `pure_` UF does not trigger precision loss; a bespoke axiomatized UF does. + +--- + +## Extending it & gotchas + +- **Adding a pure method to the whitelist.** Add `owner/name+descriptor` to + `PureMethods.WHITELIST` only after confirming the method is deterministic, side-effect-free, and + reads no ambient state (locale, time, environment, statics). A wrong entry is unsound. Today a bad + entry costs only precision (each run emits at most a single self-consistent fact); once cross-run + accumulation lands (see below) a bad entry can cause a **false SAFE**, so the bar is higher than it + looks. +- **Instrumenter frame-analysis fragility.** A category-2 parameter (a `double` or `long`) followed + by a reference parameter, and large mixed-type method bodies, trip an unrelated frame-analysis bug + in the instrumenter. Keep test targets small and single-typed; see the Javadocs in + `src/test/resources/targets/StringWhitelistTarget.java` and `WhitelistAuditAgentTarget.java`. +- **`@Symbolic` goes on a method parameter, not a local.** Annotating a local crashes the annotation + transformer when compiled without `-g`. +- **The legacy `de/uzl/its/value/**` test suite is broken** on an old formula API and fails en + masse. Scope test runs to the `symbolic.shadow` / `symbolic.processor` / `common` packages (see + the `swat-test` skill). + +--- + +## Not yet implemented (planned) + +Captured here so the plan survives; neither is in the code today. + +- **Escape-aware divergence policy.** A third divergence mode (beyond `CRASH`/`FLAG`, §4) that tells + a *legitimate* out-of-band mutation from a *genuine executor desync*. Intended mechanism: mark a + tracked object as **escaped** when it is passed as receiver/argument into an unmodeled call + (marking site in `InvocationHandler.invoke`); at a divergence, if the value came from an escaped + object → flag + adopt + continue, else → crash. The escaped bit reaches the primitive-`GETVALUE` + decision via a thread-local set in `visitGETFIELD` and **cleared at the start of every** + `visitGETVALUE_primitive` (else it leaks and masks real desync). A v1 would cover only the direct + field-read-of-escaped-object case (array/transitive reads still crash), so `FLAG` stays the + fully-sound production hatch. A refinement: a *pure* whitelisted call cannot mutate, so it should + not mark escape. + +- **Cross-run accumulation of observed pure-function facts.** The executor already emits, per run, + one **ground** observed pair `pure_(constant inputs) == constant output` for a whitelisted + String-returning pure call (`InvocationHandler.buildPureUF` builds it; `visitGETVALUE_Object` + asserts it). A single pair per run is sound on its own. What is missing is on the **explorer**: it + does not yet accumulate these pairs across runs and inject them at solve time. The three required + explorer changes **must land together**: (1) inject the accumulated per-testcase fact set at the + solve chokepoint; (2) a contradiction guard so a bad/nondeterministic entry cannot inject two + contradictory pairs; (3) an UNSAT backstop that downgrades SAFE→UNKNOWN if the accumulated `pure_` + facts alone are unsatisfiable. Injection without (2)+(3) could turn a bad whitelist entry into a + false SAFE. diff --git a/docs/test-architecture.md b/docs/test-architecture.md index 49bec43..f1ffa71 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -31,7 +31,7 @@ changes. **Never do that.** Assert only on: - `Value.isSymbolic()`; - symbolic **variable names** via `solverContext.getFormulaManager().extractVariables(f).keySet()`; - boolean results of `IF_ACMPEQ` / `IF_ACMPNE` / `equals`; -- soundness **flags** (`symbolicContextLoss`, `referenceSemanticChange`, `symbolicPrecisionLoss`); +- soundness **flags** (`symbolicContextLoss`, `symbolicPrecisionLoss`); - `Frame.operandStack` / `locals` / `ret` contents; - structured `TraceDTO` fields (L2/L3); - for SMT/UF agreement only: feed the formula to a real `ProverEnvironment` and assert @@ -98,7 +98,7 @@ executeBoundaryRecovery(receiver, owner, name, desc, concreteResult, resultAddre // 2. push receiver as the invoke operand // 3. process: INVOKEVIRTUAL(owner,name,desc) ; INVOKEMETHOD_END ; // GETVALUE_Object(resultAddress, concreteResult, i) - // 4. return { recovered: peekOperand, contextLoss, referenceSemanticChange, heap snapshot } + // 4. return { recovered: peekOperand, contextLoss, heap snapshot } ``` This drives the *real* bug path: an unmodeled invoke returns `PlaceHolder` @@ -147,7 +147,7 @@ in-JVM with Z3 via `LocalSolver.solve()`. `SolverMode = {LOCAL, HTTP, PRINT, NON `exitOnError=false`). `swat.input.*` pins the concrete path so the run is deterministic. 4. Capture stdout, parse the TraceDTO JSON (Jackson) into a typed `TraceObservation { inputs[], branches[], ufs[], symbolicContextLoss, - symbolicPrecisionLoss, referenceSemanticChange }`. + symbolicPrecisionLoss }`. **Why forked, not in-process:** the agent attaches at premain and `ThreadHandler`/`Config` are process-global singletons set at startup; resetting them mid-JVM is hacky and fragile. A forked @@ -172,7 +172,7 @@ final SV-COMP verdict, including the downgrade rules (SAFE+contextLoss→UNKNOWN [`heap-redesign` soundness notes]). Tests the *verdict*, not just the trace. **Driver.** The existing sv-comp driver / `targets/` harness. Structured per-testcase observation -(`{verdict, contextLoss, referenceSemanticChange}`) becomes clean once the **`stats.json`** +(`{verdict, contextLoss}`) becomes clean once the **`stats.json`** work lands (branch `feat/svcomp-testcase-metadata`, PR #27) — until then it is STDOUT/log scraping (`[VERDICT ]`). diff --git a/targets/sv-comp/scripts/lib/analysis/failures.py b/targets/sv-comp/scripts/lib/analysis/failures.py index 35bc11a..b4e452f 100644 --- a/targets/sv-comp/scripts/lib/analysis/failures.py +++ b/targets/sv-comp/scripts/lib/analysis/failures.py @@ -5,7 +5,6 @@ - Symbolic context loss - Symbolic precision loss - Uncaught exceptions -- Reference semantic changes - Internal SWAT errors """ @@ -55,7 +54,6 @@ def analyze_failures(log_base_dir: Path = None) -> Dict: - Symbolic context loss - Symbolic precision loss - Uncaught exceptions - - Reference semantic changes - Other errors Args: From dc5101277952487a57c60b84dcb9271cadff9707 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:55:09 +0000 Subject: [PATCH 23/33] style(heap): drop internal phase/case jargon from comments and Javadoc Remove development-phase labels (G1-G4, step 2, v1), design-doc references, and coined internal terms from comments and Javadoc across the heap/value-tracking sources. Comments now describe present behavior; no logic changes. Co-Authored-By: Claude Opus 4.8 --- .../de/uzl/its/swat/common/Provenance.java | 4 +- .../java/de/uzl/its/swat/common/Util.java | 10 ++-- .../uzl/its/swat/common/UtilInstrumented.java | 2 +- .../nocache/NoCacheMethodAdapter.java | 10 ++-- .../symbolic/SymbolicInstructionVisitor.java | 52 +++++++++---------- .../its/swat/symbolic/UFs/PureFunctionUF.java | 4 +- .../its/swat/symbolic/UFs/PureMethods.java | 28 +++++----- .../uzl/its/swat/symbolic/UFs/UFHandler.java | 2 +- .../symbolic/invoke/InvocationHandler.java | 46 ++++++++-------- .../symbolic/shadow/ShadowDivergence.java | 12 ++--- .../its/swat/symbolic/trace/DTOBuilder.java | 8 +-- .../swat/symbolic/trace/dto/BranchDTO.java | 2 +- .../its/swat/symbolic/value/PlaceHolder.java | 14 ++--- .../its/swat/symbolic/value/ValueFactory.java | 2 +- 14 files changed, 96 insertions(+), 100 deletions(-) diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java index 2c838c5..4b9f905 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Provenance.java @@ -5,9 +5,9 @@ import java.util.Map; /** - * G3-B provenance for reference equality of de-interned value types. + * Provenance tracking for reference equality of de-interned value types. * - *

De-interning (G3) gives every produced value type a fresh identity so the reference-keyed shadow + *

De-interning gives every produced value type a fresh identity so the reference-keyed shadow * heap stays sound. That fresh identity diverges from the real JVM identity, which would break * reference equality ({@code ==}). To model {@code ==} exactly, each de-interned copy records the * genuine original ("canonical") object it was made from, and {@code UtilInstrumented.refEquals} diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java index e01479e..7fdca9f 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java @@ -235,7 +235,7 @@ public static Class[] getInterfaces(Class clazz) private static void checkClassName(String className) { if (className.startsWith("[")) { - return; // array class, skip check for now + return; // array class; the descriptor check does not apply } SWATAssert.check( !className.contains(";") && !className.contains("(") && !className.contains(")"), @@ -429,10 +429,10 @@ private static boolean isDeInternedClass(Class clazz) { } /** - * Whether {@code o}'s runtime class is one G3 de-interns: String and the cached boxed wrappers + * Whether {@code o}'s runtime class is one that is de-interned: String and the cached boxed wrappers * (Boolean/Byte/Short/Character/Integer/Long), but NOT the uncached Float/Double (which use - * reference equality). Used by recovery to scope register-only-non-constant to exactly the - * de-interned value types, leaving mutable objects and Float/Double unconditionally registered. + * reference equality). Used by recovery to limit the "skip registering pure constants" policy to + * exactly the de-interned value types, leaving mutable objects and Float/Double always registered. */ public static boolean isDeInternedClass(Object o) { return o != null && isDeInternedClass(o.getClass()); @@ -441,7 +441,7 @@ public static boolean isDeInternedClass(Object o) { /** * Whether a concrete object is an immutable value type (String / boxed primitive). * Used by recovery to concretize an unmodeled value-returning method's result instead of - * identity-recovering it (G2). Independent of {@link #deInternedClasses} (the de-intern / + * identity-recovering it. Independent of {@link #deInternedClasses} (the de-intern / * reference-equality concern, which omits the uncached Float/Double): this covers String and all * eight boxed wrappers ({@link Number} = Byte/Short/Integer/Long/Float/Double, plus Boolean and * Character). diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java index b8009db..3f18549 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/UtilInstrumented.java @@ -24,7 +24,7 @@ public static void liftClass(Object param, String paramCnameDot, String methodNa } /** * Models reference equality ({@code ==}) for de-interned value types by comparing the ORIGINAL - * identities (G3-B). De-interning gave {@code a}/{@code b} fresh identities that diverge from the + * identities. De-interning gave {@code a}/{@code b} fresh identities that diverge from the * real JVM; comparing {@link Provenance#root}s (the canonical object each was de-interned from) * reproduces the un-transformed program's {@code ==}: two copies of the same interned literal / * cached box / returned object share one canonical and so compare equal. Non-de-interned classes diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java index 4ca4e5a..c682573 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/instrument/nocache/NoCacheMethodAdapter.java @@ -24,9 +24,9 @@ public void visitLdcInsn(Object value) { mv.visitLdcInsn(value); mv.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/String", "", "(Ljava/lang/String;)V", false); - // G3-B: record provenance (de-interned copy -> the interned literal canonical). The - // interned literal is a compile-time constant, so re-LDC'ing it pushes the SAME canonical - // object that every other occurrence of this literal shares; never null (no guard needed). + // Record provenance (de-interned copy -> the interned literal canonical). The interned + // literal is a compile-time constant, so re-LDC'ing it pushes the SAME canonical object + // that every other occurrence of this literal shares; never null (no guard needed). mv.visitInsn(Opcodes.DUP); mv.visitLdcInsn(value); emitProvenanceRecord(); @@ -115,7 +115,7 @@ public void visitMethodInsn(int opcode, String owner, String name, String descri // For all other method calls, proceed normally. mv.visitMethodInsn(opcode, owner, name, descriptor, isInterface); - // G3: output-boundary de-interning. De-intern a value-typed return from an UN-instrumented + // Output-boundary de-interning: de-intern a value-typed return from an un-instrumented // callee - the boundary where interned/shared values (literals, constants, this-returns, // cached boxes) enter shadow space. A fresh copy gives the produced value a distinct identity, // so the reference-keyed heap stays sound for value types. Skip the SWAT / sv-benchmarks @@ -174,7 +174,7 @@ private void deInternReturn(Type returnType) { } /** - * The six cached boxed wrappers G3 de-interns. Each carries the JVM primitive type descriptor + * The six cached boxed wrappers that are de-interned. Each carries the JVM primitive type descriptor * ({@code primDescriptor}, e.g. {@code "I"}, {@code "J"}, {@code "S"}) from which all method * descriptors are derived, so this enum is the single source of truth for both the {@code valueOf} * rewrite and the return de-intern. {@code primType} is the stack type used for the load/store diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index bdd2644..3ee59e6 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1270,26 +1270,25 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc // remove the placeholder value stack.popOperand(); - // G2: the result of an unmodeled value-returning method must NOT be identity-recovered + // The result of an unmodeled value-returning method must NOT be identity-recovered // (that would re-bind the receiver's symbolic value, e.g. toLowerCase() returning // `this`). Concretize the value type instead, and do NOT consult or mutate the heap, so - // the receiver's own entry (and its V-3 round-trip) is preserved. Context loss was - // already flagged in InvocationHandler. Non-value results fall through to G1 recovery. + // the receiver's own entry (and its round-trip) is preserved. Context loss was already + // flagged in InvocationHandler. Non-value results fall through to registry recovery. if (placeHolder.origin == PlaceHolder.ValueOrigin.UNMODELED_RETURN && Util.isValueType(inst.val)) { Logger shadowStateLogger = ThreadHandler.getShadowStateLogger(currentThread().getId()); if (placeHolder.recoveredFormula != null && inst.val instanceof String s) { - // G4: a whitelisted pure method - model the result as the carried generic UF + // A whitelisted pure method - model the result as the carried generic UF // over the inputs (concrete = observed). Preserves the relational fact // (equal inputs => equal outputs) instead of concretizing. SolverContext context = ThreadHandler.getSolverContext(currentThread().getId()); tmp = new StringValue(context, s, (StringFormula) placeHolder.recoveredFormula, inst.address); shadowStateLogger.info("Modeled unmodeled pure result as a generic UF: {}", tmp); - // G4 step 2: record this run's observed (input -> output) ground pair - + // Record this run's observed (input -> output) ground pair - // pure_(constant inputs) == observed output. Sound: a true fact about the - // real function only tightens the axiom-free UF. Cross-run accumulation + - // solve-time injection are the explorer's job - see - // docs/heap-redesign-g4-step2-explorer-handoff.md. + // real function only tightens the axiom-free UF. Cross-run accumulation and + // solve-time injection are the explorer's job. if (placeHolder.observedApplication != null) { StringFormulaManager smgr = context.getFormulaManager().getStringFormulaManager(); symbolicTraceHandler.addConstraint( @@ -1356,19 +1355,19 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc "Concrete value of the object does not match the value in the stack: {} | {}", inst.val, peek.concrete); if(peek.formula == null) { - // A constant String: reconstruct from the observed concrete. G3 - // register-only-non-constant: a constant is recoverable from inst.val on - // round-trip, so it is NOT heap-registered (registering it only grows the - // self-pinning heap leak - a String is its own weak key). + // A constant String: reconstruct from the observed concrete. A constant is + // recoverable from inst.val on round-trip, so it is NOT heap-registered + // (registering it only grows the self-pinning heap leak - a String is its + // own weak key). stack.popOperand(); StringValue val = ValueFactory.createStringValue(s, inst.address); stack.pushOperand(val); } else { (peek.asObjectValue()).setAddress(inst.address); - // G3 register-only-non-constant: register iff the shadow carries symbolic - // content (a variable/UF) - those can't be reconstructed from the concrete - // and must round-trip via the heap (e.g. a whitelisted pure method's UF); - // pure constants are skipped to bound the leak. + // Register iff the shadow carries symbolic content (a variable/UF) - those + // can't be reconstructed from the concrete and must round-trip via the heap + // (e.g. a whitelisted pure method's UF); pure constants are skipped to bound + // the leak. FormulaManager fmgr = ThreadHandler.getSolverContext(currentThread().getId()).getFormulaManager(); if (!fmgr.extractVariablesAndUFs((Formula) peek.formula).isEmpty()) { @@ -1378,10 +1377,10 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc } else { (peek.asObjectValue()).setAddress(inst.address); - // G3-A2 register-only-non-constant for de-interned boxed value types (same - // policy as the String branch above): a constant boxed value is reconstructible - // from inst.val, so skip registering it; symbolic boxed values, mutable objects, - // and uncached Float/Double keep unconditional registration. + // Same policy as the String branch above, for de-interned boxed value types: a + // constant boxed value is reconstructible from inst.val, so skip registering it; + // symbolic boxed values, mutable objects, and uncached Float/Double keep + // unconditional registration. if (!isConstantDeInternedValue(inst.val, peek)) { stack.putToHeap(inst.val, peek); } @@ -1462,8 +1461,8 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc /** * Whether {@code ref} is a de-interned value type (String / cached boxed wrapper) whose shadow is a * pure constant - i.e. carries no symbolic variable or UF. Such a value is reconstructible from the - * observed concrete on round-trip, so G3 (register-only-non-constant) skips heap-registering it to - * bound the heap; only the symbolic/UF shadows that cannot be reconstructed are registered. A boxed + * observed concrete on round-trip, so it is not heap-registered; this bounds the heap, and only the + * symbolic/UF shadows that cannot be reconstructed are registered. A boxed * value carries its formula in the inner {@link BoxedValue#getVal()}, not the wrapper's own field. */ private boolean isConstantDeInternedValue(Object ref, Value shadow) @@ -3656,7 +3655,7 @@ private void visitGETVALUE_primitive(GETVALUE_primitive inst, ValueType type) th Value v; if (placeHolder.origin == PlaceHolder.ValueOrigin.UNMODELED_RETURN && placeHolder.recoveredFormula != null) { - // G4: a whitelisted pure method with a primitive return - model the result as the + // A whitelisted pure method with a primitive return - model the result as the // carried generic UF over the inputs (concrete = observed), preserving the relational // fact (equal inputs => equal outputs) instead of concretizing. Mirrors the String // path in visitGETVALUE_Object. No MAKE_SYMBOLIC: the UF formula already carries the @@ -3704,9 +3703,10 @@ private void visitGETVALUE_primitive(GETVALUE_primitive inst, ValueType type) th || (!(peek instanceof BoxedValue) && !checkEquality(peek.concrete, inst.v))) { // The shadow's concrete diverges from the value the real JVM produced - an out-of-band // change (e.g. a tracked object mutated inside unmodeled code) or an executor desync. - // CRASH preserves the original hard-fail (dev/CI bug catching); FLAG records a soundness - // flag (context loss -> SAFE downgraded to UNKNOWN) and adopts the observed concrete - // (sound, graceful). Escape-aware differentiation (crash only on genuine desync) is G4a. + // CRASH keeps the strict hard-fail (development/CI bug catching); FLAG records a + // soundness flag (context loss, which downgrades a SAFE verdict to UNKNOWN) and adopts + // the observed concrete (sound, graceful). Escape-aware differentiation (crash only on a + // genuine desync) is not yet implemented. if (Config.instance().getShadowDivergence() == ShadowDivergence.CRASH) { SWATAssert.check(false, "[GETVALUE_primitive]: Value on stack does not match expected value! Expected: {}, Actual: {}", inst.v, peek.concrete); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java index 0c57d73..3a28548 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java @@ -12,8 +12,8 @@ import org.sosy_lab.java_smt.api.UFManager; /** - * Per-thread registry of the generic uninterpreted functions that model whitelisted pure JDK methods - * (G4). One UF symbol per signature (named via {@link PureMethods#ufName}), declared lazily and + * Per-thread registry of the generic uninterpreted functions that model whitelisted pure JDK methods. + * One UF symbol per signature (named via {@link PureMethods#ufName}), declared lazily and * cached. The UF is axiom-free: applying it asserts only equal-inputs => equal-outputs * (referential transparency), a sound over-approximation of any deterministic function. Observed * concrete input->output pairs are added separately (as constraints) to tighten it across runs. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java index d7308f1..38a5cc7 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureMethods.java @@ -5,17 +5,16 @@ /** * Whitelist of pure, deterministic, side-effect-free JDK methods that SWAT does NOT model, plus the - * descriptive naming scheme for the generic uninterpreted functions that model their unmodeled - * returns (G4). A whitelisted method's result is modeled as {@code pure__[_] - * (inputs)} instead of being concretized (G2) - preserving the relational fact (equal inputs => - * equal outputs) soundly, since an axiom-free UF over-approximates any deterministic function. + * descriptive naming scheme for the generic uninterpreted functions that model their returns. A + * whitelisted method's result is modeled as {@code pure__[_](inputs)} + * instead of being concretized - preserving the relational fact (equal inputs => equal outputs) + * soundly, since an axiom-free UF over-approximates any deterministic function. * - *

Membership is the soundness precondition: only genuinely pure + deterministic methods may + *

Membership is a soundness precondition: only genuinely pure and deterministic methods may * appear here. Exclude locale-dependent (no-arg {@code toLowerCase}/{@code toUpperCase}), * environment/property readers, argument-mutating, identity/{@code intern}, and nondeterministic - * (random/time) methods. The starter set is tiny and hand-audited; it is later scaled by a per-class - * survey of {@code java.lang}. String and all primitive return types are supported (the UF's return - * sort is the method's return type); the method must also be UNMODELED by SWAT, else the UF never + * (random/time) methods. String and all primitive return types are supported (the UF's return sort + * is the method's return type); the method must also be UNMODELED by SWAT, otherwise the UF never * fires. */ public final class PureMethods { @@ -23,14 +22,11 @@ private PureMethods() {} /** * Keys are {@code owner + "/" + name + desc} (descriptor included to disambiguate overloads). - * Entries are pure, deterministic, side-effect-free, and UNMODELED by SWAT (so the generic UF - * actually fires). Curated from the java.lang purity survey - * (docs/heap-redesign-g4-whitelist-survey.md); the boxed types' toString-family is intentionally - * absent (already modeled -> a UF would never fire). String and primitive returns are both - * supported. Membership combines the String-return starter set with the java.lang/util purity audit - * (per-class agents over Math/StrictMath/Character/Integer/Byte/Float/Double/Objects applying the - * jdk-source skill's rubric); every entry is pure, deterministic, side-effect-free, and UNMODELED - * (absent from its Invocation handler / a StringValue stub), so the generic UF actually fires. + * Every entry is pure, deterministic, side-effect-free, and UNMODELED by SWAT - absent from its + * Invocation handler, or present only as a StringValue stub - so the generic UF actually fires. + * The boxed types' toString-family is intentionally absent (already modeled, so a UF would never + * fire). Covers String and primitive returns across + * Math/StrictMath/Character/Integer/Byte/Float/Double/Objects and the String methods. */ private static final Set WHITELIST = Set.of( diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java index 5fce3aa..a1da8ff 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/UFHandler.java @@ -31,7 +31,7 @@ public SinCosUF getSinCosUF() throws NoThreadContextException { return sinCosUF; } - /** Registry of generic uninterpreted functions for whitelisted pure JDK methods (G4). */ + /** Registry of generic uninterpreted functions for whitelisted pure JDK methods. */ public PureFunctionUF getPureFunctionUF() throws NoThreadContextException { if (pureFunctionUF == null) { pureFunctionUF = new PureFunctionUF(ThreadHandler.getSolverContext(currentThread().getId())); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 8769b22..41fac64 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -34,10 +34,10 @@ public class InvocationHandler { "java/io/PrintStream/println", "de/uzl/its/swat/instrument/Intrinsics", "de/uzl/its/swat/common/UtilInstrumented", - // G3-B: refEquals's body (stepped, since UtilInstrumented is instrumented) - // calls these with a possibly-symbolic operand; ignore them so a reference - // comparison does not record spurious context loss. Both are pure/identity - // and their concretized results are all refEquals needs. + // refEquals's body (stepped through, since UtilInstrumented is + // instrumented) calls these with a possibly-symbolic operand; ignore them + // so a reference comparison does not record spurious context loss. Both are + // pure/identity and their concretized results are all refEquals needs. "de/uzl/its/swat/common/Util/shouldUseValueEquality", "de/uzl/its/swat/common/Provenance", "de/uzl/its/swat/witness/Witness", @@ -85,8 +85,8 @@ public class InvocationHandler { symbolicTraceHandler); } - // G4: model of a whitelisted pure return (result UF over symbolic inputs + the same UF over - // constant observed inputs, for the step-2 observed pair); stays null -> recovery concretizes (G2). + // Model of a whitelisted pure return (result UF over symbolic inputs + the same UF over + // constant observed inputs, for the observed pair); stays null -> recovery concretizes. PureUFModel pureUF = null; // When the method is not implemented and its not on the ignore list, we record it if (retValue instanceof PlaceHolder && @@ -120,7 +120,7 @@ public class InvocationHandler { invokeId, containsSymbolicArgument)); - // G4: model a whitelisted pure, value-returning call as a generic UF over its inputs + // Model a whitelisted pure, value-returning call as a generic UF over its inputs // (instead of concretizing at recovery). Sound by construction: an axiom-free UF // over-approximates any deterministic function. Only when an input is symbolic; // `arguments` here already includes the receiver (prepended above). @@ -129,9 +129,9 @@ public class InvocationHandler { } if( - // G4: a successfully UF-modeled pure call loses no context - the whitelist + // A successfully UF-modeled pure call loses no context - the whitelist // guarantees no side effects and the return is captured by the UF - so it must NOT - // downgrade SAFE; only flag context loss when we did not model the call. + // downgrade SAFE; only flag context loss when the call was not modeled. pureUF == null && (retValue.equals(PlaceHolder.instance) // To detect a missing implementation || retValue instanceof VoidValue vv && !vv.isSymbolic()) // To detect a missing implementation that returns nothing @@ -145,10 +145,10 @@ public class InvocationHandler { } } - // G2/G4: tag an unmodeled placeholder return so visitGETVALUE_Object recovers a value-typed + // Tag an unmodeled placeholder return so visitGETVALUE_Object recovers a value-typed // result instead of identity-recovering it (which would re-bind the receiver's symbolic value, - // e.g. String.toLowerCase() returning `this`). If a generic UF was built (G4), it rides along - // and the result is modeled as that UF; otherwise recovery concretizes (G2). This MUST stay + // e.g. String.toLowerCase() returning `this`). If a generic UF was built, it rides along + // and the result is modeled as that UF; otherwise recovery concretizes. This MUST stay // after the context-loss check above, which compares retValue against PlaceHolder.instance by // identity. if (retValue == PlaceHolder.instance) { @@ -160,16 +160,16 @@ public class InvocationHandler { return retValue; } - /** A whitelisted pure call modeled as a generic UF: the result over symbolic inputs, and (G4 - * step 2) the same UF over the constant observed inputs for the observed (input -> output) pair. */ + /** A whitelisted pure call modeled as a generic UF: the result over symbolic inputs, and the + * same UF over the constant observed inputs for the observed (input -> output) pair. */ private record PureUFModel(Formula result, Formula observedApplication) {} /** * Build the generic UF {@code pure_(inputs)} for a whitelisted pure call, or null to fall - * back to G2 concretization. Handles String and all primitive returns (the return sort is + * back to concretization. Handles String and all primitive returns (the return sort is * {@link #pureUFReturnType}); the inputs (receiver + args) must all be value-typed so their * formula fully captures the input (sound; no stateful receivers). Also builds the same UF applied - * to the CONSTANT inputs (step 2's observed-pair application) using the SAME cached declaration; + * to the CONSTANT inputs (the observed-pair application) using the SAME cached declaration; * that is null unless the return is a String and every input is a String (the only case whose * recovery side asserts the observed pair, and where String concretes become constant formulas). */ @@ -177,7 +177,7 @@ private static PureUFModel buildPureUF( String owner, String name, String desc, List> inputs) throws NoThreadContextException { // The UF's return sort is the method's return type - String or any primitive. Unsupported - // returns (void, arrays, non-String objects) yield null and fall back to G2 concretization. + // returns (void, arrays, non-String objects) yield null and fall back to concretization. FormulaType returnType = pureUFReturnType(desc); if (returnType == null) { return null; @@ -188,25 +188,25 @@ private static PureUFModel buildPureUF( .getStringFormulaManager(); List symbolicArgs = new ArrayList<>(); List constArgs = new ArrayList<>(); - boolean observable = true; // an observed pair needs constant-buildable (v1: String) inputs + boolean observable = true; // an observed pair needs constant-buildable (String) inputs for (Value v : inputs) { if (v.formula == null || !Util.isValueType(v.concrete)) { - return null; // non-value-typed or formula-less input: defer to G2 concretize. + return null; // non-value-typed or formula-less input: defer to concretization. } symbolicArgs.add((Formula) v.formula); if (v.concrete instanceof String s) { constArgs.add(smgr.makeString(s)); } else { - observable = false; // v1: only String inputs become constant formulas here. + observable = false; // only String inputs become constant formulas here. } } PureFunctionUF uf = ThreadHandler.getUFHandler(Thread.currentThread().getId()).getPureFunctionUF(); String ufName = PureMethods.ufName(owner, name, desc); Formula result = uf.apply(ufName, returnType, symbolicArgs); - // G4 step 2 observed pair: built only for String returns, the sole case whose recovery side + // Observed pair: built only for String returns, the sole case whose recovery side // (visitGETVALUE_Object) asserts the (constant inputs -> observed output) equality. The same // cached declaration is applied to the constant inputs so the pair constrains the very symbol - // used in `result`. Primitive observed pairs are future work (with the explorer step-2 side). + // used in `result`. Primitive observed pairs are future work (with the explorer side). Formula observed = (observable && FormulaType.StringType.equals(returnType)) ? uf.apply(ufName, returnType, constArgs) : null; return new PureUFModel(result, observed); @@ -216,7 +216,7 @@ private static PureUFModel buildPureUF( * The SMT return sort for a whitelisted pure method, matching the shadow value sorts exactly: * String; boolean; bitvectors of width 8/16/16/32/64 for byte/short/char/int/long; and * floating-point (single for float, double for double). Returns null for unsupported returns - * (void, arrays, non-String objects), which fall back to G2 concretization. + * (void, arrays, non-String objects), which fall back to concretization. */ private static FormulaType pureUFReturnType(String desc) { Type ret = Type.getReturnType(desc); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java index a29fe7a..83ec140 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/shadow/ShadowDivergence.java @@ -7,16 +7,16 @@ */ public enum ShadowDivergence { /** - * Hard-fail on divergence (SWATAssert) - preserves the original behavior, which is useful for - * catching executor-internal desync bugs in dev/CI. Default. + * Hard-fail on divergence (SWATAssert). Useful for catching executor-internal desync bugs in + * development and CI. Default. */ CRASH, /** - * Detect gracefully: record a soundness flag (context loss -> SAFE downgraded to UNKNOWN), adopt - * the observed concrete value, and continue. Fully sound; recommended for SV-COMP / production - * runs (no spurious crashes). Until escape-aware differentiation lands (G4a), this does not - * distinguish a legitimate out-of-band change from an executor desync - both are flagged. + * Detect gracefully: record a soundness flag (context loss, which downgrades a SAFE verdict to + * UNKNOWN), adopt the observed concrete value, and continue. Fully sound; recommended for SV-COMP + * and production runs (no spurious crashes). This policy does not distinguish a legitimate + * out-of-band change from an executor desync - both are flagged. */ FLAG } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java index 35caaa9..436ce6b 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/DTOBuilder.java @@ -56,7 +56,7 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre ArrayList inputs = new ArrayList<>(); ArrayList ufs = new ArrayList<>(); ArrayList trace = new ArrayList<>(); - // G4: the terms of the designated symbolic inputs, used to classify precision loss - a branch + // The terms of the designated symbolic inputs, used to classify precision loss: a branch // variable is "grounded" iff its term is one of these (exact + type-aware, no name pattern). Set inputTerms = new HashSet<>(); @@ -99,9 +99,9 @@ private static TraceDTO buildTraceDTO(SymbolicTrace symbolicTrace) throws NoThre constraint = String.valueOf(fmgr.dumpFormula(f)); branchPrecisionLoss = isPrecisionLoss(f, fmgr, inputTerms); } - // Executor-side decision (unchanged): aggregate = OR of the per-branch flags. The - // per-branch flag also travels on the BranchDTO so a future explorer-side, - // CFG-reachability-aware precision-loss decision can take over with no trace change (G4). + // Aggregate precision loss = OR of the per-branch flags. The per-branch flag also + // travels on the BranchDTO so a future explorer-side, CFG-reachability-aware + // precision-loss decision can take over with no trace change. symbolicPrecisionLoss |= branchPrecisionLoss; trace.add(new BranchDTO(be.getIid(), constraint, be.isBranched(), branchPrecisionLoss)); } else if (el instanceof SpecialElement se) { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java index 86b5541..13306c0 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/trace/dto/BranchDTO.java @@ -17,7 +17,7 @@ public class BranchDTO { private String inst; /** - * G4: the executor's per-branch precision-loss verdict. Carried so a future explorer-side, + * The executor's per-branch precision-loss verdict. Carried so a future explorer-side, * CFG-reachability-aware decision can key it by {@link #iid} to a CFG node. The current verdict * uses the aggregate {@code symbolicPrecisionLoss} on the TraceDTO (OR of these). */ diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index 0e1a1ee..76886f0 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -38,8 +38,8 @@ public enum ValueOrigin { GETSTATIC, // The return value of an unmodeled method (tagged in InvocationHandler). At recovery, a result // with this origin is NOT identity-recovered (which would re-bind the receiver). If it carries a - // pure_ UF (recoveredFormula, whitelisted pure method - String or primitive) it is modeled - // as that UF (G4); otherwise a value-typed result is concretized (G2). + // pure_ UF (recoveredFormula, a whitelisted pure method returning a String or primitive) it + // is modeled as that UF; otherwise a value-typed result is concretized. UNMODELED_RETURN } @@ -48,15 +48,15 @@ public enum ValueOrigin { public final Instruction inst; public final ObjectValue referenceValue; /** - * For an UNMODELED_RETURN placeholder of a whitelisted pure method (G4): the generic UF formula - * {@code pure_(inputs)} modeling the result. Null otherwise (then recovery concretizes, G2). + * For an UNMODELED_RETURN placeholder of a whitelisted pure method: the generic UF formula + * {@code pure_(inputs)} modeling the result. Null otherwise (recovery then concretizes). */ public final Formula recoveredFormula; /** - * G4 step 2: the same generic UF applied to the CONSTANT (observed) inputs, e.g. + * The same generic UF applied to the CONSTANT (observed) inputs, e.g. * {@code pure_(makeString(concreteInput))}. At recovery this is asserted equal to the * observed concrete output to record a ground (input -> output) pair. Null when no pair is - * emitted (non-String inputs / not a whitelisted pure call). + * emitted (non-String inputs, or not a whitelisted pure call). */ public final Formula observedApplication; public static final PlaceHolder instance = new PlaceHolder(false); @@ -90,7 +90,7 @@ public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { } /** - * UNMODELED_RETURN placeholder carrying, for a whitelisted pure method (G4): the generic UF over + * UNMODELED_RETURN placeholder carrying, for a whitelisted pure method: the generic UF over * the symbolic inputs ({@code recoveredFormula}, modeling the result) and the same UF over the * constant observed inputs ({@code observedApplication}, used to record the observed pair). Either * may be null. diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java index fd4e89c..e31de7b 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/ValueFactory.java @@ -47,7 +47,7 @@ public class ValueFactory { /** * Build a primitive value of {@code type} carrying an explicit symbolic {@code formula} (its - * concrete is the observed result). Used by G4 to model a whitelisted pure primitive return as a + * concrete is the observed result). Used to model a whitelisted pure primitive return as a * generic UF over its inputs. The formula's sort MUST match the value's sort (see * {@code InvocationHandler#pureUFReturnType}): bitvector of width 8/16/16/32/64 for * byte/short/char/int/long, floating-point for float/double, boolean for boolean. From 27d3639eb839f3c71416d17bb3f7bc33045f1daf Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:55:09 +0000 Subject: [PATCH 24/33] test(heap): describe cases in plain language; drop internal case IDs Rewrite Spock feature-method names and comments to plain-English behavior descriptions, dropping internal case identifiers (O-/V-/F-/E-/D-/U-) and phase labels. Rename GoobDetectionSpec -> OutOfBandDetectionSpec and HeapRecoveryV1AgentSpec -> HeapRecoveryAgentSpec. No assertion or logic changes. Co-Authored-By: Claude Opus 4.8 --- .../common/ProvenanceRefEqualsSpec.groovy | 26 ++++++------- .../its/swat/common/UtilClassNameSpec.groovy | 9 ++--- .../heap/BoxedDeInternAgentSpec.groovy | 19 ++++------ .../swat/symbolic/heap/FlagPolicySpec.groovy | 9 ++--- .../heap/HeapRecoveryAgentSpec.groovy | 32 ++++++++++++++++ .../heap/HeapRecoveryV1AgentSpec.groovy | 36 ------------------ .../symbolic/heap/ObjectIdentitySpec.groovy | 24 +++++------- ...c.groovy => OutOfBandDetectionSpec.groovy} | 16 ++++---- .../symbolic/heap/OutputDeInternSpec.groovy | 25 ++++++------ .../heap/PrecisionLossExemptionSpec.groovy | 17 ++++----- .../PureFunctionPrimitiveAgentSpec.groovy | 15 +++----- .../heap/PureFunctionUFAgentSpec.groovy | 23 +++++------ .../symbolic/heap/PureFunctionUFSpec.groovy | 21 +++++----- .../heap/PureUFPrimitiveRecoverySpec.groovy | 13 +++---- .../symbolic/heap/StringRefEqAgentSpec.groovy | 15 ++++---- .../heap/StringWhitelistAgentSpec.groovy | 15 +++----- .../symbolic/heap/ValueRecoverySpec.groovy | 19 +++++----- .../symbolic/heap/ValueSemanticsSpec.groovy | 12 +++--- .../heap/WhitelistAuditAgentSpec.groovy | 17 ++++----- .../resources/targets/BoxedReturnTarget.java | 38 +++++++++---------- .../targets/PurePrimReturnTarget.java | 10 ++--- .../resources/targets/StringRefEqTarget.java | 10 ++--- .../targets/StringWhitelistTarget.java | 9 ++--- .../resources/targets/SubstringTarget.java | 13 +++---- .../resources/targets/ToLowerCaseTarget.java | 12 ++---- .../test/resources/targets/TrimTarget.java | 16 +++----- .../targets/WhitelistAuditAgentTarget.java | 11 +++--- 27 files changed, 209 insertions(+), 273 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryAgentSpec.groovy delete mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy rename symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/{GoobDetectionSpec.groovy => OutOfBandDetectionSpec.groovy} (78%) diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy index 1f7a8b1..a4cf01e 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/ProvenanceRefEqualsSpec.groovy @@ -3,19 +3,17 @@ package de.uzl.its.swat.common import spock.lang.Specification /** - * G3-B unit test: {@link UtilInstrumented#refEquals} models reference equality by ORIGINAL identity via - * the {@link Provenance} map. Two de-interned copies that root to the same canonical compare equal; - * distinct canonicals compare unequal; non-de-interned classes fall back to plain reference equality. - * This pins the executor-independent core of G3-B (the de-intern bytecode that populates the map is - * exercised at L2). See docs/heap-redesign-g3-design.md. + * Reference equality modeled by original identity through the provenance map. Two de-interned copies + * that root to the same canonical compare equal; distinct canonicals compare unequal; non-de-interned + * classes fall back to plain reference equality. */ class ProvenanceRefEqualsSpec extends Specification { def "de-interned Strings rooting to the same interned canonical compare equal"() { given: "two distinct de-interned copies of the same literal, both rooted to the interned canonical" - String canonical = "g3b-abc".intern() - String a = new String("g3b-abc") - String b = new String("g3b-abc") + String canonical = "shared-abc".intern() + String a = new String("shared-abc") + String b = new String("shared-abc") Provenance.record(a, canonical) Provenance.record(b, canonical) @@ -27,9 +25,9 @@ class ProvenanceRefEqualsSpec extends Specification { def "a de-interned copy vs a same-valued object with a different root compares unequal"() { given: "a rooted to the interned canonical; b has no provenance entry (root(b)=b)" - String a = new String("g3b-x") - Provenance.record(a, "g3b-x".intern()) - String b = new String("g3b-x") + String a = new String("shared-x") + Provenance.record(a, "shared-x".intern()) + String b = new String("shared-x") expect: "distinct roots -> unequal (matches real new String(\"x\") == \"x\" -> false)" !UtilInstrumented.refEquals(a, b) @@ -59,10 +57,10 @@ class ProvenanceRefEqualsSpec extends Specification { def "root collapses chains at insert"() { given: "c rooted to b, b rooted to canonical -> root(c) must resolve to canonical" - String canonical = "g3b-chain".intern() - String b = new String("g3b-chain") + String canonical = "shared-chain".intern() + String b = new String("shared-chain") Provenance.record(b, canonical) - String c = new String("g3b-chain") + String c = new String("shared-chain") Provenance.record(c, b) expect: diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy index 1cc586e..25624bf 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/common/UtilClassNameSpec.groovy @@ -12,12 +12,9 @@ import spock.lang.Unroll * ({@code a.b.C}) or internal/slashed ({@code a/b/C}). {@code formatClassName} canonicalizes to the * internal form. * - *

A regression (commit a80dc60, incidental to an unrelated UF commit) added - * {@code && !className.startsWith("L")} to the guard. Because the predicate keys on the first - * character, it spuriously rejected every legitimate class name beginning with 'L' — notably - * default-package targets/benchmarks named {@code L...} (e.g. {@code LitSymTarget}) — crashing - * instrumentation through {@link SWATAssert}. Object descriptors are already caught by the {@code ';'} - * clause, so the term was pure false-positive surface and was removed. This pins both halves of the + *

A class name beginning with 'L' (e.g. a default-package target named {@code LitSymTarget}) is a + * valid class name, not an object descriptor, so the guard must accept it: object descriptors are + * distinguished by their trailing {@code ';'}, not by a leading 'L'. This pins both halves of the * contract: L-prefixed class names round-trip, real descriptors still throw. */ class UtilClassNameSpec extends Specification { diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy index 78e257a..4ec86c6 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/BoxedDeInternAgentSpec.groovy @@ -5,21 +5,16 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G3-A2 end-to-end anchor (Level L2): the REAL agent runs a program that drives the boxed de-intern - * instrumentation through both emitted-bytecode paths - the unbox+rebox de-intern wrap on - * un-instrumented valueOf(String) returns for Integer (category-1) and Long (category-2 wide-local), - * and the valueOf(primitive)->new rewrite via autoboxing for ALL SIX wrappers (Integer/Long/Short/Byte/ - * Character/Boolean), the path whose per-wrapper descriptors are DERIVED from the Boxed enum. The run - * completing (AgentRun asserts exit == 0 plus a parsed TraceDTO) is the bytecode-validity oracle: a - * malformed wrap or rewrite would VerifyError. Object-identity (the actual de-intern effect) is pinned - * at L1 by OutputDeInternSpec, the boxed-cache == semantics at L0 by ProvenanceRefEqualsSpec; this - * anchors that the boxed bytecode is real-JVM-valid for every wrapper. Mirrors PureFunctionUFAgentSpec. - * - * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + * End-to-end check that the boxed de-intern instrumentation produces JVM-valid bytecode. The real agent + * runs a program driving both emitted paths: the unbox+rebox wrap on un-instrumented valueOf(String) + * returns for Integer and Long, and the valueOf(primitive)->new rewrite via autoboxing for all six + * wrappers (Integer, Long, Short, Byte, Character, Boolean). The run completing (exit 0 plus a parsed + * trace) is the oracle: a malformed wrap or rewrite would raise a VerifyError. Object-identity effects + * are covered by OutputDeInternSpec and the boxed-cache equality semantics by ProvenanceRefEqualsSpec. */ class BoxedDeInternAgentSpec extends Specification { - def "G3-A2 (L2): all six wrappers' valueOf-rewrite (+ Integer/Long de-intern) load, verify, and run"() { + def "all six wrappers' valueOf-rewrite and Integer/Long de-intern load, verify, and run"() { when: "the agent runs a program driving the boxed de-intern bytecode paths" TraceObservation obs = AgentRun.run("targets/BoxedReturnTarget.java", "BoxedReturnTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy index 36cc9e8..7cb8c26 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/FlagPolicySpec.groovy @@ -5,16 +5,15 @@ import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue /** - * Context-loss flag policy at Level L1: the flag fires iff symbolic data flowed into the unmodeled - * call. F-1 (concrete receiver → no flag) and F-2 (symbolic receiver → flag) document the currently - * correct behavior and guard it. See docs/heap-redesign-tests.md. + * Context-loss flag policy: the flag fires if and only if symbolic data flowed into an unmodeled call. + * A concrete receiver raises no flag; a symbolic receiver raises the flag. */ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { private static final String STRING = "java/lang/String" private static final String TO_LOWER = "()Ljava/lang/String;" - def "F-1: an unmodeled call with a concrete receiver raises no context-loss flag"() { + def "an unmodeled call with a concrete receiver raises no context-loss flag"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) // concrete (not made symbolic) @@ -26,7 +25,7 @@ class FlagPolicySpec extends BaseSymbolicInstructionProcessorSpec { !result.contextLoss } - def "F-2: an unmodeled call with a symbolic receiver raises a context-loss flag"() { + def "an unmodeled call with a symbolic receiver raises a context-loss flag"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryAgentSpec.groovy new file mode 100644 index 0000000..247482f --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryAgentSpec.groovy @@ -0,0 +1,32 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.Specification + +/** + * End-to-end recovery: the agent runs a real {@code @Symbolic} toLowerCase program and the emitted + * TraceDTO is observed. The identity hash, the this-return and the soundness flags come from the + * real JVM and instrumenter rather than a fabricated instruction stream. + */ +class HeapRecoveryAgentSpec extends Specification { + + def "real toLowerCase on a symbolic string flags context loss"() { + when: + TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") + + then: "the symbolic input is designated and the soundness backstop (context loss) fires" + obs.inputNames.any { it.startsWith("java/lang/String") } + obs.symbolicContextLoss + } + + def "a branch after unmodeled toLowerCase does not reference the symbolic input"() { + when: + TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") + String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } + + then: "the result is concrete, so no branch constraint mentions the input" + inputVar != null + !obs.anyBranchReferences(inputVar) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy deleted file mode 100644 index cf4c263..0000000 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/HeapRecoveryV1AgentSpec.groovy +++ /dev/null @@ -1,36 +0,0 @@ -package de.uzl.its.swat.symbolic.heap - -import de.uzl.its.swat.testsupport.agent.AgentRun -import de.uzl.its.swat.testsupport.agent.TraceObservation -import spock.lang.Specification - -/** - * V-1 / R-1 at Level L2: the REAL agent runs a real {@code @Symbolic} toLowerCase program - * (ToLowerCaseTarget) and we observe the emitted TraceDTO. This is the faithful altitude — the - * identity hash, the this-return and the soundness flags come from the real JVM + instrumenter, not - * a fabricated instruction stream. - * - * Naming: {@code *AgentSpec} → run by the opt-in {@code agentTest} Gradle task, excluded from - * {@code test}. See docs/test-architecture.md (Level L2). - */ -class HeapRecoveryV1AgentSpec extends Specification { - - def "L2 soundness anchor: real toLowerCase on a symbolic string flags context loss"() { - when: - TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") - - then: "the symbolic input is designated and the soundness backstop (context loss) fires" - obs.inputNames.any { it.startsWith("java/lang/String") } - obs.symbolicContextLoss - } - - def "V-1 (L2): a branch after unmodeled toLowerCase must not reference the symbolic input"() { - when: - TraceObservation obs = AgentRun.run("targets/ToLowerCaseTarget.java", "ToLowerCaseTarget") - String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } - - then: "post-G2 the result is concrete, so no branch constraint mentions the input (RED until G2)" - inputVar != null - !obs.anyBranchReferences(inputVar) - } -} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy index 594877e..9d6e8b2 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ObjectIdentitySpec.groovy @@ -5,14 +5,12 @@ import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.symbolic.value.reference.ObjectValue /** - * O-4 / O-5 — object reference-equality and the canonical registry. Level L0, Phase 1 (G1). - * - * The G1 registry keys the heap by the concrete object reference (identity), so it returns one - * canonical wrapper per concrete object and keeps distinct objects distinct even when their identity - * hashes collide. {@code ObjectValue.IF_ACMPEQ} stays {@code this == o2}, which is correct under that - * one-wrapper-per-identity guarantee. These specs assert both: same object → same wrapper → equal - * (O-4); distinct objects → distinct wrappers / two entries → unequal (O-5). The faithful end-to-end - * recovery is additionally anchored at L2 (see HeapRecoveryV1AgentSpec). + * Object reference-equality and the canonical registry. The registry keys the heap by the concrete + * object reference (identity), so it returns one canonical wrapper per concrete object and keeps + * distinct objects distinct even when their identity hashes collide. {@code ObjectValue.IF_ACMPEQ} + * stays {@code this == o2}, which is correct under that one-wrapper-per-identity guarantee. These + * specs assert both: the same object recovers the same wrapper and compares equal, while distinct + * objects stay distinct wrappers and compare unequal. */ class ObjectIdentitySpec extends BaseValueSpec { @@ -20,7 +18,7 @@ class ObjectIdentitySpec extends BaseValueSpec { return new ObjectValue(context, "de/uzl/its/swat/test/Obj", new IntValue(context, 1), address) } - def "O-4: two references to distinct objects compare reference-unequal"() { + def "two references to distinct objects compare reference-unequal"() { given: ObjectValue a = objectAt(0x2000) ObjectValue c = objectAt(0x3000) @@ -29,7 +27,7 @@ class ObjectIdentitySpec extends BaseValueSpec { isUnsatisfiable(a.IF_ACMPEQ(c)) } - def "O-4: the same concrete object recovers the same canonical wrapper (reference-equal)"() { + def "the same concrete object recovers the same canonical wrapper (reference-equal)"() { given: "a shadow registered under a concrete object" Object obj = new Object() ObjectValue shadow = objectAt(0x2000) @@ -45,7 +43,7 @@ class ObjectIdentitySpec extends BaseValueSpec { isValid((a as ObjectValue).IF_ACMPEQ(b as ObjectValue)) } - def "O-5: distinct objects with a colliding identity hash compare reference-unequal"() { + def "distinct objects with a colliding identity hash compare reference-unequal"() { given: "two distinct objects whose identity hash (address) collides" ObjectValue a = objectAt(0x5000) ObjectValue b = objectAt(0x5000) @@ -54,7 +52,7 @@ class ObjectIdentitySpec extends BaseValueSpec { isUnsatisfiable(a.IF_ACMPEQ(b)) } - def "O-5: distinct concrete objects are stored without merging (reference keying)"() { + def "distinct concrete objects are stored without merging (reference keying)"() { given: "two distinct concrete objects, with shadows that happen to share an address" Object o1 = new Object() Object o2 = new Object() @@ -67,8 +65,6 @@ class ObjectIdentitySpec extends BaseValueSpec { shadow.putToHeap(o2, b) then: "the reference-keyed registry keeps distinct objects as distinct entries" - // (Structural contract of reference keying; the behavioral collision/recovery coverage is at - // L1 (ValueRecoverySpec) and the L2 anchor.) shadow.heapSize() == 2 } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutOfBandDetectionSpec.groovy similarity index 78% rename from symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy rename to symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutOfBandDetectionSpec.groovy index ad3fa04..bb05d25 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/GoobDetectionSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutOfBandDetectionSpec.groovy @@ -14,14 +14,12 @@ import de.uzl.its.swat.symbolic.value.primitive.numeric.integral.IntValue import de.uzl.its.swat.thread.ThreadHandler /** - * E-1 / E-2 (Level L1): out-of-band change detection at a primitive GETVALUE sync. When the value the - * real JVM produced diverges from the tracked shadow's concrete (e.g. a field mutated inside unmodeled - * code), the {@code shadowDivergence=FLAG} policy detects it gracefully — record context loss, adopt - * the observed concrete, and do NOT throw — instead of the CRASH policy's hard assert. The - * escape-aware "crash only on genuine desync" differentiation is deferred to G4a. See - * docs/heap-redesign-tests.md and docs/heap-redesign-goob-design.md. + * Out-of-band change detection at a primitive GETVALUE sync. When the value the real JVM produced + * diverges from the tracked shadow's concrete (for example, a field mutated inside unmodeled code), the + * {@code shadowDivergence=FLAG} policy handles it gracefully: it records context loss, adopts the + * observed concrete, and does not throw, unlike the CRASH policy's hard assert. */ -class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { +class OutOfBandDetectionSpec extends BaseSymbolicInstructionProcessorSpec { private ShadowDivergence savedPolicy @@ -46,7 +44,7 @@ class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { } } - def "E-1: under FLAG a diverging primitive GETVALUE is detected (flag + re-ground), not crashed"() { + def "under FLAG a diverging primitive GETVALUE is flagged and re-grounded, not crashed"() { given: "a tracked int shadow with concrete 10, under the FLAG policy" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") Config.instance().setShadowDivergence(ShadowDivergence.FLAG) @@ -61,7 +59,7 @@ class GoobDetectionSpec extends BaseSymbolicInstructionProcessorSpec { visitor.getStack().getActiveFrame().peek().concrete == 20 } - def "E-2: under FLAG a matching primitive GETVALUE records no flag (no false positive)"() { + def "under FLAG a matching primitive GETVALUE records no context-loss flag"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") Config.instance().setShadowDivergence(ShadowDivergence.FLAG) diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy index bdfb1a9..de6c4d3 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/OutputDeInternSpec.groovy @@ -16,14 +16,11 @@ import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import de.uzl.its.swat.thread.ThreadHandler /** - * G3 register-only-non-constant (Level L1). At the value-typed GETVALUE recovery, a de-interned value - * type (String or a cached boxed wrapper) whose shadow carries symbolic content (a variable/UF) IS - * heap-registered so it round-trips through untracked space (the whitelisted-pure-UF win), while a - * pure-constant shadow is NOT registered — it is reconstructible from the observed concrete, so - * registering it would only grow the heap. G3 de-interning (bytecode) makes the registered reference - * sound; this spec pins the executor-side registration policy for String (A1) and boxed (A2), and that - * mutable objects on the shared boxed/mutable recovery path keep unconditional registration. See - * docs/heap-redesign-g3-design.md. + * Registration policy at value-typed GETVALUE recovery. A de-interned value type (String or a cached + * boxed wrapper) whose shadow carries symbolic content is heap-registered so it round-trips through + * untracked space, while a pure-constant shadow is not registered because it is reconstructible from + * the observed concrete. Mutable (non-value-type) objects on the shared recovery path stay + * unconditionally registered. */ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { @@ -43,7 +40,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { } } - def "G3: a symbolic String shadow IS heap-registered (round-trip enabled)"() { + def "a symbolic String shadow is heap-registered so it can round-trip"() { given: "a symbolic String shadow awaiting its address (ADDRESS_UNKNOWN)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") String obj = "symbolic-abc" @@ -57,7 +54,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null } - def "G3: a constant String shadow is NOT heap-registered (reconstructible; no leak)"() { + def "a constant String shadow is not heap-registered since it is reconstructible"() { given: "a pure-constant String shadow (formula = makeString) awaiting its address" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") String obj = "constant-xyz" @@ -70,7 +67,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null } - def "G3-A2: a symbolic boxed shadow IS heap-registered (round-trip enabled)"() { + def "a symbolic boxed shadow is heap-registered so it can round-trip"() { given: "a symbolic Integer shadow awaiting its address (formula carried by the inner IntValue)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") Integer obj = 7 @@ -85,7 +82,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null } - def "G3-A2: a constant boxed shadow is NOT heap-registered (reconstructible; no leak)"() { + def "a constant boxed shadow is not heap-registered since it is reconstructible"() { given: "a pure-constant Integer shadow awaiting its address" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") Integer obj = 7 @@ -99,7 +96,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) == null } - def "G3-A2: a mutable (non-value-type) object is ALWAYS registered (shared path unaffected)"() { + def "a mutable non-value-type object is always heap-registered"() { given: "a plain mutable object whose class is not de-interned" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") Object obj = new Object() @@ -108,7 +105,7 @@ class OutputDeInternSpec extends BaseSymbolicInstructionProcessorSpec { when: "the value is recovered" recover(shadow, obj) - then: "register-only-non-constant must NOT touch mutable objects - sound identity key, must track" + then: "a mutable object is still heap-registered - it has a sound identity key and must be tracked" ThreadHandler.getSymbolicVisitor(threadId).getStack().getFromHeap(obj) != null } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy index 99aec7e..e48c7cd 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PrecisionLossExemptionSpec.groovy @@ -7,17 +7,16 @@ import org.sosy_lab.java_smt.api.FormulaType import org.sosy_lab.java_smt.api.StringFormula /** - * G4 exemption controls (Level L0): {@link DTOBuilder#isPrecisionLoss} must treat a whitelisted - * generic {@code pure_} UF over real inputs as precision-PRESERVING (sound: an axiom-free UF over - * inputs over-approximates any deterministic function, so UNSAT-under-free-UF => real UNSAT => - * SAFE holds), while a non-input variable or a bespoke (non-{@code pure_}) UF still loses precision. - * Input-ness is decided by exact term identity against the designated inputs (NOT a name pattern), so - * it works for String inputs (named {@code java/lang/String_0}, which the old regex wrongly rejected). - * See docs/heap-redesign-g4-design.md. + * Precision-loss exemption for pure uninterpreted functions. {@link DTOBuilder#isPrecisionLoss} + * treats a whitelisted generic {@code pure_} UF over designated inputs as precision-preserving (an + * axiom-free UF over inputs over-approximates any deterministic function, so UNSAT under the free UF + * implies real UNSAT and SAFE holds), while a non-input variable or a bespoke (non-{@code pure_}) UF + * still loses precision. Input-ness is decided by exact term identity against the designated inputs + * rather than a name pattern, so it also holds for String inputs named {@code java/lang/String_0}. */ class PrecisionLossExemptionSpec extends BaseValueSpec { - // A realistic String input variable name - lowercase + slashes, which the old [A-Z].*_[0-9].* regex rejected. + // A realistic String input variable name: lowercase plus slashes. private StringFormula inputVar() { fmgr.getStringFormulaManager().makeVariable("java/lang/String_0") } private StringFormula otherVar() { fmgr.getStringFormulaManager().makeVariable("intermediate") } @@ -32,7 +31,7 @@ class PrecisionLossExemptionSpec extends BaseValueSpec { return fmgr.getStringFormulaManager().equal(s, fmgr.getStringFormulaManager().makeString("abc")) } - def "a designated input variable alone is not precision loss (incl. a String input the regex rejected)"() { + def "a designated input variable alone is not precision loss, including a String input"() { given: def v = inputVar() expect: diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy index 3d3fa00..ae2f360 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionPrimitiveAgentSpec.groovy @@ -5,18 +5,15 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G4 primitive-return end-to-end anchor (Level L2): the REAL agent runs a program that takes pure, - * unmodeled, PRIMITIVE-returning java.lang methods (Math.floorDiv/floorMod/cbrt, Float.intBitsToFloat, - * Character.toLowerCase/isDigit) on symbolic inputs and branches on each result. Each result must be - * modeled as a generic {@code pure_} UF over its inputs (the int/long/double/float/char/boolean - * return sorts), so the run preserves SAFE: NO context loss, NO precision loss, and the UFs actually - * ride into the branch constraints. Mirrors PureFunctionUFAgentSpec (the String-return analogue). - * - * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + * End-to-end check that pure, unmodeled, primitive-returning java.lang methods + * (Math.floorDiv/floorMod/cbrt, Float.intBitsToFloat, Character.toLowerCase/isDigit) on symbolic + * inputs are each modeled as a generic {@code pure_} UF over their inputs, across the + * int/long/double/float/char/boolean return sorts. The run preserves SAFE: no context loss, no + * precision loss, and the UFs ride into the branch constraints. */ class PureFunctionPrimitiveAgentSpec extends Specification { - def "G4 (L2): pure primitive-returning methods are modeled as UFs (no context/precision loss)"() { + def "pure primitive-returning methods are modeled as UFs (no context or precision loss)"() { when: "the agent runs a program branching on pure unmodeled primitive returns over symbolic inputs" TraceObservation obs = AgentRun.run("targets/PurePrimReturnTarget.java", "PurePrimReturnTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy index a4df8fe..39d3b79 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFAgentSpec.groovy @@ -5,19 +5,14 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G4 end-to-end anchor at Level L2: the REAL agent runs a {@code @Symbolic} program that calls a - * whitelisted pure unmodeled method ({@code String.trim()}) and branches on its result - * (TrimTarget). The emitted TraceDTO must show the relationship preserved (the branch references the - * input through the {@code pure_String_trim} UF) AND neither soundness flag set - i.e. SAFE is - * genuinely preserved through the call, the headline G4-full claim, verified with real instrumentation - * rather than a fabricated instruction stream. Contrast with HeapRecoveryV1AgentSpec, where the - * non-whitelisted {@code toLowerCase} still flags context loss. - * - * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + * End-to-end check that the agent runs a {@code @Symbolic} program calling a whitelisted pure + * unmodeled method ({@code String.trim()}) and branching on its result. The emitted trace shows the + * relationship preserved (the branch references the input through the {@code pure_String_trim} UF) + * and neither soundness flag set, so a SAFE verdict survives the call. */ class PureFunctionUFAgentSpec extends Specification { - def "G4 (L2): a whitelisted pure trim into a branch preserves SAFE (no context/precision loss)"() { + def "a whitelisted pure trim into a branch preserves SAFE (no context or precision loss)"() { when: TraceObservation obs = AgentRun.run("targets/TrimTarget.java", "TrimTarget") String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } @@ -25,15 +20,15 @@ class PureFunctionUFAgentSpec extends Specification { then: "the symbolic input is designated" inputVar != null - and: "the relationship is preserved - the branch references the input through the UF (unlike G2)" + and: "the relationship is preserved - the branch references the input through the UF" obs.anyBranchReferences(inputVar) - and: "neither SAFE downgrade fires: context loss skipped (modeled) and the pure_ UF is exempt" + and: "neither SAFE downgrade fires: context loss is skipped because modeled, and the pure_ UF is exempt" !obs.symbolicContextLoss !obs.symbolicPrecisionLoss } - def "G4 (L2): a whitelisted pure substring (arg-taking, mixed-sort UF) into a branch preserves SAFE"() { + def "a whitelisted pure substring (arg-taking, mixed-sort UF) into a branch preserves SAFE"() { when: TraceObservation obs = AgentRun.run("targets/SubstringTarget.java", "SubstringTarget") String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } @@ -44,7 +39,7 @@ class PureFunctionUFAgentSpec extends Specification { and: "the branch references the input through the mixed-sort UF pure_String_substring_int" obs.anyBranchReferences(inputVar) - and: "neither SAFE downgrade fires (whitelist expansion to an arg-taking method stays sound)" + and: "neither SAFE downgrade fires (an arg-taking whitelisted method stays sound)" !obs.symbolicContextLoss !obs.symbolicPrecisionLoss } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy index 3fd8b9e..2dd1f95 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureFunctionUFSpec.groovy @@ -9,11 +9,10 @@ import org.sosy_lab.java_smt.api.Formula import org.sosy_lab.java_smt.api.StringFormula /** - * G4 generic-UF mechanism (Level L1). A whitelisted, pure, UNMODELED value-returning call - * ({@code String.trim}) on a symbolic input is modeled as the generic UF {@code pure_String_trim} - * over that input, instead of being concretized (G2). This preserves the relational fact (equal - * inputs => equal outputs) while staying sound (the UF is axiom-free). See - * docs/heap-redesign-g4-design.md and docs/heap-redesign-tests.md. + * Verifies that a whitelisted, pure, unmodeled value-returning call ({@code String.trim}) on a + * symbolic input is modeled as the uninterpreted function {@code pure_String_trim} over that input + * instead of being concretized. This preserves the relational fact (equal inputs imply equal + * outputs) while staying sound, because the UF is axiom-free. */ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { @@ -44,7 +43,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { } finally { p.close() } } - def "U-5: trim (whitelisted, unmodeled) models its result as a UF over the input"() { + def "a whitelisted unmodeled trim models its result as a UF over the input"() { given: "a symbolic String receiver" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) @@ -61,7 +60,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { result.recovered.concrete == "abc" } - def "U-4: equal inputs yield equal results (UF congruence / determinism)"() { + def "equal inputs yield equal results (UF congruence / determinism)"() { given: "two independently symbolic String receivers" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue s1 = new StringValue(solverContext, "abc", 0x1000); s1.MAKE_SYMBOLIC() @@ -79,7 +78,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { isValid(bmgr.implication(premise, conclusion)) } - def "U-soundness: the axiom-free UF excludes no real behavior (result can equal any value)"() { + def "the axiom-free UF excludes no real behavior (result can equal any value)"() { given: setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) @@ -93,7 +92,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { isSat(smgr.equal(result.recovered.formula as StringFormula, smgr.makeString("a totally different value"))) } - def "U-7: a whitelisted pure call emits its observed (input->output) pair as a ground UF constraint"() { + def "a whitelisted pure call emits its observed input-output pair as a ground UF constraint"() { given: "a symbolic String receiver with a known concrete value" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) @@ -112,7 +111,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { fm.extractVariables(pair).isEmpty() } - def "U-6: a whitelisted pure call is modeled (UF), so it does NOT flag context loss"() { + def "a whitelisted pure call is modeled as a UF, so it does not flag context loss"() { given: "a symbolic String receiver" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") StringValue receiver = new StringValue(solverContext, "abc", 0x1000) @@ -121,7 +120,7 @@ class PureFunctionUFSpec extends BaseSymbolicInstructionProcessorSpec { when: "trim() (whitelisted, pure) is invoked and modeled as a UF" def result = executeBoundaryRecovery(receiver, STRING, "trim", TRIM, "abc") - then: "context loss is NOT flagged (it is the only remaining SAFE downgrade, and we modeled it)" + then: "context loss is not flagged, since the call is modeled" !result.contextLoss } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy index 0be6835..8cb0e87 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/PureUFPrimitiveRecoverySpec.groovy @@ -7,13 +7,12 @@ import de.uzl.its.swat.symbolic.value.primitive.numeric.floatingpoint.FloatValue import spock.lang.Unroll /** - * G4 primitive-return recovery unit (Level L0): {@link ValueFactory#createNumericalValue(ValueType, - * Object, org.sosy_lab.java_smt.api.Formula)} builds a primitive value carrying an explicit symbolic - * formula (concrete = observed), for EVERY primitive sort. This is the construction the primitive - * GETVALUE recovery uses to install a {@code pure_} UF result. It pins the per-sort cast and that - * a carried symbolic formula makes the value symbolic - including short and byte, which have no pure - * unmodeled JDK method to exercise at L2. The sort matching of int/long/double/float/char/boolean is - * additionally anchored end-to-end by PureFunctionPrimitiveAgentSpec. + * Primitive-return recovery: {@link ValueFactory#createNumericalValue(ValueType, Object, + * org.sosy_lab.java_smt.api.Formula)} builds a primitive value carrying an explicit symbolic formula + * (concrete = observed), for every primitive sort. This is the construction that installs a + * {@code pure_} UF result during primitive recovery. It pins the per-sort cast and that a + * carried symbolic formula makes the value symbolic, including short and byte, which have no pure + * unmodeled JDK method to exercise against a running agent. */ class PureUFPrimitiveRecoverySpec extends BaseValueSpec { diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy index 128d3a9..8e61779 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringRefEqAgentSpec.groovy @@ -5,23 +5,22 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G3-B end-to-end acceptance (Level L2): a de-interned {@code ==} (symbolic String vs a literal) is - * modeled via Provenance.root as a reference comparison, and must fire NEITHER soundness flag. This - * pins the round-2 mechanism correction: the stepped {@code refEquals} body's {@code root}/ - * {@code shouldUseValueEquality} calls are IGNORED (no context loss), and {@code refEquals} no longer - * routes through {@code Objects.equals} (no reference-semantic-change). The old value-equality - * {@code refEquals} fired both flags here. Mirrors PureFunctionUFAgentSpec. + * End-to-end check that a de-interned {@code ==} (symbolic String versus a literal) is modeled via + * {@code Provenance.root} as a reference comparison and fires neither soundness flag. The + * {@code refEquals} body's {@code root}/{@code shouldUseValueEquality} calls are ignored (no context + * loss), and {@code refEquals} does not route through {@code Objects.equals} (no reference-semantic + * change). */ class StringRefEqAgentSpec extends Specification { - def "G3-B (L2): de-interned == is modeled by provenance root and fires neither soundness flag"() { + def "de-interned == is modeled by provenance root and fires neither soundness flag"() { when: TraceObservation obs = AgentRun.run("targets/StringRefEqTarget.java", "StringRefEqTarget") then: "the symbolic input is designated" obs.inputNames.any { it.startsWith("java/lang/String") } - and: "modeling == via root suppresses context-loss (IGNORED Provenance.root + shouldUseValueEquality)" + and: "modeling == via root suppresses context loss (ignored Provenance.root and shouldUseValueEquality)" !obs.symbolicContextLoss } } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy index af29b1e..1f2c0b1 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/StringWhitelistAgentSpec.groovy @@ -5,18 +5,15 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G4 String-whitelist anchor (Level L2): the REAL agent runs a program calling pure String methods that - * the purity audit confirmed are UNMODELED stubs in StringValue (so the generic pure_ UF fires) - - * across String, int and boolean returns - on a symbolic String receiver. Each must be modeled as a UF, - * preserving SAFE (no context loss, no precision loss) with the UFs in the branch constraints. The - * modeled String methods (substring, charAt, length, ...) are intentionally NOT whitelisted; they stay - * precise and are exercised by PureFunctionUFAgentSpec. - * - * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + * End-to-end check that pure String methods which are unmodeled stubs in StringValue (so the generic + * pure_<sig> UF fires), across String, int and boolean returns, are each modeled as a UF on a + * symbolic String receiver. The run preserves SAFE (no context loss, no precision loss) with the UFs + * in the branch constraints. The modeled String methods (substring, charAt, length, ...) are not + * whitelisted; they stay precise and are exercised elsewhere. */ class StringWhitelistAgentSpec extends Specification { - def "G4 (L2): unmodeled pure String methods are modeled as UFs (no context/precision loss)"() { + def "unmodeled pure String methods are modeled as UFs (no context or precision loss)"() { when: "the agent runs a program calling pure unmodeled String stubs on a symbolic receiver" TraceObservation obs = AgentRun.run("targets/StringWhitelistTarget.java", "StringWhitelistTarget") diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy index d950a70..b2ab5b1 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -6,11 +6,10 @@ import de.uzl.its.swat.symbolic.value.reference.lang.StringValue import org.sosy_lab.java_smt.api.Formula /** - * Value-typed boundary recovery at Level L1 (the workhorse fixture). An unmodeled value-returning - * call must not let its result alias the receiver's symbolic value - whether the call returns - * {@code this} (V-1) or a fresh object (V-2). The only difference between the two below is the - * result's object identity (the receiver's own object for a this-return vs a distinct object), - * which isolates the reference-keyed recovery as the defect. See docs/heap-redesign-tests.md. + * Boundary recovery for value-returning calls. An unmodeled value-returning call must not let its + * result alias the receiver's symbolic value, whether the call returns {@code this} or a fresh + * object. The two cases differ only in the result's object identity (the receiver's own object for + * a this-return versus a distinct object), isolating the reference-keyed recovery. */ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { @@ -21,7 +20,7 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { return solverContext.getFormulaManager().extractVariables(value.formula as Formula).keySet() } - def "V-1: toLowerCase this-return must not alias the receiver's symbolic formula"() { + def "a this-returning unmodeled call does not alias the receiver's symbolic formula"() { given: "a symbolic, already-lowercase String receiver registered on the heap" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") int receiverAddress = 0x1000 @@ -30,22 +29,22 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { receiver.MAKE_SYMBOLIC() def receiverVars = varsOf(receiver) - and: "precondition: the receiver really is symbolic" + and: "the receiver really is symbolic" assert receiverVars.size() == 1 when: "toLowerCase() is invoked (unmodeled) and the this-return is recovered" def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, concrete) // this-return: the result object IS the receiver's concrete object - then: "preconditions that hold today: context loss flagged, concrete correct" + then: "context loss is flagged and the concrete result is correct" result.contextLoss result.recovered.concrete == concrete - and: "the recovered value must NOT carry the receiver's symbolic formula (RED until G2)" + and: "the recovered value does not carry the receiver's symbolic formula" varsOf(result.recovered).disjoint(receiverVars) } - def "V-2: a new-object transform return does not alias the receiver (consistent with V-1)"() { + def "a fresh-object unmodeled return does not alias the receiver's symbolic formula"() { given: "a symbolic, concretely upper-case String receiver (toLowerCase returns a NEW object)" setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") int receiverAddress = 0x1000 diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy index 0ce436f..5c12247 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueSemanticsSpec.groovy @@ -7,10 +7,8 @@ import org.objectweb.asm.Type import org.sosy_lab.java_smt.api.Formula /** - * Value-typed semantics at Level L0 — String identity/value rules that hold by construction - * (no instrumentation). V-5 (copy ctor), V-6 (interned-literal reuse), V-9 (concrete grounding). - * These document currently-correct behavior and guard against regression. See - * docs/heap-redesign-tests.md. + * String identity and value rules that hold by construction, without instrumentation: the copy + * constructor, reuse of an interned literal, and concrete grounding of a symbolic value. */ class ValueSemanticsSpec extends BaseValueSpec { @@ -18,7 +16,7 @@ class ValueSemanticsSpec extends BaseValueSpec { return fmgr.extractVariables(v.formula as Formula).keySet() } - def "V-5: new String(s) keeps the source's symbolic formula (committed copy-ctor model)"() { + def "new String(s) keeps the source's symbolic formula"() { given: "a symbolic source string and a fresh target" StringValue s = new StringValue(context, "abc", ObjectValue.ADDRESS_UNKNOWN) s.MAKE_SYMBOLIC() @@ -33,7 +31,7 @@ class ValueSemanticsSpec extends BaseValueSpec { varsOf(t) == varsOf(s) } - def "V-6: reusing the same literal yields the same constant formula with no spurious vars"() { + def "reusing the same literal yields the same constant formula with no free variables"() { given: StringValue a = new StringValue(context, "lit", ObjectValue.ADDRESS_UNKNOWN) StringValue b = new StringValue(context, "lit", ObjectValue.ADDRESS_UNKNOWN) @@ -44,7 +42,7 @@ class ValueSemanticsSpec extends BaseValueSpec { isValid(a.IF_ACMPEQ(b)) } - def "V-9: making a value symbolic preserves its concrete grounding"() { + def "making a value symbolic preserves its concrete grounding"() { given: StringValue s = new StringValue(context, "seed", ObjectValue.ADDRESS_UNKNOWN) s.MAKE_SYMBOLIC() diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy index dbc2a31..3f07f33 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/WhitelistAuditAgentSpec.groovy @@ -5,18 +5,15 @@ import de.uzl.its.swat.testsupport.agent.TraceObservation import spock.lang.Specification /** - * G4 broadened-whitelist anchor (Level L2): the REAL agent runs a program exercising the java.lang/util - * purity-audit additions across all eight audited owners (Math, StrictMath, Character, Integer, Byte, - * Float, Double, Objects) and every return sort, including the String->float parse bridge. Every result - * must be modeled as a generic pure_ UF, so the run preserves SAFE: no context loss, no precision - * loss, and the UFs ride into the branch constraints. Companion to PureFunctionPrimitiveAgentSpec. - * - * Naming: {@code *AgentSpec} -> run by the opt-in {@code agentTest} task. See docs/test-architecture.md. + * End-to-end check that pure java.lang/util methods across eight owners (Math, StrictMath, Character, + * Integer, Byte, Float, Double, Objects) and every return sort, including the String-to-float parse + * bridge, are each modeled as a generic pure_<sig> UF. The run preserves SAFE: no context loss, + * no precision loss, and the UFs ride into the branch constraints. */ class WhitelistAuditAgentSpec extends Specification { - def "G4 (L2): audited whitelist additions are modeled as UFs across all classes (no context/precision loss)"() { - when: "the agent runs a program calling pure unmodeled JDK methods from the audit on symbolic inputs" + def "pure methods across all whitelisted classes are modeled as UFs (no context or precision loss)"() { + when: "the agent runs a program calling pure unmodeled JDK methods on symbolic inputs" TraceObservation obs = AgentRun.run("targets/WhitelistAuditAgentTarget.java", "WhitelistAuditAgentTarget") then: "the run completed and symbolic inputs were designated" @@ -27,7 +24,7 @@ class WhitelistAuditAgentSpec extends Specification { !obs.symbolicContextLoss !obs.symbolicPrecisionLoss - and: "the generic UFs entered the branch constraints, one representative per audited owner + sort" + and: "the generic UFs entered the branch constraints, one representative per owner and sort" obs.anyBranchReferences("pure_Math_pow_double_double") // Math, double obs.anyBranchReferences("pure_StrictMath_cbrt_double") // StrictMath, double obs.anyBranchReferences("pure_Math_addExact_int_int") // Math, int diff --git a/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java index f5b847e..2345dd3 100644 --- a/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java +++ b/symbolic-executor/src/test/resources/targets/BoxedReturnTarget.java @@ -1,26 +1,23 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for G3-A2 (boxed output-boundary de-interning), driving the de-intern instrumentation - * for the wrapper types under the real JVM verifier (the run completing at exit 0 with a parsed TraceDTO - * is the bytecode-validity oracle; a malformed wrap/rewrite would VerifyError). + * Exercises the de-intern instrumentation for the boxed wrapper types under the JVM verifier. A run + * that completes at exit 0 with a parsed TraceDTO confirms the wrap/rewrite bytecode is valid; a + * malformed rewrite would raise a VerifyError. * *

    - *
  • deInternReturn (unbox+rebox) branch - declared-wrapper returns from un-instrumented - * {@code valueOf(String)} factories: Integer (category-1) and Long (category-2 wide-local).
  • - *
  • valueOf rewrite branch - autoboxing emits {@code .valueOf(primitive)}, which the - * adapter rewrites to {@code new (primitive)} for ALL SIX wrappers (Integer/Long/Short/ - * Byte/Character/Boolean). Per-wrapper descriptors are DERIVED from the {@code Boxed} enum, so - * verifier coverage of every type - especially the short/byte/char descriptors paired with - * int-category opcodes - is the point of constructing all six here.
  • + *
  • Wrapper returns from factories - declared-wrapper returns from un-instrumented + * {@code valueOf(String)} factories: Integer and Long.
  • + *
  • valueOf rewrite - autoboxing emits {@code .valueOf(primitive)}, which the + * adapter rewrites to {@code new (primitive)} for all six wrappers (Integer, Long, + * Short, Byte, Character, Boolean). Constructing all six covers every per-wrapper descriptor, + * especially the short/byte/char descriptors paired with int-category opcodes.
  • *
* - *

The autoboxed wrappers are kept CONCRETE and consumed via string concatenation ({@code toString}), - * and the {@code valueOf(String)} factories are limited to Integer/Long. This is deliberate: feeding a - * symbolic value through Short/Byte/Character/Boolean parsing or unboxing trips unrelated gaps in the - * symbolic modeling of those wrappers, which are out of scope for this de-intern bytecode-validity test. - * Object-identity (the de-intern effect) is pinned at L1 by OutputDeInternSpec, the boxed-cache - * {@code ==} semantics at L0 by ProvenanceRefEqualsSpec. See docs/heap-redesign-g3-design.md (Level L2). + *

The autoboxed wrappers are kept concrete and consumed via string concatenation, and the + * {@code valueOf(String)} factories are limited to Integer and Long. Feeding a symbolic value through + * Short/Byte/Character/Boolean parsing or unboxing hits gaps in the symbolic modeling of those + * wrappers that are unrelated to the de-intern bytecode validity this target checks. */ public class BoxedReturnTarget { @@ -29,12 +26,11 @@ public static void main(String[] args) { } public static String test(@Symbolic int x) { - // deInternReturn branch: declared-wrapper returns from un-instrumented valueOf(String) factories. - Integer i = Integer.valueOf(Integer.toString(x)); // category-1 unbox+rebox - Long l = Long.valueOf(Long.toString((long) x)); // category-2 wide unbox+rebox + // Declared-wrapper returns from un-instrumented valueOf(String) factories. + Integer i = Integer.valueOf(Integer.toString(x)); + Long l = Long.valueOf(Long.toString((long) x)); - // valueOf rewrite branch: autoboxing -> .valueOf(primitive) -> new (primitive), - // for all six wrappers. Concrete; consumed via string concatenation below (no symbolic unbox). + // Autoboxed wrappers, kept concrete and consumed via string concatenation below. Integer ai = 11; Long al = 12L; Short ash = (short) 13; diff --git a/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java b/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java index 43d4198..0886ae0 100644 --- a/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java +++ b/symbolic-executor/src/test/resources/targets/PurePrimReturnTarget.java @@ -1,12 +1,10 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for G4 primitive-return UF support: an instrumented method calls un-instrumented, - * pure, UNMODELED java.lang methods whose return type is a PRIMITIVE, on symbolic inputs, and branches - * on each result. Under the real agent each result is modeled as a generic {@code pure_} UF over - * its inputs (not concretized), so the branches stay symbolic with NO context loss and NO precision - * loss. Covers int, long, double, float, char and boolean returns (short/byte have no pure unmodeled - * JDK method and are pinned at L0 by PureUFPrimitiveRecoverySpec). See docs/heap-redesign-g4-* . + * Calls pure, unmodeled java.lang methods with primitive return types on symbolic inputs and branches + * on each result. Each result is modeled as a generic {@code pure_} uninterpreted function over + * its inputs rather than concretized, so the branches stay symbolic. Covers int, long, double, float, + * char and boolean returns (short and byte have no suitable pure unmodeled JDK method). */ public class PurePrimReturnTarget { diff --git a/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java b/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java index 96e047e..51392f4 100644 --- a/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java +++ b/symbolic-executor/src/test/resources/targets/StringRefEqTarget.java @@ -1,11 +1,9 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 acceptance for G3-B: a symbolic String compared to a literal with {@code ==} (rewritten to - * UtilInstrumented.refEquals, now root-based). The de-interned input and the literal have different - * roots, so {@code ==} is reference-false - and, crucially, modeling it through Provenance.root must - * fire NEITHER soundness flag (no symbolic-context-loss from the stepped refEquals body, no - * reference-semantic-change), unlike the old value-equality refEquals which fired both. See G3-B. + * Compares a symbolic String to a string literal with reference equality ({@code ==}). The symbolic + * input is de-interned, so it and the interned literal are distinct references and {@code ==} is + * false. Reference equality is modeled without changing reference semantics. */ public class StringRefEqTarget { @@ -14,7 +12,7 @@ public static void main(String[] args) { } public static String test(@Symbolic String s) { - if (s == "MATCH") { // de-interned ==: root(s) vs interned "MATCH" -> distinct -> false, no flags + if (s == "MATCH") { // distinct references -> false return "eq"; } return "ne"; diff --git a/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java b/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java index 7a775e0..f00fbbb 100644 --- a/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java +++ b/symbolic-executor/src/test/resources/targets/StringWhitelistTarget.java @@ -1,11 +1,10 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 regression target for the String purity-audit additions (the StringValue stubs that the - * audit confirmed are unmodeled, so the generic pure_ UF fires). Drives a representative few - - * String, int and boolean returns - on a symbolic String receiver into branches. Single String - * parameter only (a category-2 double followed by a reference param trips an unrelated frame-analysis - * fragility in the instrumenter). See docs/heap-redesign-g4-whitelist-survey.md (Level L2). + * Drives unmodeled pure String methods on a symbolic String receiver into branches, covering String, + * int and boolean returns. Each result is modeled as a generic pure_ uninterpreted function. + * Uses a single String parameter: a category-2 parameter (double or long) followed by a reference + * parameter trips a frame-analysis limitation in the instrumenter. */ public class StringWhitelistTarget { diff --git a/symbolic-executor/src/test/resources/targets/SubstringTarget.java b/symbolic-executor/src/test/resources/targets/SubstringTarget.java index 2c46522..f050878 100644 --- a/symbolic-executor/src/test/resources/targets/SubstringTarget.java +++ b/symbolic-executor/src/test/resources/targets/SubstringTarget.java @@ -1,11 +1,10 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for the G4 arg-taking shape: a symbolic String is passed to an unmodeled but - * WHITELISTED pure method that TAKES AN ARGUMENT ({@code String.substring(int)}), whose result drives - * a branch. Exercises the mixed-sort generic UF ({@code pure_String_substring_int(String, int)}) end - * to end: the branch should reference the input through the UF, and NEITHER soundness flag should - * fire. Complements TrimTarget (the no-arg shape). See docs/test-architecture.md (Level L2). + * Passes a symbolic String to an unmodeled but whitelisted pure method that takes an argument + * ({@code String.substring(int)}) and branches on the result. This exercises the mixed-sort generic + * uninterpreted function {@code pure_String_substring_int(String, int)}, so the branch references the + * input through the UF. Complements {@code TrimTarget}, which uses a no-argument method. */ public class SubstringTarget { @@ -14,8 +13,8 @@ public static void main(String[] args) { } public static String test(@Symbolic String s) { - String r = s.substring(1); // whitelisted pure + unmodeled, arg-taking -> pure_String_substring_int(s, 1) - if (r.equals("bcd")) { // the UF result reaches a branch + String r = s.substring(1); // modeled as pure_String_substring_int(s, 1) + if (r.equals("bcd")) { return "eq"; } return "ne"; diff --git a/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java b/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java index e0c7afc..6a0ed1e 100644 --- a/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java +++ b/symbolic-executor/src/test/resources/targets/ToLowerCaseTarget.java @@ -1,13 +1,9 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for V-1 / R-1: a symbolic, concretely already-lowercase String is passed to an - * unmodeled {@code toLowerCase()} that returns {@code this}. Run under the real SWAT agent - * (ANNOTATION mode, the default), this exercises the identity-recovery / aliasing path. - * - * {@code @Symbolic} is on the parameter (the annotation transformer handles parameters cleanly; - * a local needs the {@code -g} debug table). Compiled against the agent jar, so it has no external - * dependency. See docs/test-architecture.md (Level L2). + * Passes a symbolic, concretely already-lowercase String to an unmodeled {@code toLowerCase()} that + * returns {@code this}, then branches on the result. This exercises the identity-recovery / aliasing + * path. The method is not on the purity whitelist, unlike {@code TrimTarget}. */ public class ToLowerCaseTarget { @@ -17,7 +13,7 @@ public static void main(String[] args) { public static String test(@Symbolic String s) { String r = s.toLowerCase(); - if (r.equals("abc")) { // branch on the (currently aliased) result + if (r.equals("abc")) { return "eq"; } return "ne"; diff --git a/symbolic-executor/src/test/resources/targets/TrimTarget.java b/symbolic-executor/src/test/resources/targets/TrimTarget.java index 83f400e..8566117 100644 --- a/symbolic-executor/src/test/resources/targets/TrimTarget.java +++ b/symbolic-executor/src/test/resources/targets/TrimTarget.java @@ -1,14 +1,10 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 target for the G4 end-to-end anchor: a symbolic String is passed to an unmodeled but - * WHITELISTED pure method ({@code String.trim()}), whose result then drives a branch. Run under the - * real SWAT agent, this exercises the generic-UF path ({@code pure_String_trim}) end to end: the - * branch constraint should reference the input through the UF, and NEITHER soundness flag - * (context-loss, precision-loss) should fire - so SAFE would be preserved through the call. - * - * Mirrors {@code ToLowerCaseTarget} (the non-whitelisted contrast, which still flags context loss). - * {@code @Symbolic} is on the parameter. See docs/test-architecture.md (Level L2). + * Passes a symbolic String to an unmodeled but whitelisted pure method ({@code String.trim()}) and + * branches on the result. This exercises the generic uninterpreted-function path: the result is + * modeled as {@code pure_String_trim(s)}, so the branch constraint references the input through the + * UF. Contrasts with {@code ToLowerCaseTarget}, whose method is not whitelisted. */ public class TrimTarget { @@ -17,8 +13,8 @@ public static void main(String[] args) { } public static String test(@Symbolic String s) { - String r = s.trim(); // whitelisted pure + unmodeled -> modeled as pure_String_trim(s) - if (r.equals("abc")) { // the UF result reaches a branch + String r = s.trim(); // modeled as pure_String_trim(s) + if (r.equals("abc")) { return "eq"; } return "ne"; diff --git a/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java b/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java index 51a8853..5cc7143 100644 --- a/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java +++ b/symbolic-executor/src/test/resources/targets/WhitelistAuditAgentTarget.java @@ -1,12 +1,11 @@ import de.uzl.its.swat.annotations.Symbolic; /** - * Level-2 regression target for the broadened G4 purity whitelist (the java.lang/util audit additions). - * Each method drives a few pure, unmodeled, value-typed JDK methods - across all eight audited owners - * and every return sort, including the String->float parse bridge - on a symbolic input into a branch. - * Under the real agent every result must be modeled as a generic pure_ UF, so the run preserves - * SAFE (no context loss, no precision loss). Methods are kept small/separate to avoid an unrelated - * frame-analysis fragility in the instrumenter on large mixed-type method bodies. + * Drives pure, unmodeled, value-typed JDK methods from the purity whitelist on symbolic inputs into + * branches. The methods span several owners and every return sort, including the String-to-float parse + * bridge. Each result is modeled as a generic pure_ uninterpreted function. The methods are kept + * small and separate because large mixed-type method bodies trip a frame-analysis limitation in the + * instrumenter. */ public class WhitelistAuditAgentTarget { From cddfa28ea768ee95b47d7d787b007d348c2b88b1 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 10:55:09 +0000 Subject: [PATCH 25/33] docs(heap): remove superseded design docs; de-jargon reference docs Delete the point-in-time per-phase design docs now consolidated into docs/heap-tracking.md. Rewrite test-architecture.md and the swat-test/jdk-source skills to drop case IDs, phase labels, and @See conventions, and repoint doc links at heap-tracking.md. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/jdk-source/SKILL.md | 5 +- .claude/skills/swat-test/SKILL.md | 7 +- docs/heap-redesign-g1-design.md | 80 --- docs/heap-redesign-g2-design.md | 72 --- docs/heap-redesign-g3-design.md | 536 ------------------ docs/heap-redesign-g4-design.md | 164 ------ ...heap-redesign-g4-step2-explorer-handoff.md | 103 ---- docs/heap-redesign-g4-whitelist-survey.md | 92 --- docs/heap-redesign-goob-design.md | 87 --- docs/heap-redesign-tests.md | 79 --- docs/test-architecture.md | 137 ++--- 11 files changed, 60 insertions(+), 1302 deletions(-) delete mode 100644 docs/heap-redesign-g1-design.md delete mode 100644 docs/heap-redesign-g2-design.md delete mode 100644 docs/heap-redesign-g3-design.md delete mode 100644 docs/heap-redesign-g4-design.md delete mode 100644 docs/heap-redesign-g4-step2-explorer-handoff.md delete mode 100644 docs/heap-redesign-g4-whitelist-survey.md delete mode 100644 docs/heap-redesign-goob-design.md delete mode 100644 docs/heap-redesign-tests.md diff --git a/.claude/skills/jdk-source/SKILL.md b/.claude/skills/jdk-source/SKILL.md index d32b00e..8fff89a 100644 --- a/.claude/skills/jdk-source/SKILL.md +++ b/.claude/skills/jdk-source/SKILL.md @@ -1,12 +1,13 @@ --- name: jdk-source -description: Fetch the exact source of a JDK method/class from the active JDK's lib/src.zip (version-exact, no network), and audit a java.lang method for SWAT purity-whitelist eligibility. Use when deciding whether a JDK method may be added to PureMethods.WHITELIST (G4 pure-function UF modeling), or when you need to read what a JDK method actually does. +description: Fetch the exact source of a JDK method/class from the active JDK's lib/src.zip (version-exact, no network), and audit a java.lang method for SWAT purity-whitelist eligibility. Use when deciding whether a JDK method may be added to PureMethods.WHITELIST (pure-function UF modeling), or when you need to read what a JDK method actually does. --- # JDK source + purity-whitelist audit Two jobs: (1) cheaply read JDK source, (2) decide if a method may join `PureMethods.WHITELIST` -(`symbolic-executor/.../symbolic/UFs/PureMethods.java`). Background: `docs/heap-redesign-g4-whitelist-survey.md`. +(`symbolic-executor/.../symbolic/UFs/PureMethods.java`). Background: the pure-function whitelist +section of `docs/heap-tracking.md`. ## Fetching source — `scripts/jmethod.py` diff --git a/.claude/skills/swat-test/SKILL.md b/.claude/skills/swat-test/SKILL.md index d39676d..da534b4 100644 --- a/.claude/skills/swat-test/SKILL.md +++ b/.claude/skills/swat-test/SKILL.md @@ -33,13 +33,12 @@ TDD), annotate the feature `@PendingFeature(reason = "…")`: it runs and assert behavior, is reported **pending (skipped), not a failure**, and **fails the build the moment it starts passing** (your "fix landed" signal). Put currently-true preconditions first, or as a *separate non-pending* feature, so an infra break is a real failure, not masked as pending. -Add `@See("docs/heap-redesign-tests.md")` to link the behavioral case. ## How to add a test **L0** — extend `de.uzl.its.swat.symbolic.heap.BaseValueSpec` (gives `context`, `fmgr`, and the `isValid(BooleanFormula)` / `isUnsatisfiable(BooleanFormula)` boolean oracles). Construct values -with `context`. Example: `ObjectIdentitySpec` (O-4). +with `context`. Example: `ObjectIdentitySpec`. **L1** — extend `de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec`. Call `setupTestContext(className, method)` first. For an unmodeled-call recovery, use the fixture: @@ -48,7 +47,7 @@ def result = executeBoundaryRecovery(receiver, owner, name, desc, concreteResult // result.recovered (Value), result.contextLoss // resultAddress == receiver.address -> this-return; fresh address -> new-object return ``` -Example: `HeapRecoveryV1Spec` (V-1). +Example: `ValueRecoverySpec`. **L2** — add a tiny target to `src/test/resources/targets/.java` using `@Symbolic` on a **method parameter** (a local needs `-g` and may crash the annotation transformer). Name the spec @@ -59,7 +58,7 @@ TraceObservation obs = AgentRun.run("targets/.java", "") // obs.inputNames ; obs.anyBranchReferences(inputVar) ``` `AgentRun` compiles against the agent jar, forks a JVM with `solver.mode=PRINT`, and parses the -TraceDTO. Example: `HeapRecoveryV1AgentSpec`. +TraceDTO. Example: `HeapRecoveryAgentSpec`. ## Run diff --git a/docs/heap-redesign-g1-design.md b/docs/heap-redesign-g1-design.md deleted file mode 100644 index 7b87747..0000000 --- a/docs/heap-redesign-g1-design.md +++ /dev/null @@ -1,80 +0,0 @@ -# G1 — Canonical Registry + Faithful Key: Problem & Proposed Solution - -Phase 1 of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (converged after 1 independent review round — see "Review round 1 — resolutions" below). Acceptance green at all levels: O-4 + O-5 green (L0), V-2/F-1/F-2 stay green, V-1 stays pending (correct — that's G2), L2 anchor green through the real agent. No half-migrated API (int heap key fully replaced by the concrete-reference key; `int address` retained only for NULL/de-intern/delegation/debug). Implementation summary at the end of this doc. - -## Problem (recap, grounded) - -The shadow heap is a *recovery cache* — `JVMHeap` = `HashMap` keyed by `System.identityHashCode` (`shadow/JVMHeap.java`), consulted only when a reference re-enters from unmodeled code as a `PlaceHolder` (`SymbolicInstructionVisitor.visitGETVALUE_Object` `getFromHeap(inst.address)`; also `visitLDC_Object`, delegation). Frames hold *direct* `ObjectValue` references, so in-flow aliasing works by shared pointers; the heap is the fallback to re-correlate a bare reference with its shadow. - -Four confirmed bugs (audit): - -- **(a) duplicate shadow per concrete object** — `visitNEW`/`visitLDC_String` create+push without registering; canonical registration is incidental (only if a `GETVALUE_Object` later fires while the object is stack-top). Distinct wrappers for one object → divergent field maps. -- **(c) collision + no eviction** — 31-bit `identityHashCode` key in a never-evicted `HashMap`: distinct live objects collide (second `put` overwrites); dead-object hashes get reused. -- **(d) unregistered objects** — as (a): `NEW`/arrays/`LDC_String` register only incidentally. -- **`==` correctness** — `ObjectValue.IF_ACMPEQ` is `bfm.makeBoolean(this == o2)` (`ObjectValue.java:153`), i.e. *wrapper* Java-identity. This is correct **iff** there is exactly one wrapper per concrete object — which (a)/(d) violate. So duplicate wrappers make real `==` semantics wrong. - -Root cause: the correlation key (`identityHashCode`) is not faithful (collisions; not unique), and there is no canonicalization (find-or-create) guaranteeing one shadow per concrete object. - -## Proposed solution - -**Make the heap a canonical, faithful-key registry; keep frames holding direct references (cached pointers); canonicalize at the boundary sites only.** No full per-access indirection (the GETVALUE path is hot; full indirection would add a lookup per object touch — non-goal per the plan). - -1. **Faithful key = the concrete object reference.** Replace the int-`identityHashCode`-keyed map with an identity map keyed by the actual concrete `Object` (reference equality, not hash). Use a weak-keyed identity map so dead objects evict — Guava `new MapMaker().weakKeys().makeMap()` (Guava already a dependency; `weakKeys()` switches the map to `==` key comparison and weak references). This fixes (c): distinct objects never collide (reference identity); dead entries are GC'd (eviction → fixes the O-6 reuse hazard). - -2. **Canonicalize (find-or-create) at every introduce/recover site.** One shadow per concrete object: - - `visitGETVALUE_Object` already carries the concrete object as `inst.val` (`AbstractInstructionProcessor.GETVALUE_Object` passes `v`). Recovery becomes `registry.get(inst.val)`; on miss, create the shadow and `registry.put(inst.val, shadow)`. The existing `ADDRESS_UNKNOWN` adopt-peek branch becomes the canonical register-on-first-sight for `NEW`/array results (they already flow through a `GETVALUE_Object`). - - **`visitLDC_Object` currently drops the object** — `AbstractInstructionProcessor.LDC(long,Object)` builds `new LDC_Object(iid, identityHashCode(c))` (`:35`). Thread the object through (`LDC_Object(iid, identityHashCode, c)`) so it can canonicalize by reference like the others. (`LDC_String` already carries the string.) - - delegation path (`visitGETVALUE_Object` `:1374`): key by the delegated object reference too. - -3. **`register-on-create`.** `NEW`/`*NEWARRAY`/`LDC_String`/`LDC_Object` results are canonicalized at their immediately-following `GETVALUE_Object` (which has the concrete ref). No object is left unregistered before it can re-enter. (Fixes (a)/(d).) - -4. **Keep `IF_ACMPEQ = this == o2` unchanged.** Once canonicalization guarantees one wrapper per concrete object, wrapper-identity *is* concrete-identity, so `this == o2` is correct **and collision-free** (it never consults the 31-bit hash). We deliberately do **not** switch `IF_ACMPEQ` to compare `address`, because `address` stays `identityHashCode` and address-comparison would re-introduce collisions. - -5. **`address` keeps its current meaning** (`identityHashCode`, with `0`=null, `-1`=unknown) — used only for the NULL check (`IFNULL`/`IFNONNULL`), de-interned-string compare (`ObjectsInvocation`), and debug labels. It is **not** the equality key. (Optional later cleanup: give each canonical shadow a unique id and retire `address`-as-hash; out of scope for G1.) - -6. **Inspection seam unchanged in contract.** `ShadowContext.heapSize()/heapLookup/heapEntries` and `JVMHeap.size()/values()` keep their semantics (one entry per canonical identity; `heapSize()` = distinct identities). The registry implements the same view, so the O-tests' assertions are stable. - -This fixes (a), (c), (d), and the `==` bug. It does **not** fix V-1 (value-type this-return aliasing) — under a faithful key, `registry.get(this-returned-object)` still returns the receiver's shadow *because it is genuinely the same object*; G2's value-type policy is what stops identity-recovery for immutables. G1 before G2 is correct ordering. - -## Acceptance tests — and a contradiction to resolve - -The intended acceptance reds are **O-4** ("two wrappers for one identity compare equal") and **O-5-heap** ("colliding-hash objects don't merge → `heapSize()==2`"). As currently written (L0, `ObjectIdentitySpec`), they construct `ObjectValue`s **directly** at a chosen `address`, with no concrete object behind them. That creates a real contradiction: - -- **O-4-same** (red): `a=objectAt(0x2000)`, `b=objectAt(0x2000)` → asserts `IF_ACMPEQ` **valid** (equal). -- **O-5-distinct** (green guard): `a=objectAt(0x5000)`, `b=objectAt(0x5000)` → asserts `IF_ACMPEQ` **unsatisfiable** (unequal). - -These are the *same construction* (two distinct wrappers sharing an address) with **opposite** expectations. No `IF_ACMPEQ` — wrapper-based or address-based — can satisfy both: wrapper-identity makes both unequal (O-4 fails, current state), address-equality makes both equal (O-5 breaks). The L0 manual construction cannot encode the real distinction ("same concrete object" vs "distinct objects, colliding hash"), because that distinction lives in the concrete reference, which L0 doesn't have. - -**Proposed resolution:** the canonicalization behavior must be exercised *through the registry by concrete reference*, not by manual wrapper construction: - -- **O-4** → obtain the two references by canonicalizing the **same** concrete object twice (`registry.findOrCreate(obj)` → returns the *same* wrapper) → `IF_ACMPEQ` true. (Keep `this==o2`.) -- **O-5** → canonicalize **two distinct** concrete objects (distinct refs, even with a colliding `identityHashCode`) → two distinct wrappers, `heapSize()==2`, and `IF_ACMPEQ` false. - -This makes O-4 and O-5 consistent and tests the actual fix. The truest home for these is L1/L2 (real re-entry of real objects); at minimum the L0 specs must be rewritten to drive the registry's find-or-create with concrete references rather than fabricated wrappers at fixed addresses. (The handoff already anticipated rewriting O-5's `when:` block; this extends that to O-4 and explains why.) - -## Risks / open questions for review - -- **Weak identity map semantics & perf.** Guava `weakKeys()` map at introduce/recover (not per-access). Is the added lookup acceptable on the hot GETVALUE path? Any concern with weak-ref GC timing causing a live-but-evicted miss (then a fresh shadow is created — sound but loses field state)? -- **Threading the concrete ref to `LDC_Object`** (and delegation): correct, and are there other heap-consult sites that only have the int? -- **`address` left as `identityHashCode`**: any remaining code that *relies* on `address` for identity/equality (beyond NULL/de-intern/debug) that would still be collision-exposed? -- **NULL handling** under reference keying: `inst.val == null` ⇒ NULL value (don't register). Confirmed sufficient? -- **Is keeping `this==o2` (vs a unique-id) the right call for G1**, deferring the unique-id cleanup — or does leaving `address`=hash leave a latent trap? -- **Test-rework correctness:** is the O-4/O-5 resolution above sound, and does it actually exercise canonicalization rather than tautology? - -## Review round 1 — resolutions - -Accepted from review: - -- **[B1] Declare Guava explicitly.** Guava is currently only a *transitive* leak (build.gradle:35-47 declares no guava; `ObjectValue.java:3` imports `ImmutableSet` only because java-smt/z3 jars drag it in). Add an explicit `implementation` on guava (catalog entry) as a prerequisite before using `MapMaker`; this also de-risks the existing `ImmutableSet`. -- **[B2] Don't tautologize the L0 tests; move behavioral acceptance to L1.** Resolution refined: **keep `IF_ACMPEQ = this == o2`** unchanged, so the existing green L0 guards stay green and unchanged — "O-4 distinct objects" (different addresses) and "O-5 distinct objects, colliding hash" (two distinct wrappers → `this==o2` false → unequal). The two **red** behavioral cases move to **L1** (real `GETVALUE_Object` recovery by reference): O-4 = re-enter the *same* concrete object twice → registry returns the *same* wrapper → `this==o2` true; O-5 = re-enter two *distinct* concrete objects (even colliding hash) → distinct wrappers → `heapSize()==2` and unequal. At L0 keep only a *non-circular structural* assertion (e.g. distinct keys ⇒ distinct entries that does not pre-assume find-or-create); do not rewrite the green guards into registry-tautologies (would lose their infra-break protection). The contradiction the doc named is real (O-4-same `0x2000/0x2000` expect-equal vs O-5-distinct `0x5000/0x5000` expect-unequal are the same construction with opposite expectations), and "keep `this==o2` + move the reds to L1" is what dissolves it — manual same-address wrappers can't encode same-object vs distinct-colliding. -- **[C1] Weaken the `==`-correctness claim to the accurate version.** Not "canonicalization guarantees one wrapper everywhere." Accurate: for plain reference objects `this==o2` is correct because **either** both operands are in-flow shared pointers (`visitIF_ACMPEQ` pops them straight off the stack, no registry), **or** a recovered operand was canonicalized at the boundary. Strings don't use this path at all (`StringValue.IF_ACMPEQ:81` uses formula equality). **Residual gap (documented, acceptable for G1):** a `NEW`/array result that escapes to unmodeled code *before its first `GETVALUE_Object`* re-enters unregistered → a second wrapper. Registration happens at the first `GETVALUE_Object` (where the concrete ref exists; the ref is not available at `visitNEW`), so "register-on-create" = "register at first GETVALUE." This narrows but does not fully close bug (a); note it. -- **[C2] `LDC_Object` threading is feasible and a bonus fix.** The instrumentation already pushes the constant for the dispatch (`InstructionMethodAdapter.visitLdcInsn`), so `LDC(long,Object)` receives the real object — threading it is pure plumbing. Bonus: today an LDC'd object becomes a bare addressed `ObjectValue` with `null` concrete (`visitLDC_Object` → `createObjectValue(null, inst.c)`); threading the ref fixes that too. Caveat: guard the `Class`/`MethodHandle`/`MethodType` constant case (reflection branch in `ValueFactory`). -- **[C4] Finish the delegation path** (`visitGETVALUE_Object` ~:1374): key by the delegated object reference too. Note (pre-existing, not G1's to fix): delegation detection uses mutable flags on the shared static processor singleton. -- **[C5] `address`-as-`identityHashCode` collision is NOT debug-only.** `ObjectsInvocation.invokeEquals` decides the `referenceSemanticChange` flag via `getAddress() != getAddress()` — a real soundness signal keyed on the 31-bit hash, so collisions can under-flag it. G1 leaves this (de-interning is G3); record it as a known residual to retire when `address` becomes a unique id. -- **Nits:** `equals()` NPEs when `fields==null` (latent, no callers — add a guard if touched); `getConcrete()` returns `address` for `ObjectValue` (no identity-comparison callers — inert); `visitIF_ACMPEQ` records the branch twice (pre-existing — flag to test authors so oracle counts aren't confused). - -Pushed back (reviewer error to confirm): - -- **[C3] Weak-key eviction does NOT cause live-object precision loss.** A `weakKeys()` entry is cleared only when the key (the concrete object) is **unreachable from GC roots** — i.e. dead. While the program holds the object anywhere (reachable), the weak key is retained regardless of whether the shadow side can "see" it; GC reachability is a property of the program's strong refs, not the shadow's visibility. So there is **no** "live-but-evicted" hazard: eviction happens exactly when the object can never re-enter again (which is precisely O-6's desired behavior). Therefore weak `IdentityHashMap`/`MapMaker().weakKeys()` is **sound and precise for live objects AND fixes O-6** — no collision/precision trade, contrary to the round-1 note. (Injected-id/JVMTI remain future options, but are not needed to avoid an eviction problem that doesn't exist.) Reviewer to confirm this reasoning. - -Confirmed by review (no change): G1 correctly does NOT fix V-1 (a `this`-returned genuinely-same object recovering the receiver's shadow is correct *identity* behavior; G2's value-type policy is what stops it). Reference-keying does not disturb V-1/V-2/F-1/F-2 or the de-interned-string formula-equality path. One heap per thread (per-visitor `ShadowContext`), so the registry needs no extra locking. diff --git a/docs/heap-redesign-g2-design.md b/docs/heap-redesign-g2-design.md deleted file mode 100644 index 352e0f7..0000000 --- a/docs/heap-redesign-g2-design.md +++ /dev/null @@ -1,72 +0,0 @@ -# G2 — Value-type boundary recovery (collapse v1): Problem & Proposed Solution - -Phase 2 of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (converged after 1 design-review round + 3 independent post-impl reviews for style/refactoring/correctness). V-1 green at L1 **and** L2 (real agent); V-2/F-1/F-2/V-5/V-6/V-9/O-4/O-5 stay green; 0 pending across heap+processor+agentTest. The design-review resolutions are at the bottom; B2 was settled by reading the explorer (VIOLATION is replay-witnessed, so no downgrade flag is needed). Builds on G1 (`67505c4`). - -## Problem (recap, grounded) - -After G1 the heap is keyed by the concrete object reference. That makes G1's object cases correct — but it does **not** fix the value-type aliasing bug (V-1), and *cannot*, because the aliasing is semantically faithful at the identity level: - -`String.toLowerCase()` on an already-lowercase receiver returns `this`. So the unmodeled call's result *is the receiver object*. Flow today (post-G1): - -1. `visitINVOKEVIRTUAL` (toLowerCase, unmodeled) → `InvocationHandler.invoke(...)` returns `PlaceHolder.instance` and records context loss (symbolic receiver) — `SymbolicInstructionVisitor.java:2319+`, `InvocationHandler.java:76-122`. -2. `visitINVOKEMETHOD_END` pushes that placeholder onto the caller stack (`:3406-3413`). -3. `visitGETVALUE_Object` sees the placeholder; `inst.val` is the concrete result = **the receiver object** (this-return); `getFromHeap(inst.val)` returns the **receiver's symbolic `StringValue`** → the result aliases the receiver's formula (`:1265-1294`). - -So a value produced by an unmodeled method is re-bound to its receiver's symbolic value. The receiver is a real symbolic input; the result is a different logical value we have no model for — but identity-recovery makes them one. (`invokeToLowerCase` is a `PlaceHolder` stub, `StringValue.java:1125`.) - -The right behavior (plan §G2, "collapse v1"): at a **mirrored invoke boundary of an unmodeled, value-returning method**, do **not** identity-recover for value types; produce a concretized (non-symbolic) result and keep the already-recorded context-loss flag. - -## Proposed solution (collapse v1) - -**Tag the unmodeled value-typed return, and concretize it at recovery instead of identity-recovering.** - -1. **New placeholder origin** `PlaceHolder.ValueOrigin.UNMODELED_RETURN` (alongside UNSPECIFIED/DATABASE/GETFIELD/GETSTATIC). -2. **Tag at the single chokepoint — `InvocationHandler.invoke`.** After the existing missing-invocation / context-loss recording runs (which must still observe the plain `PlaceHolder.instance`), if the result is an unmodeled placeholder **and** the method's return type is a value type (`Type.getReturnType(desc)` ∈ `Util.deInternedClasses` = String + 6 boxed wrappers), return `new PlaceHolder(ValueOrigin.UNMODELED_RETURN, …)` instead of `PlaceHolder.instance`. All 5 invoke sites route through here, so this is the only tag site. -3. **Concretize at recovery — `visitGETVALUE_Object`.** When the placeholder's origin is `UNMODELED_RETURN`: skip `getFromHeap` entirely; build a concretized value from `inst.val` via `ValueFactory.createObjectValue(inst.val, inst.address)` (for a String this yields a `StringValue` whose formula is the **constant** `makeString(bytes)` — non-symbolic, so it carries no variables) and push it. Context loss is already flagged (step 2's path is unchanged). -4. **Do NOT overwrite the receiver's heap entry.** Leave `heap[receiverObject] = receiver` intact. This preserves **V-3** (a genuinely symbolic string stored and re-fetched *unchanged* still recovers its formula). The cost: see residual below. - -Net: the result no longer carries the receiver's formula (V-1 fixed); it's concrete + context-loss-flagged; V-2/V-3/F-1/F-2 are untouched. - -## Why this shape - -- **Tag, not type-sniff-at-recovery.** `UNSPECIFIED` placeholders arise from several sources (generic pushes, etc.); only the unmodeled-invoke return should change behavior. A dedicated origin is surgical and mirrors how GETFIELD/GETSTATIC already tag their placeholders (`:1191-1193`, `:1222-1224`). -- **Tag after the context-loss logic** so `InvocationHandler`'s `retValue.equals(PlaceHolder.instance)` check (`:110`) still fires and records the flag; only the *returned* value is swapped. -- **Concretize = createObjectValue with a constant formula** — the existing factory path already produces a non-symbolic value; no new "concretize" primitive needed. - -## Scope / non-goals - -- **Value types only** (`deInternedClasses`). Unmodeled returns of other reference types keep G1 behavior (identity-recover or create fresh). -- **No UF** (G4b). A pure whitelisted method would later return `UF_m(inputs)` instead of concrete; deferred. -- **No φ-set / branching** (v2). Recovery yields a single concrete value, not a candidate set. -- **No output de-interning** (G3). - -## Known residual (documented, deferred to G3) - -Because we don't overwrite the heap, the *result* of a this-return is concretized on the stack but the shared object still maps to the receiver. If that result then round-trips through unmodeled memory and re-enters via `getFromHeap`, it re-aliases to the receiver — the V-1-via-round-trip variant. This is the fundamental overloaded-identity problem (result and receiver are one object); only **splitting the identity (G3 output de-interning)** closes it. Overwriting the heap here would close the result's round-trip but break V-3's (the receiver's round-trip), so v1 keeps V-3 and defers the result-round-trip to G3. - -## Acceptance tests - -Flip green: **V-1 (L1)** `ValueRecoverySpec` (recovered result's vars disjoint from receiver's; concrete correct; context loss flagged) and **V-1 (L2)** `HeapRecoveryV1AgentSpec` (branch on the real `toLowerCase` result does not reference the symbolic input). Remove their `@PendingFeature`. - -Stay green: V-2 (new-object — already non-aliasing), F-1/F-2 (flag policy — `InvocationHandler` context-loss path unchanged), V-5/V-6/V-9, O-4/O-5, processor specs, L2 anchor. - -## Risks / open questions for review - -- **Tag placement vs the context-loss check.** Is returning a tagged placeholder *after* the recording correct, and does anything else compare the return against `PlaceHolder.instance` by identity (`==`) or `equals` and break when it's a tagged instance? (e.g. callers of `InvocationHandler.invoke`, `setReturnValue`, METHOD_END.) -- **Return-type detection.** Is `Type.getReturnType(desc).getClassName()` ∈ `deInternedClasses` the right test? `deInternedClasses` is a private `Set` of `Class.getName()` — need a small accessor (`Util.isValueTypeName(String)` or similar). Boxed return descriptors (`Ljava/lang/Integer;` etc.) map correctly? -- **Concretize correctness.** Does `createObjectValue(inst.val, inst.address)` for a boxed wrapper (Integer/Long/…) also yield a non-symbolic value (no leaked variables)? Any path where `inst.val` is null here? -- **The "don't overwrite" choice** vs V-3: is preserving the receiver entry the right call, and is the result-round-trip residual genuinely G3's to close (not a v1 soundness hole that bites SV-COMP verdicts — note context loss already downgrades the verdict)? -- **Interaction with G1 registration.** After G1, the receiver was registered under its concrete object during its own GETVALUE. Confirm the UNMODELED_RETURN path doesn't need to (and doesn't) touch that entry. -- **Does any modeled method ever return `PlaceHolder.instance`** for a value-typed return (so it'd be wrongly tagged)? (Modeled String ops return real values; verify the stub set.) - -## Review round 1 — resolutions - -Accepted from review: - -- **[B1 + C3, unified] Gate the *concretize* on the concrete `inst.val` type at recovery, not the declared return type at tag time.** Tag **all** unmodeled invoke returns as `UNMODELED_RETURN` (no return-type gating in `InvocationHandler`). In `visitGETVALUE_Object`, the `UNMODELED_RETURN` branch concretizes **iff `Util.isValueType(inst.val)`**, else falls back to the existing G1 recovery (`getFromHeap`/create). This closes both the **Float/Double gap** (`deInternedClasses` is String + 6 wrappers and *excludes* Float/Double — the "6 boxed wrappers" framing was wrong; Java has 8) and the **declared-vs-concrete mismatch** (a method declared `CharSequence`/`Object`/`Number` but concretely returning a String/box now still concretizes) in one move. Non-value-typed unmodeled object returns are unchanged from G1. -- **New `public Util.isValueType(Object)`** = `String || Number || Boolean || Character` (String + all 8 boxed wrappers); `null` ⇒ false. Independent of `deInternedClasses` (which stays as the de-intern/`==` concern and deliberately omits uncached Float/Double — don't widen it and change `==` semantics). -- **[#1 ordering] The tag swap goes strictly after `InvocationHandler.java:108`** (the `equals(PlaceHolder.instance)` context-loss check), with a code comment so a later refactor can't move it ahead and silently kill the flag. `PlaceHolder` has no `equals` override (Object identity), and `Frame.setRet`/the `symbolicInstance`/`i==0` checks don't disturb a tagged instance — verified by review. -- **[#5] Gate is `equals(PlaceHolder.instance)` specifically** (not "any PlaceHolder"): `PlaceHolder.symbolicInstance` (symbolic non-String receivers) and all modeled real results are never tagged. Review confirmed `placeholder ⇒ unmodeled` is safe across the String/boxed stub set. -- **[nits]** Guard `inst.val == null` in the concretize branch (→ fall back); add `UNMODELED_RETURN` to the enum (toString readability optional). - -[B2] **downgrade-safety claim corrected.** I overstated it: context loss downgrades **SAFE→UNKNOWN only** (`SVCompDriver.py:304`); VIOLATION is downgraded only by `reference_semantic_change` (`:316`). The base V-1 case is fully sound. The residual (a concretized this-return result round-trips through unmodeled memory, re-enters, re-aliases, then drives a branch) is narrow and closed by G3 (output de-interning splits the identity). Open: in a concolic engine a reported VIOLATION should be a *concretely witnessed* real input replayed to the error — if so, the residual can only change *which inputs we try*, never manufacture a false VIOLATION; SAFE is the only verdict at risk and context loss already downgrades it. To verify during implementation: confirm SWAT's VIOLATION is replay-witnessed; if it is **not**, arm a downgrade (`reference_semantic_change` or equivalent) on the `UNMODELED_RETURN` concretize path. diff --git a/docs/heap-redesign-g3-design.md b/docs/heap-redesign-g3-design.md deleted file mode 100644 index 54de38f..0000000 --- a/docs/heap-redesign-g3-design.md +++ /dev/null @@ -1,536 +0,0 @@ -# G3 — Output-boundary de-interning: Problem & Proposed Solution - -Phase G3. Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`) + G4 (`48763bc`/`6d39904`/`58c3932`). - -## Implementation status - -- **A1 (String): committed `994e642`.** Boundary de-intern of un-instrumented String returns + register-only-non-constant at the String recovery path. 2 design-audit rounds + 3 post-impl reviews. -- **A2 (boxed): implemented (this branch).** Extends de-interning to the six cached boxed wrappers in `Util.deInternedClasses` (Integer/Long/Short/Byte/Character/Boolean; **not** Float/Double — uncached, reference-equality). Two parts: - - *Bytecode* (`NoCacheMethodAdapter.deInternReturn` + the `Boxed` table): boxed wrappers have no copy constructor, so the return is **unbox+rebox**ed (`new Integer(result.intValue())`, etc.), null-guarded. A local holds the unboxed primitive across the `NEW` — required for `long` (category-2, where `DUP_X1`/`SWAP` can't reorder a wide value) and uniform via `Type.getOpcode(ISTORE/ILOAD)`. Mirrors the existing `valueOf(primitive)` rewrites. - - *Executor* (`visitGETVALUE_Object` non-String branch + `isConstantDeInternedValue`): register-only-non-constant, **scoped to `Util.isDeInternedClass(inst.val)`** so it touches only the de-interned boxed types. Mutable objects and Float/Double on this shared recovery path keep unconditional registration. A boxed wrapper carries its formula in the inner `BoxedValue.getVal()`, not the wrapper's own field — the predicate reads the right one. - - *Tests*: `OutputDeInternSpec` (L1) — symbolic boxed → registered, constant boxed → not, **mutable object → always registered** (shared-path regression guard); `BoxedDeInternAgentSpec` (L2) — Integer (cat-1) + Long (cat-2 wide) returns load, verify, and run under the real agent (`AgentRun` asserts `exit==0`, so a malformed wrap would fail). 48 heap+processor + 5 L2 green. - - *Note on the round-trip win*: boxed UF materialization is not yet active (the whitelist survey's "Future tier"), so today boxed shadows reaching recovery are constants or symbolic-input-derived; the soundness win (fresh identity → no aliasing collision) holds regardless, and register-only-non-constant is forward-ready for when boxed UFs land. -- **B (equality review): pending** — review `refEquals` / `==`→value-equality + the `reference_semantic_change` flag asymmetry for the de-interned cases. - -## Problem (recap) - -The shadow heap (G1) is now a **reference-keyed** weak identity map: a tracked value is recovered by the -real object's reference identity. For mutable objects (J1) that key is sound. For **immutable value types -(J2: String + boxed)** the reference is NOT a sound key — interning and `this`-returns make two logically -distinct values share one object (so one heap cell), or make a result share the receiver's object. - -G2 defends at the *recovery* site (concretize an unmodeled value-typed return; don't consult/mutate the -heap for it). But a documented **residual** remains: if a value-typed result (e.g. `toLowerCase()` -returning `this`) flows through **untracked** space (an untracked field/store) and is later recovered by -its real reference, the heap lookup finds the *receiver's* stale symbolic cell → the concretized result -**re-aliases** to the receiver. This is **precision-only**: G2 already flags context-loss on the unmodeled -call (SAFE→UNKNOWN), and VIOLATION is replay-witnessed, so it cannot cause a wrong verdict — but it -pollutes tracking and inflates candidate-set sizes. - -The principled fix for J2's "no sound reference key": make every produced value a **distinct object**, so -the reference key becomes sound for value types too (no two logical values share one object; no result -shares its receiver). That is **de-interning**, extended to method *returns*. - -## What already exists (nocache, active by default `useStringInterning=true`) - -`instrument/nocache/NoCacheMethodAdapter` already de-interns at instrumented sites: -- **String literals:** `LDC "x"` → `new String("x")`. -- **`String.intern()`** calls are removed. -- **Boxed `valueOf`** (`Integer/Long/Short/Byte/Character/Boolean.valueOf(prim)`) → `new Boxed(prim)`. - -So de-interning of *inputs/literals* is done. G3 adds the missing **output boundary**: value-typed method -*returns*. - -## Proposed solution (G3) - -Extend `NoCacheMethodAdapter.visitMethodInsn`: after a call that returns a **value type** (v1: `String`), -wrap the result in a fresh object so the produced value has a fresh identity. Bytecode to wrap a `String` -already on the stack: -``` -NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V -``` -(leaves `new String(result)` on the stack). Gated on `useStringInterning` (the existing de-intern switch), -and only for calls at instrumented sites whose return descriptor is `Ljava/lang/String;`. Skip our own -de-intern `` (void return — not value-returning, so naturally skipped) and `IGNORED_INVOCATIONS`-style -intrinsics. - -Effect (D-1): every value-returning call yields a fresh-identity object → a `this`-return can no longer -alias the receiver, and the reference key is unique per produced value → the G2 residual closes and -candidate sets stay singletons. - -## Why it's verdict-neutral / sound (D-2) - -De-interning copies the value (`new String(s)` has the same content) — `.equals`/value semantics are -unchanged. The only behavioral change is **reference identity** (`==`): two formerly-`==` values become -`!=`. The executor already detects this (`SymbolicTraceHandler.recordReferenceSemanticChange`, fired in -`ObjectsInvocation` when de-interned values are reference-compared) and downgrades **VIOLATION→UNKNOWN** -(never SAFE→VIOLATION or VIOLATION→SAFE). So de-interning can only make a verdict *more* conservative -(a `==`-dependent VIOLATION becomes UNKNOWN), never wrong. The same reasoning already justifies the -existing literal/boxed de-interning. - -## Acceptance tests (from heap-redesign-tests.md) - -- **D-1 Fresh identity on produced values:** an unmodeled value-returning call at an instrumented site → - result has a fresh identity distinct from inputs; a this-return cannot alias the receiver; candidate set - is a singleton. -- **D-2 De-interning is verdict-neutral:** a program run with/without output de-interning → same verdict - (soundness-neutral; de-interning never flips soundness). -- **D-3 reference-semantic-change only when warranted:** `==` on de-interned values → the flag fires only - when de-interned `==` actually diverges from real reference semantics, not otherwise. - -## The central tradeoff (the thing to decide) - -De-interning returns is **precision on the SAFE/tracking side at a cost on the VIOLATION side**: more -distinct objects → more `==` divergence → more `reference_semantic_change` → more VIOLATION→UNKNOWN, plus a -`new String` allocation per value-typed return. The G2 residual it closes is precision-only (cannot cause a -wrong verdict). So G3's worth hinges on: does the tracking-precision gain (cleaner candidate sets, no -round-trip re-aliasing) outweigh the added VIOLATION→UNKNOWN cost + overhead? - -## FINAL PLAN — boundary de-intern (un-instrumented value-type returns) + register-only-non-constant - -Supersedes the "narrow this-return-prone" scoping below: that was a mis-framing (user-corrected). Interning is -**general** — a literal `"Hello"`, a constant, or any object returned from code we don't instrument can be -interned/shared, which is the same heap-key unsoundness as a `this`-return. So the criterion is the *boundary*, -not specific methods. - -**Part 1 — Bytecode (`NoCacheMethodAdapter.visitMethodInsn`): de-intern un-instrumented value-type returns.** -After a call whose return type is a value type subject to interning (v1: `Ljava/lang/String;`) where **the -callee is not instrumented** (`!Util.shouldInstrument(owner)`), wrap the result, **null-guarded**: -``` -DUP ; IFNULL L ; NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V ; L: -``` -- **Skip the SWAT / sv-benchmarks intrinsic owners** (`org/sosy_lab/sv_benchmarks/Verifier`, `de/uzl/its/swat/**` - — Intrinsics/Witness/UtilInstrumented/instrument.svcomp.Verifier): un-instrumented but the symbolic-input - designation / witness seam — de-interning them would de-intern the symbolic input. -- Gate on `useStringInterning`. Value types only (boxed deferred; mutable J1 never de-interned). -- **Why complete:** instrumented code's literals are already de-interned at `LDC` (existing nocache), `intern()` - removed, boxed `valueOf` de-cached; every value entering from un-instrumented space is now de-interned at that - call. So the only un-de-interned values are exactly the un-instrumented returns this targets. - -**Part 2 — Executor: register only NON-CONSTANT shadows (`:1366`).** The de-interned copy `O2` already registers -via the existing value-typed ADDRESS_UNKNOWN `putToHeap` (`:1366`) so it round-trips with no new mechanism — but -broad de-interning makes the **self-pinning leak** (a String is its own never-evicting weak-map key) grow. Fix: -guard that `putToHeap` on the shadow being **non-constant** — register iff `!fmgr.extractVariablesAndUFs(formula) -.isEmpty()`. A pure constant is fully reconstructible from the observed concrete (`inst.val`) on round-trip, so -registering it buys nothing; a symbolic/UF shadow carries a relationship that the concrete can't reconstruct, so -it must be registered. This keeps the full de-intern (identity fix for all) while bounding the leak to the -UF/symbolic round-trips that benefit. -- **Predicate = non-constant formula, NOT `isSymbolic()`** (a G4 UF result is built by a constructor, so its - flag may be false though it carries a UF — guarding on the flag would wrongly skip exactly the win). -- **`:1366` is a SHARED path** (all value-typed ADDRESS_UNKNOWN recoveries, incl. modeled constant results like - `concat`-of-constants), so the guard also stops registering those — safe by the same reconstructible argument, - but broader than G3-only: auditors must confirm no non-G3 path relies on a constant being heap-registered. Also - audit `:1363` (the `formula==null` sibling — a constant by definition; would now skip, consistent) and confirm - `:1331` is irrelevant (placeholder path, value types return early at `:1303`). -- No other executor change. G2 concretize branch unchanged; the de-interned O2's UF survives (`invokeInit` copies - the formula). - -**Soundness / tests / costs:** as established by the prior audit — G3 can't flip a verdict; `==`/`refEquals` is -class-based + independent of de-interning; VIOLATION replay-witnessed; register-only-non-constant is sound -(constants reconstructible). Null-guard needed; the bytecode-wrap test MUST run with `checkClassAdapter=true` -(off in `sv-comp.cfg`). Tests at corrected altitudes: UF-survives-round-trip + a constant-still-recovers + -clobber-regression at L1; bytecode-wrap/null/loads+verifies + D-1 fresh-identity at L2; D-2 verdict-neutral at L3; -D-3 + measure the `Objects.equals` `reference_semantic_change` change (G3's O2 sets `userDeInterned`). Pre-existing -`replace(CharSequence)→this` `:1398` modeling inaccuracy: confirm de-intern (which wraps AFTER O1's GETVALUE) -doesn't worsen it. - -### ROUND-2 VERDICT — CONVERGED (all 3 auditors): sound, complete, ready, with gates - -- **Bytecode (auditor 1):** broad criterion sound. `!Util.shouldInstrument(owner)` is the correct, transformer-consistent boundary (and returns false for `java/lang/*` → those String returns de-interned, intended). **Refinement:** the skip-set MUST be an explicit literal owner check — - `owner.startsWith("de/uzl/its/swat/") || owner.equals("org/sosy_lab/sv_benchmarks/Verifier")` — layered as an ADDITIONAL exclusion when `!shouldInstrument(owner)`; do NOT delegate to `shouldInstrument` (it already returns false for those, so it would no-op the skip; note its hard-coded true-cases for `svcomp/Verifier`+`UtilInstrumented` mean the explicit `de/uzl/its/swat/**` entry is the load-bearing protection for Intrinsics/Witness). Wrap mechanics invariant across invoke opcodes/stack depths. **Watch-item:** broad scope multiplies wrap sites → method-bytecode-size (64 KB) pressure on String-heavy methods. -- **Executor (auditor 2):** register-only-non-constant is correct + a STRICT IMPROVEMENT over the dropped fresh-guard (gates on shadow content, so it never wrongly skips a legit cell update — eliminates the round-1 first-wins hazard). Keeps G4 UF results; bounds the leak to symbolic/UF returns; round-trip + no-clobber hold. **Doc fixes:** (a) the reason to use `extractVariablesAndUFs` (not `isSymbolic()`) is that it's the safe SUPERSET (catches UF-over-constants) — NOT "the G4 result's flag is false" (for the result shadow reaching `:1366` it carries the input var, so the flag is true there); (b) the `:1363` `formula==null` sibling is always-constant → simply DROP its `putToHeap` rather than predicate-guard it. -- **Soundness/scope (auditor 3):** sound (broad scope touches only identity, which no `==`/witness path consults) + complete (boundary criterion + existing LDC/intern/boxed de-intern provably covers all interning entry points). register-only-non-constant resolves the heap-bloat leak. Residual over-reach = pure `new String` allocation on ALL String returns incl. modeled (no perf baseline) — accepted watch-item. - -**TWO MUST-DO GATES (not optional):** -1. **Measure the `reference_semantic_change`-induced VIOLATION→UNKNOWN delta on the SV-COMP suite, before/after.** Broad scope makes the `Objects.equals(deInterned String, …)` firing strictly larger (every O2 sets `userDeInterned=true`); a currently-VIOLATION testcase flipping to UNKNOWN is a real score regression. (Mitigating fact: the suite currently has zero `Objects.equals` on Strings — auditor round-1 — and the `==`/refEquals path does NOT set the flag and is value-equality regardless of de-intern, so the expected delta is ~0; the gate is to confirm + catch regressions.) -2. **Run the bytecode-wrap test with `checkClassAdapter=true`** (`sv-comp.cfg` has it off, so a malformed wrap would otherwise degrade silently to a `VerifyError`→UNKNOWN). - -**Tests:** L1 — UF-survives-untracked-round-trip; a constant-return still recovers (pins the `:1363` reconstruct path); clobber-regression; **the non-constant-guard predicate itself** (drive a GETVALUE at `:1366` with a constant-formula StringValue → assert `getFromHeap` stays null; with a UF-formula → assert registered). L2 (`checkClassAdapter=true`) — bytecode-wrap + null-return + loads-and-verifies + D-1 fresh-identity. L3 — D-2 verdict-neutral (de-intern on/off → same verdict). D-3 — assert the `Objects.equals`-on-de-interned firing + measure (gate 1). - -### Round-2 audit re-check points (the changes vs the narrow plan) -1. The **broad criterion** (`!shouldInstrument(owner)` + value-type return, minus SWAT/sv-benchmarks intrinsics): - is it sound + complete, is `Util.shouldInstrument(owner)` the right boundary test at the call site, and is the - intrinsic skip-set correct/sufficient? -2. **Register-only-non-constant at the shared `:1366`**: is `!extractVariablesAndUFs(formula).isEmpty()` the right - predicate, does it correctly keep G4 UF results (flag-independent), and is skipping constant registration safe - for ALL value-typed ADDRESS_UNKNOWN recoveries (no non-G3 regression)? -3. Surviving items: null-guard + `checkClassAdapter=true` test; the `replace`/`:1398` interaction; the - `Objects.equals` firing change; test altitudes. - -## (Superseded) CONVERGED PLAN (narrow this-return-prone) — replaced by the FINAL PLAN above - -Three independent auditors (bytecode / executor-clobber / soundness-scope-tests) converged. Soundness is -confirmed by all three (G3 cannot flip SAFE↔VIOLATION; the `==`→value-equality rewrite is class-based and -total-for-Strings, hence **independent of** whether G3 de-interns any object; VIOLATION is replay-witnessed on -the already-de-interned program). The plan is revised as follows: - -1. **DROP the fresh-guard (it was misdiagnosed).** A *modeled* `this`-return (`toString()→this`) returns the - receiver object carrying its **real address**, so it takes the address-match branch (`:1397-1399`) and does - NO `putToHeap` — it never reaches `:1366`, so there is no clobber to guard. And the de-interned copy `O2` is - always a fresh object (`getFromHeap(O2)==null` always), so the guard would never trigger on it; worse, a - `getFromHeap==null` guard would change `:1366` from latest-wins to first-wins for already-keyed Strings, - which some paths (the `setAddress`+`putToHeap` pattern) may rely on. → **Do not add the guard.** Instead, - first write the clobber test against *current* code and confirm it is already green (it is, per audit). -2. **NO new executor code needed.** The de-interned `O2` (a fresh `String` carrying the copied UF/concretized - formula via `invokeInit`, which preserves concrete+formula) already lands on the existing value-typed - registration at `:1364-1366` (reached via the wrap's `` GETVALUE), so it is `putToHeap`'d and - **round-trips correctly** (recovering the G4 UF for whitelisted methods). The user's "does the symbolic - handling store it correctly?" → **yes, the existing path already does**, once de-interning gives `O2` a - fresh identity. (G2's concretize branch stays unchanged — O1 is consumed by the wrap; only O2 is registered.) -3. **NARROW the bytecode scope (all three flagged broad-scope problems):** - - **MUST skip the symbolic-input / SWAT intrinsic owners.** `nocache` runs BEFORE the SV-COMP transformer, - so a blanket String-return wrap would de-intern `org/sosy_lab/sv_benchmarks/Verifier.nondetString()` — the - symbolic input at its designation seam. `NoCacheMethodAdapter` cannot see `InvocationHandler.IGNORED_INVOCATIONS`, - so mirror a minimal skip-set (`de/uzl/its/swat/**` owners + `org/sosy_lab/sv_benchmarks/Verifier`). - - **Restrict to `this`-return-prone `java/lang/String` methods**, not all String returns — bounds (a) the - self-pinning heap leak (each de-interned String return is a never-evicting weak-map cell, since a String - is its own key), (b) the per-return `new String` overhead on the hottest opcode class, and (c) the one - real `reference_semantic_change` increase (C-4 below). Curated v1 set: `toLowerCase`, `toUpperCase`, - `trim`, `strip`, `stripLeading`, `stripTrailing`, `substring` (by owner+name+desc at the call site). - **Exclude `replace(CharSequence,CharSequence)`** (its modeled `this`-return can trip the `:1398` - address-match assertion — a pre-existing inaccuracy G3 shouldn't poke) and `concat`/fresh-producers - (no aliasing → pure cost). -4. **Null-guard confirmed needed**; and `instrumentation.checkClassAdapter=false` in `sv-comp.cfg`, so a bad - wrap is NOT caught by the verifier (it surfaces as a silent `VerifyError`→UNKNOWN). → the bytecode-wrap test - MUST run with `checkClassAdapter=true`. -5. **One real cost to test+measure (C-4):** G3's `O2` sets `userDeInterned=true` (via `invokeInit`), so a - program doing `Objects.equals(trim(x), s)` now fires `reference_semantic_change` where a PlaceHolder result - previously might not → more VIOLATION→UNKNOWN. Sound (only conservatism), but contradicts the earlier - "~0 added firings"; narrowing the scope minimizes it. Cover with a D-3 test + measurement. -6. **Corrected test altitudes:** UF-survives-round-trip + clobber-regression at **L1** (drive GETVALUE with - chosen identities, introspect `getFromHeap`); bytecode-wrap correctness + null-return + loads-and-verifies - at **L2** with `checkClassAdapter=true`; D-2 verdict-neutral at **L3** (same verdict with de-intern on/off); - D-3 `Objects.equals`-firing at L1/L2. The L1 fixture is post-instrumentation so it can't test the wrap; - "candidate-set singleton" isn't observable at L2 without a new field — demote to L1 heap-introspection. -7. **Confirm the win materializes:** before paying even the narrow cost, confirm at least one in-scope pattern - exists where a whitelisted pure String method's result round-trips through untracked space (the UF-survival - win G3 uniquely enables). - -Net: G3(b) shrinks to **a narrow, null-guarded, intrinsic-skipping bytecode de-intern of `this`-return-prone -String methods** + **no executor change** (existing registration suffices) + tests at corrected altitudes. - -## (Original) PLAN — superseded by the converged plan above - -Corrected after user steering: the earlier "defer" reasoning under-valued G3 (it framed it as a narrow -residual). G3 is the *core* fix that makes the reference key sound for value-typed returns, enabling them -to be tracked (UF for whitelisted, concretized otherwise) **through untracked round-trips** (option (b)). -The `==`/`refEquals` concern noted below is pre-existing, total-for-Strings, and INDEPENDENT of G3 -(`refEquals` maps every String `==` to value-equality based on the class, not the object) — so it neither -motivates nor blocks G3. - -### Part 1 — Bytecode: de-intern value-typed returns (NoCacheMethodAdapter.visitMethodInsn) -After a call whose **return descriptor is `Ljava/lang/String;`** (v1) at an instrumented site, give the -result a fresh identity, **null-guarded** (`new String((String)null)` would NPE): -``` -DUP ; IFNULL L ; NEW java/lang/String ; DUP_X1 ; SWAP ; INVOKESPECIAL java/lang/String.(Ljava/lang/String;)V ; L: -``` -- Gate on the existing `useStringInterning` switch. Value types only (String in v1; mutable J1 returns keep - their sound identity key — never de-intern them). Boxed returns deferred. -- Applies to **all** String returns incl. modeled ones: de-interning fixes the *real object identity* - (this/interned), independent of how the executor shadows it. The shadow flows through unchanged — the - wrap's `String.(String)` runs `invokeInit`, copying the shadow's concrete + formula (so a modeled - or G4-UF formula survives; only identity becomes fresh). - -### Part 2 — Executor: make registration sound + round-trip-correct (visitGETVALUE_Object) -The forward (de-interned) object `O2` is a fresh `String` carrying the shadow's formula; it already lands on -the non-placeholder ADDRESS_UNKNOWN path which **already** `putToHeap`s it (`:1366`) → round-trip recovery -works with no new registration code needed for `O2`. The fix is to make that registration **non-clobbering**: -- **Fresh-guard the value-typed `putToHeap`**: register only when the object isn't already tracked - (`getFromHeap(inst.val) == null` before `putToHeap` at `:1366`, and audit `:1363`/`:1331`). This prevents a - **modeled `this`-return** (`O1 == receiver`, possibly reaching `:1366` with ADDRESS_UNKNOWN) from - overwriting the receiver's symbolic cell — re-introducing the very aliasing G3 removes. (Unmodeled - this-returns are already handled+returned in the G2 concretize branch `:1278-1304`, never reaching - `:1366`, so they don't clobber today.) -- Net executor change is small: a guard, not a new mechanism. G2's deliberate "don't putToHeap" for the - concretize branch can stay (O1 is consumed by the de-intern; O2 carries the shadow forward and is - registered at `:1366`). - -### Two-object (O1 → O2) model (the subtlety to audit) -Each de-interned return yields O1 = the method's real return (maybe `this`/interned, immediately consumed by -the wrap's ``) and O2 = `new String(O1)` (fresh, flows forward). Both trigger a `GETVALUE_Object`. The -plan relies on: O1 (unmodeled) being handled in the G2 branch without registration; O1 (modeled this-return) -being **skipped by the fresh-guard** at `:1366`; O2 being fresh → registered at `:1366`. Round-trip recovers -O2's shadow (the UF for whitelisted methods — the goal). - -### Acceptance tests -D-1 fresh identity (L2: this-return produces a distinct-identity result, candidate set singleton); a new -**round-trip** test (L2: a whitelisted pure return stored via untracked space then recovered keeps its UF — -no re-alias to receiver, shadow preserved); D-2 verdict-neutral; D-3 reference-semantic-change only when -warranted; plus a **clobber regression** test (a this-return must not overwrite the receiver's shadow). - -### Key audit questions (for the three auditors) -1. **Bytecode**: is the null-guarded wrap correct (stack states, COMPUTE_FRAMES + CheckClassAdapter, the - IFNULL frame)? Does `LocalVariablesSorter` need anything? Discarded-result / chained-call edges? -2. **The clobber/fresh-guard**: does a modeled `this`-return actually reach `:1366` with ADDRESS_UNKNOWN and - `O1 == receiver`? Is the `getFromHeap == null` guard the right and sufficient fix at `:1366` (and - `:1363`/`:1331`)? Could the guard wrongly SKIP a legitimately-fresh registration? Is there any non-G3 path - that already clobbers here today? -3. **Round-trip correctness**: does O2 (fresh String with the copied UF/concretized formula) actually get - registered at `:1366` and recovered on an untracked round-trip? Does `invokeInit` truly preserve the - formula (so the UF survives the de-intern copy)? -4. **Soundness/verdict-neutrality (D-2)**: can de-interning + the registration change a verdict's soundness - (vs only conservatism)? Interaction with G2 (concretize branch unchanged), G4 (UF formula carried through - the copy), G_oob (divergence detection unaffected?). -5. **Scope/perf**: all String returns (incl. modeled) — acceptable overhead, or narrow? Is registering every - de-interned String return going to bloat the heap / change candidate-set behavior unexpectedly? - -## (Superseded) earlier defer analysis — kept for context - -An adversarial review (verified against code) recommends **not doing G3 now**: -- **Precision-only + narrow.** The residual G3 closes is a 4-way intersection (unmodeled call ∧ this-return ∧ untracked round-trip ∧ receiver previously heap-registered) and already yields the correct *conservative* verdict (G2 context-loss → SAFE→UNKNOWN at `InvocationHandler.java:138`/`SVCompDriver.py:304`; VIOLATION replay-witnessed). No current target exhibits a precision problem it would fix. -- **The "cost tradeoff" as framed is ~0.** `reference_semantic_change` fires ONLY on `Objects.equals(String,String)` (one caller, `ObjectsInvocation.java:47`); the SV-COMP suite has zero such String uses. So broad de-interning adds ~0 flag firings — but also means the advertised "VIOLATION→UNKNOWN conservatism" does NOT cover the `==` path. -- **Real costs/bugs G3 would add:** a `new String` per String return incl. already-MODELED returns (overhead, redundant); and a real **null-return NPE** (`new String((String)null)` throws) for any in-scope method that can return null. -- **It widens a pre-existing soundness approximation (the real issue):** `String ==` is rewritten by `RefEqualityMethodAdapter` → `UtilInstrumented.refEquals` → `Objects.equals` (value-equality) for de-interned classes, **with no flag**. So `new String("x") == new String("x")` is modeled as **true** (real JVM: false), unflagged, and VIOLATION is witnessed by re-running this de-interned+value-equality program. This is independent of G3 (it exists for literal/boxed de-interning today) but G3 would enlarge the de-interned-String population and thus the divergence surface. **Deciding whether this `==`→value-equality approximation is acceptable is the higher-value soundness question in this area — not the G2 residual.** - -**If G3 is done anyway:** narrow hard (only the unmodeled `this`-return-prone String methods — `toLowerCase/toUpperCase/trim/strip/substring`-style — not all String returns; feasible at the call site via callee owner/name), add a null guard, and first resolve/document the `==`/refEquals gap. - -## Risks / open questions for review - -- **Worth it + scope.** Is *broad* String-return de-interning right, or should it be narrowed (only - `this`-return-prone methods like `toLowerCase`/`trim`; or only value-returning calls in symbolic scope) - to bound the `reference_semantic_change` cost? Is G3 worth doing now at all, given it's precision-only and - G1/G2/G4 already prevent wrong verdicts? -- **Bytecode correctness.** Is `NEW; DUP_X1; SWAP; INVOKESPECIAL (String)V` the right wrap for a - String on the stack? COMPUTE_FRAMES/verify implications; interaction with the existing `CheckClassAdapter`. -- **reference_semantic_change cost (D-3).** Does the existing flag fire *only when warranted*? Will broad - return de-interning materially increase VIOLATION→UNKNOWN on the SV-COMP suite? (Plan says "measure - candidate-set sizes; monitor reference_semantic_change cost.") -- **Verdict-neutrality (D-2).** Confirm de-interning a return can never change a verdict's soundness, only - conservatism. -- **Interaction with G2/G4.** Does de-interning returns make G2's concretize partly redundant (the heap - recovery would no longer find the receiver)? Does it interfere with G4's UF-modeled returns (the UF - result is a materialized value; de-interning the real object underneath it — any conflict)? -- **Performance.** Return sites are hot-ish (every value-returning call); a `new String` per String return. - No perf baseline exists. Acceptable, or gate/narrow? -- **Boxed returns.** Defer to a follow-up (wrapping a boxed return needs unbox+rebox), or include? v1 = - String only (matches G2/G4 v1 scope). -- **Testing altitude.** D-1/D-3 likely L2 (real instrumentation + real identities); D-2 is L3 (verdict - with/without). What's feasible? - ---- - -# G3-B — Exact reference-equality via provenance (replaces the conservative flag) - -Phase G3-B. Design for review (after A1 `994e642` + A2 `791ce41`). Converged with the user before audit. - -## Problem -De-interning gives value-type objects fresh identities, which breaks reference-`==`. The current mitigation -(pre-G3) rewrites every `==`/`!=` to `UtilInstrumented.refEquals`, which uses **value-equality** for -de-interned classes, and a flag (`recordReferenceSemanticChange`) that downgrades VIOLATION->UNKNOWN when -value-equality might diverge from real reference-`==`. `UtilInstrumented` is instrumented, so the `==` path's -inner `Objects.equals` converges at `ObjectsInvocation`, which is where the flag lives. The flag is sound but: -1. **String-only** (`instanceof StringValue`) -> A2's de-interned **boxed** `==` divergence is unflagged - (e.g. `Integer.valueOf(200) == Integer.valueOf(200)`: real false, de-intern+value-eq true) -> a real - soundness gap. -2. **Over-fires** -> it flags comparisons that do NOT actually diverge (operands sharing the same original but - de-interned to distinct copies: `"x" == "x"`, a this-return `r == s`, and any `s == "literal"`), producing - spurious UNKNOWN and **losing SAFEs the user confirms cost us**. - -Root cause: `==` is **reference identity** (a concrete, per-path fact), but de-intern + value-equality models -it as a **value comparison**, which diverges exactly for equal-value / distinct-original-identity operands. - -## Design — model `==` by ORIGINAL identity (exact), drop the flag -1. **Stop de-interning constants.** `NoCacheMethodAdapter.visitLdcInsn` no longer de-interns String literals. - Literals are constants -> A1 `register-only-non-constant` never heap-registers them -> they cause no - reference-key collision -> they never needed distinct identities. Left interned, their `==` is real - reference identity (exact, no map). This removes the largest source of `==` divergence (`"x"=="x"`, - `s=="literal"`) for free and shrinks the provenance map to the genuinely-needed residual. -2. **Provenance map** for the remaining de-interned objects (non-constant un-instrumented returns - - this-returns etc.): a process-wide identity-keyed, weak-keyed map `copy -> root(original)` (guava - `MapMaker().weakKeys()`, the same primitive `JVMHeap` uses), populated at the de-intern step - (`NoCacheMethodAdapter` records `(copy, original)` when wrapping a return; `root` collapses chains so a - copy-of-a-copy resolves to the ultimate original). -3. **`refEquals`** (concrete branch decider): `shouldUseValueEquality(a,b) ? root(a) == root(b) : a == b` - - **reference on the originals**, NOT `Objects.equals`. Exact original `==`. -4. **Symbolic `==` becomes a per-path concrete constant.** Reference identity is not solver-controllable - (distinct allocations are never `==` regardless of input values; value comparison is `.equals`), so the - `refEquals` result is concretized. **Delete** `recordReferenceSemanticChange` and the `instanceof - StringValue` divergence block in `ObjectsInvocation`. Explicit `Objects.equals` / instance `.equals` stay - value-equality (correct - those are value comparisons and are de-intern-invariant). - -Result: `==` exact in every case (interned literal, this-return, distinct objects, alias, boxed cache) for -String AND boxed; no flag; no spurious UNKNOWN. - -## Why each case is now exact -| Operands | real `==` | provenance model | how | -|---|---|---|---| -| alias (`b = a`) | true | true | same object / same root | -| different values | false | false | distinct roots | -| two interned literals `"x"`,`"x"` | true | true | not de-interned -> same interned object | -| `s == "literal"` (input vs literal) | false | false | distinct roots (s not de-interned, literal interned) | -| `new String("x") == "x"` | false | false | distinct roots | -| this-return `s.toLowerCase() == s` | true | true | copy's root **is** s | -| boxed `valueOf(200) == valueOf(200)` | false | false | distinct roots | - -## Open questions for the auditors -- **Soundness:** does provenance + concretized `==` reproduce real reference-`==` in EVERY case above (and any - missed), for String AND boxed? Does concretizing lose any constraint the solver actually needs (claim: no - - identity is concrete/per-path)? Any path where a wrong verdict could result? -- **Literal-handling safety:** is leaving literals interned safe - does anything rely on the original LDC - de-intern (why was it there)? Interaction with `register-only-non-constant`, the heap, G2 concretize, G4 UFs? - Do literals ever need distinct identities for a reason we're missing? -- **Feasibility & cost:** the de-intern bytecode restructure to keep the original accessible (String: an extra - DUP; boxed: reuse the local); the map reachable consistently from concrete `refEquals` (raw object) and the - symbolic side (`shadow.concrete`); weak-key GC behavior; per-`==` lookup cost; the interaction with - `UtilInstrumented` being instrumented (do we still need that special-case once `refEquals` is reference-only - and concretized?). -- **Scope:** does the de-intern step record provenance for ALL de-interned returns (incl. constant returns it - can't distinguish at bytecode), and does that matter for map size / correctness? - ---- - -## G3-B REVISED (after round-1 of 3 design auditors + a characterization experiment) - -Round-1 outcome: the design is sound + buildable but the framing had two errors, one "blocker" was a mis-analysis, and a characterization experiment changed the mechanism. Revised design below; this is what round-2 reviews. - -### Experiment (settles the auditor contradiction) -Ran a symbolic `s`, `if (s == "abc")` under the real agent. Result: branch constraint = `(assert true)` (NOT a flippable value-equality constraint), AND `symbolicContextLoss=true` AND `referenceSemanticChange=true`. Conclusions: -- `==` is **concretized** today (the value-equality computed inside `refEquals`'s instrumented body is discarded; the call's boolean is concretized). The feasibility auditor was right; the "solver-controllable value-equality" framing was wrong. -- **Context-loss is a SECOND flag** firing in this area (because `refEquals(s,…)` concretizes a symbolic operand). Fixing only `referenceSemanticChange` would NOT recover the SAFE — context-loss would still fire. - -### Corrected mechanism — MODEL `refEquals` as a reference comparison (not "concretize") -G3-B must make `refEquals` a **modeled** reference comparison so that BOTH flags correctly do not fire and the result is exact: -- Route `refEquals` to a model that returns `root(a) == root(b)` as a **concrete identity boolean** for de-interned classes (`a == b` otherwise). Because the result depends only on object identity, not on `s`'s symbolic value, it must NOT trigger context-loss, and (provenance being exact) needs no `reference_semantic_change` downgrade. -- This supersedes the earlier "concretize the result" wording. The point is to model `==` as the (concrete, per-path) reference fact it is, sourced from `root`. - -### Soundness (corrected) — the round-1 "blocker" was a mis-analysis -The soundness auditor's claimed VIOLATION->SAFE (un-instrumented callee returns a JVM-shared object, SWAT de-interns it, `root(b)!=a` -> missed violation) does NOT hold: we record `root(copy) = the genuine pre-wrap returned object`, which carries the **real JVM identity**. So `root(a)==root(b)` reproduces real `==` exactly, including canonical-cache hits. The auditor assumed `root` points to a fresh copy; it points to the genuine original. **Hard requirement that makes this true:** record the genuine pre-wrap reference at EVERY de-intern site. - -Corrected justification (replaces "identity isn't solver-controllable"): concretizing `==` is sound because real reference-`==` is a **per-path concrete fact** and VIOLATION is **concretely witnessed** on the transformed program. Removing the discarded value-equality loses nothing real — a `==`-true path that only value-equality reached is infeasible in real Java (distinct objects are never `==` regardless of value). - -### Scope (decided): `==`/`!=` only; identity-hash is a documented residual (option A) -Provenance makes `==`/`!=` exact. `System.identityHashCode` / `IdentityHashMap` / identity-keyed logic in **un-instrumented** code on a de-interned value is an **irreducible, pre-existing, rare residual** (nocache has de-interned literals/boxed since before G3): reinjecting the original at the boundary would break the round-trip recovery (the de-interned identity must persist across the boundary for `getFromHeap`), and a redirect can't reach un-instrumented calls. Decision (user): document it; do NOT build a redirect or reinjection. The exactness claim is scoped to `==`/`!=`. - -### Implementation obligations (from all three round-1 auditors) -- **Atomic package:** stop-de-interning-literals (point 1) + model-refEquals-via-root (point 3) + delete-the-flag (point 4) must land together (the only consumer of `userDeInterned` is the flag block). Point 1 is **String-literal-only** (no boxed-literal analogue; boxed exactness comes from provenance). -- **Record provenance at EVERY de-intern site** with the genuine pre-wrap reference - LDC (if any de-intern remains there - but point 1 removes it), the 6 `valueOf` rewrites, and `deInternReturn`. A missed site breaks a `root` chain -> divergence. No single choke point today. -- **Map:** `MapMaker().weakKeys().weakValues()` (identity keys, like `JVMHeap`); `root(x)` returns `x` on a miss (collected or never-recorded) and collapses chains. Weak keys alone strongly pin the originals (leak); weak values + root-on-miss=self is leak-safe AND correct. -- **Bytecode:** restructure `deInternReturn` to keep the original across the wrap (String: extra DUP / a local; boxed: store the boxed original, not just the primitive), null-guard-correct, and tested with `checkClassAdapter=true` (sv-comp.cfg has it off). Compounds the 64KB method-size watch-item. -- **Deletions span 3 languages:** Java (`recordReferenceSemanticChange` + `userDeInterned` + the `ObjectsInvocation` block + the TraceDTO/SymbolicTrace field), the explorer (`SVCompDriver.py` VIOLATION->UNKNOWN downgrade + DTO parse), and Groovy tests (`TraceObservation`, `BaseSymbolicInstructionProcessorSpec`). Coordinate, or leave the explorer field inert with a comment - don't leave a silently-dead downgrade. -- **Leave alone:** `UtilInstrumented`'s instrumented status + the RefEquality skip-set (entangled with `liftClass` + the regress guard). -- **Re-verify A1** "a constant String is never `putToHeap`'d" under the no-literal-de-intern assumption (it's what makes leaving literals interned safe), and add a regression test that two occurrences of the same literal don't merge in the heap. - -### Round-2 questions -- Is "model `refEquals` as `root`-reference" the right mechanism, and does it actually avoid BOTH context-loss and `reference_semantic_change` (verify the executor doesn't flag a modeled reference comparison with a symbolic operand)? -- Is the corrected soundness argument airtight (genuine-pre-wrap-reference recording at all sites => `root` == real JVM identity => exact `==`; no VIOLATION->SAFE)? -- Is the atomic-package + record-at-all-sites + weakValues + 3-language-deletion plan complete and correctly sequenced? - ---- - -## G3-B round-2 outcome (all 3 auditors): String converged + sound; mechanism corrected; boxed fork - -- **Blocker RESOLVED.** Soundness auditor withdrew round-1 Finding C: recording `root(copy)=genuine pre-wrap object` => `root` carries real JVM identity => `root(a)==root(b)` is exact reference-`==` (incl. canonical hits). No VIOLATION->SAFE for String. -- **MECHANISM CORRECTED (decisive, feasibility auditor).** The design's "route `refEquals` in the invoke dispatch" is **DEAD CODE**: `UtilInstrumented` is force-instrumented, so the engine STEPS INTO `refEquals`'s body (the `getNextInst() instanceof INVOKEMETHOD_END` discriminator is false for instrumented callees) and never models the call -> `StaticInvocation` routing is never consulted. The two flags fire from INSIDE the stepped body: `symbolicContextLoss` from `Util.shouldUseValueEquality(s,..)` (un-instrumented `common/Util`, NOT in IGNORED, symbolic arg) and `referenceSemanticChange` from the inner `Objects.equals`. **Correct mechanism:** rewrite `refEquals`'s BODY to `shouldUseValueEquality(a,b) ? Provenance.root(a)==Provenance.root(b) : a==b`, and add `Provenance.root` + `Util.shouldUseValueEquality` to `IGNORED_INVOCATIONS` (so those inner calls concretize without firing context-loss). Keeps `UtilInstrumented` instrumented (no contradiction with leaving its status alone). Then `root(a)==root(b)` is an IF_ACMPEQ on concretized root objects -> a constant identity boolean -> no flags, exact. -- **HARD requirement:** collapse provenance chains at INSERTION (store the fully-resolved root at `record`), NOT lazy lookup -> eliminates the GC'd-intermediate-link VIOLATION->SAFE that `weakValues()`+lazy-walk would introduce. -- **BOXED FORK (real, new).** The Integer cache makes boxed `==` harder than String: `Integer.valueOf(100)==Integer.valueOf(100)` is TRUE in real Java (cache), and today's value-equality `refEquals` gets it right. But the existing `valueOf` de-intern turns both into distinct `new Integer(100)`, so `root`=self -> FALSE -> with the flag deleted that is a **false SAFE (regression)**. We CANNOT "stop de-interning boxed like literals" because `valueOf` can take a symbolic value (cache-range collisions), unlike always-constant String literals. Two sound options: - - **(X) Exact boxed:** at each `valueOf` de-intern site, also call the real `valueOf` to get the cached canonical and `record(freshBox, canonical)`. Then `root(a)==root(b)` is exact for boxed too (cache-hit -> same canonical -> TRUE; non-cache -> distinct -> FALSE). Cost: one extra `valueOf` call per de-intern site. Flag fully deleted; consistent with String. - - **(Z) Hybrid:** String exact via provenance (no String flag); boxed keeps value-equality + a conservative flag (the original A2-gap fix) -> sound, boxed `==`-divergence -> UNKNOWN. Keeps a flag for the rare boxed case. -- **Doc corrections:** the identityHashCode/IdentityHashMap/argument-identity residual is **unsound w.r.t. real Java** (accepted, no backstop) - not merely "rare/precision." - ---- - -## G3-B CORRECTION — literals DO turn symbolic; de-intern ALL (no "stop de-interning literals") - -Empirically confirmed (ran a `@Symbolic String s = "seed"` target under the agent): the literal `"seed"` -becomes a **symbolic String input** (`inputNames=[java/lang/String_0]`, a branch references it). So a literal -is NOT "always a constant" - it can be made symbolic (the designated input is a literal; more generally any -string may acquire a symbolic shadow). Therefore it needs a **distinct identity** so that (a) it is registered -and recoverable through untracked space, and (b) it does not collide with a same-valued *plain* literal that -shares the interned object. **=> "Stop de-interning literals" (REVISED point 1) is WITHDRAWN as unsound.** - -**Corrected model = UNIFORM (the user's original assumption): de-intern ALL value types + provenance everywhere.** -- Keep the existing de-intern at ALL sites (LDC literals + boxed `valueOf` + un-instrumented returns). No - special-casing. -- Record provenance `copy -> root(genuine canonical/original)` at EVERY de-intern site, collapsed-at-insert: - - **LDC literal:** the interned literal (the LDC value, on the stack before the wrap; DUP it). Two `"abc"` - literals -> same interned canonical -> `root` equal -> `==` true (exact). A literal-that-turns-symbolic -> - distinct de-interned identity, registered, recoverable; `==` vs a plain same-value literal -> both root to - the interned canonical -> true (exact, matches real JVM). - - **boxed `valueOf`:** the cached canonical (extra real `valueOf` call - the rewrite replaces valueOf before - it runs). Cache-hit -> same canonical -> true; non-cache -> distinct -> false (exact). - - **un-instrumented return:** the genuine pre-wrap returned object. -- `refEquals` body -> `shouldUseValueEquality(a,b) ? root(a)==root(b) : a==b`; `Provenance.root` + - `Util.shouldUseValueEquality` in IGNORED_INVOCATIONS (suppress context-loss); delete the flag. - -This is simpler (no literal special-casing, no "is it safe to stop de-interning literals" analysis, no -heap-isolation-for-interned-literals concern) and is exactly uniform across String + boxed: every value type is -de-interned and carries provenance to its canonical/original. Cost: a `record` call per de-intern site (incl. -per literal - bytecode/64KB watch), plus the extra `valueOf` at boxed sites. - ---- - -# G3-B2 — Delete the superseded reference_semantic_change flag (proposal for audit) - -The G3-B core (committed 59ed40d) makes `==` exact via provenance, and `refEquals` no longer routes -through `Objects.equals`, so the `==` path can no longer reach the `reference_semantic_change` flag. The -flag now fires ONLY on explicit user `Objects.equals(deInternedString, ...)` in `ObjectsInvocation` - -and there it is **spurious**: `Objects.equals` is value-equality (`a==b || a.equals(b)`; the `.equals` -dominates), which is de-intern-invariant, so de-interning never changes its outcome. So the flag (and -its `userDeInterned` marker) is now dead weight that only ever downgrades VIOLATION->UNKNOWN -unnecessarily. G3-B2 removes it. Soundness: removing a conservative downgrade is sound IFF the case it -guarded is now handled exactly (it is - `==` via provenance) or never diverged (explicit Objects.equals). - -## Proposed deletions (complete map, current line numbers) - -**Java (symbolic-executor):** -1. `invoke/java/util/ObjectsInvocation.java:42-49` - delete the `instanceof StringValue` block that calls - `recordReferenceSemanticChange`. invokeEquals keeps: arg check, NULL guard, `a.invokeMethod("equals", - ..., [b])`. KEEP the ObjectsInvocation class (explicit Objects.equals stays value-equality - correct). -2. `value/reference/lang/StringValue.java:40` (`userDeInterned` field + its `@Getter`) + `:225` - (`this.userDeInterned = true` in invokeInit). Update the invokeInit comment (no longer sets a flag). - `isUserDeInterned()`'s only caller was #1. -3. `trace/SymbolicTraceHandler.java:216-219` (`recordReferenceSemanticChange()`) + `:237-239` - (`isReferenceSemanticChange()`). Only callers: #1 and #6. -4. `trace/SymbolicTrace.java:29` - the `referenceSemanticChange` field (+ lombok-generated get/set: - `setReferenceSemanticChange` was used only by #3; `isReferenceSemanticChange` by #3/#6). -5. `trace/dto/TraceDTO.java:20` (field), `:22` (ctor param), `:28` (assignment). Update the ctor. -6. `trace/DTOBuilder.java:111` - drop the `symbolicTrace.isReferenceSemanticChange()` argument from the - TraceDTO ctor call. - -**Explorer (symbolic-explorer, Python) - full chain delete (no silently-dead downgrade left behind):** -7. `driver/SVCompDriver.py:316-318` - the `VIOLATION -> UNKNOWN` downgrade gated on - `...reference_semantic_change`. This is the actual verdict effect. -8. `parse/DataTransferObjects.py:35` - the `referenceSemanticChange: bool = False` DTO field. -9. `constraint/ConstraintController.py:43,54` - parse `request.referenceSemanticChange` + pass it on. -10. `constraint/ConstraintService.py:26,42,54` - the `reference_semantic_change` param + docstring + the - `Database.add_trace(...)` argument. -11. `data/Database.py:117,129` - the `reference_semantic_change` param + the - `tree[...].record_reference_semantic_change()` call. -12. `data/BinaryExecutionTree/Tree.py:36` (field init) + `:71-73` (`record_reference_semantic_change`). - -**Groovy tests:** -13. `testsupport/agent/TraceObservation.groovy:14` (field) + `:22` (parse). Once the Java DTO drops the - field, the JSON no longer carries it. -14. `processor/BaseSymbolicInstructionProcessorSpec.groovy:296` (doc) + `:329` - (`referenceSemanticChange: traceHandler.isReferenceSemanticChange()` in the executeBoundaryRecovery - result map). Verify no spec reads `result.referenceSemanticChange`. -15. `heap/StringRefEqAgentSpec.groovy:28` - drop the `!obs.referenceSemanticChange` assertion (the flag no - longer exists); KEEP `!obs.symbolicContextLoss` (context-loss is NOT deleted and still validates the - round-2 IGNORED suppression). - -## Open questions for the audit -- **Completeness:** are there any references the grep missed (reflective/string-keyed access, the - explorer's JSON contract, other tests reading `referenceSemanticChange`/`userDeInterned`)? -- **Soundness:** confirm deleting the flag cannot turn a real VIOLATION into SAFE: the `==` path is now - exact (provenance), and explicit `Objects.equals` never diverged under de-interning. Any third path? -- **Explorer correctness (untestable here - no runnable explorer test in this env):** does removing the - chain (param threading through Controller/Service/Database/Tree + the SVCompDriver downgrade) leave the - remaining call sites consistent (arity, kwargs)? Is full deletion safer than leaving it inert+commented? -- **Test impact:** does removing `referenceSemanticChange` from the executeBoundaryRecovery result map - break any L1 spec? Does the JSON-field removal break TraceObservation parsing (it uses `.path(...)` - which tolerates a missing field -> false, so order matters: ok)? - -## G3-B2 audit outcome — CONVERGED (3 auditors): sound, verdict-safe, green; 2 additions - -- **Soundness CONFIRMED (all 3).** After G3-B core, `refEquals` is `root(a)==root(b)` (no `Objects.equals`), so the `==` path never reaches `ObjectsInvocation`; the flag's sole remaining firing site is explicit `Objects.equals(deInterned,...)`, which is value-equality (de-intern-invariant) and so spurious. No third firing path. The deleted `SVCompDriver` block is a VIOLATION->UNKNOWN downgrade (never creates a verdict), so removal can only reduce conservatism -> cannot turn a real VIOLATION into SAFE. Sound. -- **Java #1-#6 complete + dangling-free.** Lombok: `SymbolicTrace` is class-level `@Getter@Setter`, so removing the field removes both accessors cleanly. `new TraceDTO(...)` has EXACTLY ONE call site (DTOBuilder:111) -> arity change consistent. -- **Explorer #7-#12: full delete, complete + verdict-safe.** Grep surface == the 12 sites; trailing-param/kwarg/single-call drops, no arity skew. pydantic v2 default `extra='ignore'` -> the DTO-field removal is tolerant of BOTH sequencings (Java-first or Python-first) -> no cross-language ordering hazard. Full-delete recommended over inert (inert leaves a permanently-False downgrade = the silently-dead code we want gone). -- **Groovy #13-#15: green.** Only `StringRefEqAgentSpec` asserts the flag; no spec reads `result.referenceSemanticChange` (the 5 executeBoundaryRecovery callers read only `.contextLoss`/`.recovered`); `TraceObservation.parse` uses `.path(...)` (tolerates missing). The `StringRefEqAgentSpec` reduction (keep `!symbolicContextLoss`) still validates the round-2 mechanism (context-loss is the load-bearing flag). - -### ADDITIONS to the deletion list (from the audit) -- **#1b — drop 3 now-unused imports in `ObjectsInvocation.java`** after removing the block: `StringValue` (:13), `ThreadHandler` (:14), and the static `currentThread` (:17). (`SymbolicTraceHandler` import stays — it's the `invokeStaticMethod` param type.) -- **#16 — `targets/sv-comp/scripts/lib/analysis/failures.py:74,102,127`** (MISSED by the original map): the `'reference_semantic_change'` stat bucket + the `'Found reference semantic change'` log-grep (that exact string is emitted only by the deleted `SVCompDriver:317` log) + the print category. Delete these or the analysis category goes permanently dead (the "no silently-dead reference" principle applies here too). - -### EXPLICIT GUARD -- **Preserve `symbolicPrecisionLoss`** — it sits adjacent to `referenceSemanticChange` in the `TraceDTO` ctor (`TraceDTO.java:18,22,27`) and is NOT part of this deletion. The reduced ctor keeps `symbolicContextLoss` + `symbolicPrecisionLoss`, drops only `referenceSemanticChange`. -- Cosmetic: reword `StringRefEqAgentSpec`'s "old refEquals fired both" comment (only one flag remains). diff --git a/docs/heap-redesign-g4-design.md b/docs/heap-redesign-g4-design.md deleted file mode 100644 index 7958091..0000000 --- a/docs/heap-redesign-g4-design.md +++ /dev/null @@ -1,164 +0,0 @@ -# G4 — Purity whitelist + generic UF for unmodeled pure returns: Problem & Proposed Solution - -Phase G4 (G4a whitelist + G4b generic UF, designed as one phase). **Status: G4-full, converged after 2 design-review rounds; awaiting user sign-off.** Builds on G1 (`67505c4`) + G2 (`e84b46e`) + G_oob (`5b35699`). - -## Round 2 — CONVERGED (exemption validated) + must-fixes - -Reviewer verified the `precision_loss` exemption is sound: (a) pruning — UNSAT-under-free-UF ⇒ real UNSAT ⇒ SAFE sound; (b) coverage — branch *enumeration* is concrete-driven (`SymbolicTraceHandler.checkAndSetBranch` records the real direction), so an ungrounded UF can't hide a reachable error branch, only affect flip-feasibility (where it's sound); (c) VIOLATION stays replay-witnessed. Scoping correct (bespoke UFs non-exempt; a `pure_` over a non-input var still downgrades — transitivity confirmed: `FormulaCreator.VariableAndUFExtractor.visitFunction` returns CONTINUE, descending into UF args). - -**Must-fixes (mechanical, fold into implementation):** -1. **Implement the exemption with a `FormulaVisitor` via `fmgr.visitRecursively`, checking `functionDeclaration.getKind() == FunctionDeclarationKind.UF` — NOT the keys-only `extractVariablesAndUFs` map.** The flat name→formula map can't distinguish a UF symbol from a variable, so the obvious implementation would be silently wrong. One pass collects `(name, isUF)`; apply input-regex to variables, `pure_` rule to UFs. -2. **Apply the exemption at BOTH sites** — `DTOBuilder.java:84-87` and the `InterruptedException` fallback `:100-102`. -3. **Extract the precision-loss predicate into a testable method** (the predicate currently lives inline in package-private `DTOBuilder`) so the negative control (exemption-not-too-broad) is a real L1 unit test; L2 (`TraceObservation` already parses `symbolicPrecisionLoss`) anchors it end-to-end. - -**Note (soundness-load-bearing):** with the exemption active, **context-loss becomes the only remaining SAFE downgrade on the UF path** — so "keep context-loss flagging on the UF path" is part of the soundness story, not just hygiene. (v1 doesn't whitelist `toLowerCase`, so F-2 is untouched.) Also acceptable-but-conservative: a whitelisted result that flows through a *bespoke* UF before a branch still downgrades (the bespoke UF taints it). - -## Round 3 — user steering: executor decides now, prepare the trace for a future explorer-side decision - -Decision (user): keep the **executor** making the precision-loss decision for now (the `pure_` exemption stays executor-side), but **enrich the trace** so the explorer has what it needs to take over the decision later — specifically an explorer-side, **CFG-reachability-aware** precision-loss decision ("downgrade SAFE only if a precision loss is on a path that can reach an assert"). The static CFG doesn't exist yet — it's a separate block planned soon — so we design today to feed it, not build it. - -Concretely (changes vs the Round-1/2 plan): -- **Per-branch classification.** Run the precision-loss classifier (the must-fix `FormulaVisitor`: input-regex for variables, `pure_` rule for UFs) **per `BranchElement`** in `DTOBuilder`, producing a per-branch boolean. The existing aggregate `symbolicPrecisionLoss` (TraceDTO) = OR of the per-branch flags → **verdict behavior is unchanged**; the explorer still downgrades on the aggregate, so the executor remains the authoritative decision-maker for now. -- **Trace contract.** `BranchDTO` gains a `precisionLoss` boolean (the executor's per-branch verdict), alongside its existing `iid`. The explorer parses it (forward-compatible) but does **not** consume it for the verdict yet. Rationale: a future CFG-aware decision keys each precision-loss branch by `iid` → CFG node → assert-reachability, with **no new trace contract** needed when that block lands. -- **Why per-branch + iid is sufficient prep:** the future decision needs only (a) *where* each precision loss occurred (the per-branch flag + `iid`, NEW) and (b) the static CFG (separate, future). Symbol-level detail, if ever needed, is recoverable from the per-branch constraint string already in `BranchDTO`. So this is minimal-yet-sufficient, not speculative schema. -- **Scope:** today only `precision_loss` gets the per-branch shape; `context_loss`/`reference_semantic_change` follow the same pattern later (same future arc). - -Net G4-full deliverable: the UF mechanism (executor) + the executor-side per-branch precision-loss classifier with the `pure_` exemption (aggregate drives the verdict, unchanged) + the per-branch `BranchDTO.precisionLoss` trace field parsed by the explorer (staged for the upcoming explorer-side, CFG-aware decision) + the tiny starter whitelist. - -**Scope (user decision):** purity whitelist + generic per-signature UF — the precision upgrade to G2. The escape-aware `DIFFERENTIATED` policy (deferred from G_oob) and StringBuilder/array/object divergence detectors are a *separate* follow-up that reuses this whitelist. Not G3 (output de-interning), which is unrelated. - -## Problem (recap) - -After G2, an unmodeled value-returning method's result is **concretized** (constant formula, non-symbolic) + context-loss-flagged — sound, but it drops the symbolic relationship to the inputs. For a method that is genuinely a *deterministic function of its inputs* (e.g. `String.toLowerCase()`), we can do better than "forget it": model the result as `result = f(inputs)` for an unknown-but-fixed `f`. That preserves the relational fact (equal inputs ⇒ equal outputs) and lets the explorer reason about / generate inputs through the call, without us knowing `f`'s definition. - -This is **not** the bespoke axiomatized UFs (`ToLowerCaseUF` etc.) — those live inside symbolic *models* and the heap redesign doesn't touch them. G4 is a **generic** uninterpreted function: one fresh UF symbol *per method signature*, applied to the call's symbolic inputs, with **no hand axioms**. - -## Why it's sound - -A generic uninterpreted function is a valid over-approximation of *any* deterministic function: it asserts only `inputs₁ = inputs₂ ⇒ f(inputs₁) = f(inputs₂)` (referential transparency). So for a method that is a deterministic, side-effect-free function of its captured inputs, `result = UF_sig(inputs)` is sound **by construction** — there are no axioms to get wrong. The soundness precondition collapses to: **the whitelist contains only genuinely pure + deterministic methods** (the G4a job). (Concrete grounding still holds: the result's concrete = the real observed value; the UF is uninterpreted, so nothing forces `UF(concrete-inputs) = concrete-result`, and the solver's choices are replay-validated — no false verdicts.) - -## Proposed solution - -**Architecture (A): build the UF formula where the inputs are (InvocationHandler), materialize the value where the concrete is (GETVALUE).** - -1. **Purity whitelist** (`Util` or a dedicated class): a set of method signatures (`owner/name:desc`) known to be pure + deterministic. Starter set kept *small* (the motivating cases, e.g. `java/lang/String.toLowerCase:()...`, `toUpperCase`, `trim`, `strip`), config-extensible. Default policy is "model only what a test needs" — most methods stay concretized (G2). -2. **At `InvocationHandler.invoke`** (the G2 tag site, where receiver+args are in hand): if the method is unmodeled (`retValue == PlaceHolder.instance`) **and** whitelisted-pure **and** ≥1 input is symbolic, build `Formula uf = ufmgr.callUF(declareUF(sig, returnType, argTypes…), inputFormulas…)` — argTypes from the input `Value`s' formula sorts, returnType from `Type.getReturnType(desc)`. Carry `uf` on the `UNMODELED_RETURN` placeholder (new optional `Formula recoveredFormula` field). Declarations cached per signature (a small registry, e.g. on `UFHandler`). If all inputs are concrete → no UF (fall to G2 concretize). -3. **At the `GETVALUE` UNMODELED_RETURN branch** (extends G2's concretize): if the placeholder carries a UF formula → materialize the result `Value` with `concrete = inst.val` (observed) and `formula = uf` (via the formula-taking ctor for the result type, e.g. `new StringValue(ctx, s, ufFormula, addr)`); else → G2 concretize (constant). No divergence check (built fresh from the observed concrete). - -**v1 scope:** String-returning whitelisted methods first (the motivating `toLowerCase`/`trim` cases; aligns with V-1). The mechanism generalizes to boxed/primitive returns (extend the result-type → FormulaType mapping + materialization); those are fast-follows, not v1. - -## Test interaction (expected) - -Whitelisting `toLowerCase` changes **V-1**: today V-1 asserts the result is `disjoint` from the receiver's variables (the G2 concretize outcome). The UF outcome `UF_toLowerCase(s)` legitimately *depends on* `s` (just isn't *aliased* — `formula ≠ s.formula`). So V-1's assertion moves from "disjoint" to "**depends on `s` but is not `s`'s formula**", and a new **U-5** (modeled-result precision: a whitelisted pure call's result carries a UF over the input, equal inputs ⇒ equal results) is added. If `toLowerCase` is *not* in the v1 starter whitelist, V-1 stays as-is and a different whitelisted method drives the U tests — decide during impl. - -## Acceptance tests - -- **U-5 (precision):** a whitelisted pure unmodeled method with a symbolic input → result depends on the input (its var set ⊇ the input's), is not concrete, and not aliased. -- **U-4 (relational/determinism):** the same whitelisted method applied twice to equal symbolic inputs ⇒ equal result formulas (same UF, same args). (Via `extractVariables` / SAT-agreement, not formula sorts.) -- **U-soundness (no over-constraint):** the UF adds no constraint that excludes a real behavior (it's axiom-free; assert the PC stays SAT for inputs the real method admits). -- Stay green: V-2, V-3, F-1/F-2, V-5/V-6/V-9, O-4/O-5, G_oob E-1/E-2, processor specs, L2 anchor. V-1 updated (above). - -## Risks / open questions for review - -- **Architecture (A):** is building the UF at `InvocationHandler` and materializing at `GETVALUE` correct — is `inst.val` (concrete) reliably present at the recovery for a whitelisted return, and is carrying a `Formula` on `PlaceHolder` clean? Any path where the carried formula's sort won't match the materialized value's type? -- **Generic UF construction:** does `UFManager.declareUF`/`callUF` support heterogeneous arg sorts (String + bitvector + …)? Correct per-signature **caching** (re-`declareUF` with the same name — safe, or must cache the `FunctionDeclaration`?). Deriving arg `FormulaType`s from input `Value`s and the return `FormulaType` from the descriptor — complete for String (v1) and the extension types? -- **Determinism precondition:** the whitelist must exclude methods that are pure-but-nondeterministic across runs/JVMs (locale-dependent case mapping, `hashCode`, iteration order, identity). How do we vet entries? (Conservative: tiny, hand-audited starter set; document the bar.) -- **Symbolic-input gate:** only build a UF when ≥1 input is symbolic — confirm concrete-only inputs correctly fall to G2 concretize (a UF over constants is pointless). -- **Soundness of concrete vs UF:** the materialized value has `concrete = observed` and `formula = UF(inputs)`, with no constraint tying them. Confirm this can't yield a false verdict (it shouldn't — UF is uninterpreted, solver models are replay-validated; same argument as G2/G_oob). -- **V-1 interaction:** is updating V-1's assertion (disjoint → depends-but-not-aliased) the right call, and should `toLowerCase` be in the v1 whitelist (forcing that change) or deferred? -- **Whitelist home/form:** `Util` set vs dedicated class; signature format; config-extensibility — what's cleanest and consistent with existing config patterns. - -## Review round 1 + decision: G4-FULL (UF + precision-preserving exemption) - -Decision: **G4-full** — the generic UF *and* a `precision_loss` exemption so SAFE is no longer downgraded through whitelisted pure calls. (G4-minimal would only steer input-generation; SAFE stays downgraded.) - -**Key review finding (B1, verified):** `DTOBuilder.java:84-87` sets `symbolic_precision_loss` if any branch-constraint symbol fails the input-var regex `[A-Z].*_[0-9].*`; `SVCompDriver.py:308-310` downgrades SAFE→UNKNOWN on it. A generic UF symbol fails the regex ⇒ SAFE always downgrades ⇒ a UF alone gives **no** verdict gain over G2. G4-full adds the exemption. - -**The exemption (the soundness-critical, new part):** change the `precision_loss` test (`DTOBuilder.java:84-87` and the analogous `:100-102`) from "all symbols match the input regex" to: **fire precision_loss unless every *variable* matches the input regex AND every *UF* is a `pure_`-namespaced generic UF.** Rationale: an axiom-free UF over real inputs is a sound over-approximation of any deterministic function — if a path is UNSAT with the UF free, the real function (one interpretation) is also UNSAT ⇒ SAFE sound. Crucially: **the bespoke axiomatized UFs (ToLowerCaseUF/EqualsIgnoreCaseUF/SinCosUF) stay NON-exempt** (their partial axioms' soundness is uncertain — the "alpha" ones), and any non-input *variable* still downgrades. `extractVariablesAndUFs` already surfaces nested vars, so a `pure_` over a non-input variable still downgrades (transitivity handled). Must distinguish UF-vs-variable in the extracted map. - -**Review fixes folded in:** -- **Do NOT whitelist `toLowerCase` in v1** — it's locale-dependent (Turkish-i) AND whitelisting it breaks V-2 and F-2 (both exercise `toLowerCase`), not just V-1. Lead with **`trim`/`strip`/`Math.abs`** (locale-independent, deterministic); leave V-1/V-2/F-1/F-2 untouched; drive U-4/U-5 with those. -- **Keep context-loss flagging on the UF path** — the UF is additive precision, never a license to remove a flag. -- **Value-typed receivers/args only** — `UF(receiver.formula, args)` is sound only when the result is a function of value-typed inputs; forbid whitelisting instance methods on stateful (non-value) receivers (result could depend on mutable fields not captured). -- **Descriptive, self-documenting UF names** `pure__[_]` ([A-Za-z0-9_] only) — e.g. `pure_String_trim`, `pure_Math_abs_int`, `pure_String_substring_int_int`. Reads as "the pure-function model of String.trim"; arg types disambiguate overloads; survives SMT dump/re-parse; cannot collide with the bespoke `toLowerCase`/etc. names. The `pure_` prefix is the exemption's recognizer (no internal phase jargon). Whitelist is curated (java.lang), so simple class names don't collide. -- **Cache per full signature** (`owner/name:desc`) on `UFHandler` (per-thread/solver-context); assert arg/return `FormulaType`s match on reuse. -- **Derive arg sorts via `fmgr.getFormulaType(formula)`** (not the descriptor) so they match the value's actual sort (avoids the Integer-theory-vs-Bitvector mismatch). -- **Gate on ≥1 symbolic input** via the existing `containsSymbolicArgument` — but read args BEFORE `arguments.add(0, instance)` mutates the list (N1). -- **Primitive-return path** (`visitGETVALUE_primitive`) intentionally ignores any carried UF formula in v1 (String returns only); assert/note it so it's not mistaken for a bug. - -**Whitelist construction:** hand-audited tiny starter (trim/strip/Math.abs) to prove the mechanism + exemption soundness; then an agent survey of String/Integer/Long/Short/Byte/Character/Boolean/Float/Double/Math/StrictMath (hazard checklist: nondeterministic, env/property readers, locale-no-arg, arg-mutating, identity/intern, default Object) to populate the broad list. Format `owner/name:desc`, config-extensible. - -**New acceptance test (G4-full):** a whitelisted pure call whose result feeds a branch ⇒ SAFE is **not** downgraded (precision_loss NOT set) when only `pure_`+input symbols appear; and a control where a non-input variable / non-`pure_` UF in a branch **still** sets precision_loss (the exemption isn't too broad). Plus U-4/U-5/U-soundness. - -## Round 4 — naming + cross-run observed-pair aggregation (user steering) - -**Naming (done above):** `pure__[_]`, prefix `pure_` is the exemption recognizer. No `g4uf_` jargon. - -**Cross-run observed-pair aggregation (the thing that gives the UF teeth).** A bare generic UF is fully *free* (only the relational `equal-inputs⇒equal-outputs` fact). Each concolic run, however, observes a concrete `(inputs → output)` pair for a `pure_` call — ground truth about the real function. Asserting `pure_String_trim(" hi ") == "hi"` only *tightens* the over-approximation (the constraint is true of the real function), so it's **sound** (UNSAT-under-tightened ⇒ real UNSAT ⇒ SAFE still holds) and strictly more precise. Accumulating these across all runs of a testcase turns each UF into a growing partial lookup-table of observed behavior — better input generation and tighter reasoning. - -**Where/how — reuses the existing UF-constraint plumbing (no new accumulation infra):** -- The bespoke UFs already ship their *defining constraints* via `symbolicTrace.getConstraints()` → `UFDTO` (`DTOBuilder.java:68-71`), and the explorer already accumulates UF definitions **per-testcase** in a `Set` (`Tree.ufs` + `Tree.record_ufs`, `Database.add_trace`). So a `pure_` UF emits its observed pair the same way: at the UF materialization, `addConstraint(fmgr.equal(callUF(decl, constant(concrete_in…)), constant(concrete_out)))` — a ground fact over *constant* inputs (NOT the symbolic input; that would be wrong). It travels via `UFDTO`, dedups into `Tree.ufs` across runs, and is injected when solving (verify the injection path `SolverHandler`/`ConstraintManager` reads `Tree.ufs`). -- Capture point: at `InvocationHandler` we have the concrete inputs (input `Value.concrete`) + the `decl`; at `GETVALUE` we have the concrete output (`inst.val`). So carry the constant-input UF application (or the concrete inputs + decl) on the placeholder and complete `== constant(out)` at materialization. -- Scope: per-testcase accumulation (matches "for a single testcase each UF gets more constraints"); the function is deterministic so pairs are valid, but per-testcase keeps it simple and bounded. - -**Sequencing (two commits within G4):** -- **G4 step 1 (core):** UF mechanism (free UF) + the `pure_` exemption + per-branch precision-loss trace + tiny whitelist + U-4/U-5/U-soundness + exemption controls. Proves the mechanism + soundness + SAFE-precision. The UF is free here. -- **G4 step 2 (aggregation):** emit observed `(in→out)` pairs as `pure_` UF constraints; verify per-testcase accumulation + solver injection; tests that the constraint appears and accumulates. Makes the UF informative. -- **Then:** the `java.lang` whitelist survey agents to scale the list. - -Each step runs the loop: implement → 3 independent reviews → commit. - -## Step 2 — implementation design (for review) - -**Status: G4 step 1 committed (48763bc) + L2 anchor (252ec5d). Step 2 EXECUTOR side implemented + committed; explorer side documented for the upcoming rework in `docs/heap-redesign-g4-step2-explorer-handoff.md` (user decision: prepare the executor, document the explorer work, move on to the whitelist survey).** - -Goal: give the free generic UF teeth by accumulating observed `(input -> output)` ground facts across a testcase's runs. A run that calls `String.trim()` on concrete `"abc "` observing `"abc"` is ground truth: `pure_String_trim("abc ") == "abc"`. Asserting it only *tightens* the axiom-free UF (a true fact about the real function), so it stays sound (UNSAT-under-tightened => real UNSAT => SAFE holds; concrete-grounded => no false VIOLATION) while making the UF informative for input generation + reasoning. - -**Emission (executor).** The concrete inputs are known at `InvocationHandler` but the concrete output only arrives at `GETVALUE`. So: -- At `InvocationHandler.buildPureUF`, in addition to the result UF `pure_sig(symbolic inputs)`, build a second application over the **constant** inputs `pure_sig(makeString(input.concrete), ...)` using the SAME cached declaration (so it's the same UF symbol). Carry it on the `UNMODELED_RETURN` placeholder (new field, e.g. `observedApplication`). -- At the `GETVALUE` materialization, emit `symbolicTraceHandler.addConstraint(equal(observedApplication, makeConstant(inst.val)))`. - -**Accumulation (explorer) — reuses existing plumbing.** `addConstraint`-ed constraints travel via `symbolicTrace.getConstraints() -> UFDTO` (DTOBuilder:68-71), and the explorer accumulates UF definitions per-testcase in a `Set` (`Tree.ufs` + `Tree.record_ufs`, dedup by definition string). So each run's observed pair accrues across runs for that testcase, with no new accumulation infra. - -v1 scope: String returns + String inputs only (constant building = `makeString`); other arg/return types arrive with the whitelist survey. - -### Round-1 review (BLOCKER) — the accumulation path doesn't exist; step 2 must build it - -The reviewer verified that the "rides existing accumulation, no new infra" premise is **false**: -- **`Tree.ufs` is dead code.** It is written by `Tree.record_ufs` (Tree.py:60) and **read nowhere** — it is never injected into the solver. -- **The live injection uses `Node.ufs`, which is frozen per-node.** `StrategyService.collect_uf_definitions` (StrategyService.py:78-82) injects `node.ufs` into `path_constraints` → `Z3Handler.solve` (SolverHandler.py:407-409). But `Node.ufs` is set only in the `Node` ctor (Node.py:51); `Tree.add_recursive` never merges incoming ufs into an EXISTING node. So a run's observed pair is injected only onto branches THAT run first created — flipping a pre-existing branch sees only the pairs frozen at its creation, NOT the accumulated table. The cross-run accumulation (step 2's whole point) does not happen. - -**Revised design (the rework):** step 2 must ADD explorer-side injection of the accumulated per-testcase set. Chosen fix: in `StrategyService.collect_uf_definitions`/`solve_branch`, also union the live tree's accumulated `Tree.ufs` into `path_constraints` (this finally gives `Tree.ufs` a consumer; `get_tree` returns a deepcopy, so read the set that reflects all `add_trace` calls before this solve). The executor emission half (carry const-application, emit `addConstraint(equal(...))` at GETVALUE) is unchanged and was confirmed sound. - -**C1 soundness wrinkle (must handle):** at solve time, if the injected UF-constraint set is UNSAT, `SVCompDriver` treats "no SAT branch" as **SAFE** (SVCompDriver.py:271-273) → a contradictory set yields a **false SAFE** for the whole testcase. All-true (deterministic) observed pairs can never be contradictory, so the whitelist's determinism bar prevents it — but step 2 elevates a bad whitelist entry from "imprecision" to "potential false SAFE." Mitigation: a contradiction guard at the dedup/accumulation point (same UF+inputs, different output ⇒ drop/skip, don't poison) + document the elevated stakes. (Confirmed clean by review: #2 getConstraints→UFDTO, #3 same cached decl for symbolic+constant applications, #4 ground-fact-only-tightens, #6 carry one const-application Formula on PlaceHolder, #7 emit at GETVALUE / inst.val faithful.) - -**Tests:** executor-side emission at L1 (assert the pair is in `getConstraints()`); plus an explorer/L3 test that an accumulated pair from an EARLIER run actually constrains a LATER flip — the current L2 anchor would pass even if injection were inert, so it does not cover this. - -### Round-2 review — design converged; step 2 is materially bigger than "ride existing plumbing" - -Round-2 validated the revised injection approach (no staleness: the `select_branch` deepcopy reflects all prior `record_ufs`; format/idempotence clean; `collect_uf_definitions` is the universal chokepoint) but surfaced an implementation hole + sharpened the guard. Net: step 2 now spans the executor AND the explorer solve path AND verdict soundness. Four must-fixes: -1. **Injection wiring.** `collect_uf_definitions(node)` cannot reach `Tree.ufs` (Node has no tree ref; `endpoint_id` is None for 4/5 drivers). Thread the accumulated set through `select_branch` → `solve_branch` → `collect_uf_definitions` (select_branch already holds the post-`add_trace` deepcopy). NOT via `get_tree(endpoint_id)`. (StrategyService.py:16-23,78-96) -2. **C1 contradiction guard (structural, not string-Set identity).** In `Tree.record_ufs`, parse each `pure_*` observed pair, key on `(uf-name, args) → rhs`, drop on conflicting rhs. New code in the accumulation path; narrow to `pure_` names. (Tree.py:52-60) -3. **C1 solve-time backstop (verdict soundness).** Before any UNSAT→SAFE conclusion (SVCompDriver.py:271-273), if the accumulated `pure_` facts ALONE are UNSAT, downgrade SAFE→UNKNOWN. Converts a worst-case false SAFE (bad whitelist entry) into a conservative UNKNOWN. ~3 lines, gated on `pure_` ufs present. -4. **L3 explorer test.** Drive the explorer directly (`Database.add_trace` + `select_branch` + `solve_branch`), assert an accumulated pair from an EARLIER run flips a LATER solve SAT→UNSAT (reset Database + `clear_constraint_cache` between cases). The L2 anchor would pass even if injection were inert. - -Confirmed clean: executor emission half (carry const-application, emit at GETVALUE, same cached decl); format match; idempotent double-add. Nit: the per-node `node.ufs` walk becomes redundant once the union lands. - -**Scope observation (for the user):** must-fixes 1 + 3 are explorer **solve-path / verdict-logic** changes — they overlap the explorer rework (static CFG + explorer-side decisions) you said is coming soon, and #3 changes the SAFE/UNKNOWN logic. Step 1 already delivers G4's core (SAFE preserved through pure calls); cross-run accumulation is a precision/input-generation enhancement on top, not core soundness. So there's a real choice about whether to build the explorer machinery now or coordinate it with that rework. - -Original open questions (now answered): -1. **Injection (critical) — ANSWERED: does NOT exist for accumulated facts; must be built (must-fix 1 above).** -2. **Carry design.** Is carrying the pre-built const-application (one `Formula`) cleaner/sounder than carrying the concrete inputs + decl and building at GETVALUE? Any hazard adding a second `Formula` field to `PlaceHolder`? -3. **Same-symbol sharing.** The result UF (over symbolic inputs) and the observed-pair UF (over constants) must be the SAME declaration/symbol (else the observed facts don't constrain the symbolic result). Confirm `PureFunctionUF.apply` returns the cached decl for both calls. -4. **Soundness.** Re-confirm asserting `pure_sig(const_in) == const_out` only tightens (never excludes a real behavior), and that a wrong/duplicate emission can't cause a false verdict. Dedup correctness across runs (definition-string identity). -5. **Determinism caveat.** If a whitelisted method were non-deterministic across runs, two runs could assert `f(x)==a` and `f(x)==b` with a!=b -> UNSAT (poisoning the whole testcase). The whitelist's determinism bar prevents this; flag that step 2 raises the stakes on it (a non-deterministic entry now corrupts solving, not just precision). -6. **Testing.** Executor-side: assert the observed-pair constraint is emitted (in `symbolicTrace.getConstraints()`); the cross-run accumulation itself is explorer/L3. Right altitude? - -## Round 5 — step-1 post-implementation review fixes (two real blockers) - -The first 3-review pass found step 1, as first implemented, did NOT actually deliver SAFE precision, for two reasons (both fixed): - -- **Issue A — input detection was wrong for String/array inputs (also pre-existing).** The precision-loss "is this a real input?" test used only the regex `[A-Z].*_[0-9].*`. Real input names: primitives are `I_0` (match), but **String inputs are `java/lang/String_0`** and arrays `[I_0` (don't match). So (pre-existing) String/array-input branches always tripped precision_loss → SAFE always downgraded; and G4's exemption could never fire (the String input var itself failed the regex). **Fix:** detect inputs by exact **term identity** against the designated inputs (`symbolicTrace.getInputs() → value.formula`, collected into a `Set`; JavaSMT formulas have value-based equals/hashCode) — correct for all types, no name pattern. The regex is **kept as an additive backstop** for symbolic variables that are grounded but NOT designated inputs — specifically values re-materialized by GETVALUE heap recovery, which call `MAKE_SYMBOLIC` with a fresh `I_n`-style name without registering an input (verified at SymbolicInstructionVisitor:1301,1318). Pure-term-only would conservatively (soundly) downgrade those → regression; the backstop avoids it. Net: term-identity is primary (fixes String/array designated inputs + the exemption); regex is a backstop. (Dropping the backstop later is safe once recovery-created vars are handled.) -- **Issue B — context-loss independently downgraded SAFE on the UF path, making the exemption pointless.** A whitelisted pure unmodeled call recorded context-loss (→ SAFE→UNKNOWN) regardless of the exemption. **Fix (supersedes the Round-2 "keep context-loss" note, which was self-defeating):** when a whitelisted pure call is **successfully modeled as a UF**, do NOT record context-loss for it. Sound because the whitelist guarantees purity/side-effect-freedom (nothing changed but the return) and the return is captured by the UF — so no context is actually lost. Non-whitelisted unmodeled calls (e.g. `toLowerCase`) still flag context-loss exactly as before (F-2 + the L2 anchor confirm). Both fixes rest entirely on whitelist correctness (purity), the standing G4 soundness precondition. - -Tests added/updated: PrecisionLossExemptionSpec now uses realistic `java/lang/String_0` input terms + passes the input set (5 cases); PureFunctionUFSpec adds U-6 (a whitelisted pure call does NOT flag context-loss). All green: 42 heap+processor, 2 L2 (anchor still flags `toLowerCase`), 0 fail. Plus style nits (redundant import, em-dash, import order). Re-review (3 agents) then commit. diff --git a/docs/heap-redesign-g4-step2-explorer-handoff.md b/docs/heap-redesign-g4-step2-explorer-handoff.md deleted file mode 100644 index 40e4bfc..0000000 --- a/docs/heap-redesign-g4-step2-explorer-handoff.md +++ /dev/null @@ -1,103 +0,0 @@ -# G4 step 2 — explorer hand-off: cross-run UF observed-pair accumulation - -**Status:** the EXECUTOR side is implemented + committed. This document specifies the remaining -**explorer** work, deliberately deferred to coordinate with the upcoming explorer rework (static CFG -+ explorer-side decisions), since it touches the solve path and verdict logic. - -## What the executor already does (done) - -For a whitelisted pure unmodeled value-returning call (G4 step 1, e.g. `String.trim`/`strip`), the -executor models the result as a generic axiom-free UF `pure__(inputs)`. Step 2 adds: -each run, at the GETVALUE recovery, the executor emits the **observed ground pair** for that call as a -constraint: - -``` -pure_() == -e.g. (= (pure_String_trim "abc ") "abc") -``` - -built with the SAME cached UF declaration as the symbolic result UF (so it constrains the same -symbol). It is emitted via `symbolicTraceHandler.addConstraint(...)`, so it travels to the explorer on -the existing UF channel: `SymbolicTrace.getConstraints()` → `DTOBuilder` `UFDTO` (DTOBuilder.java:78-81) -→ explorer `Parser.parse_ufs` → `Database.add_trace` → `tree.add(...)` (onto `Node.ufs`) **and** -`tree.record_ufs(...)` (into the per-testcase `Tree.ufs` set). Verified by `PureFunctionUFSpec` U-7 -(the pair is emitted, and is ground — over the constant input, no free variable). - -Emitting one pair per run is **sound on its own**: a single observed pair is a true fact about the -real function and cannot self-contradict, so nothing here can cause a wrong verdict today. - -## Why the explorer work is needed (the gap) - -Accumulation across runs does **not** currently reach the solver: -- **`Tree.ufs` is dead code** — written by `Tree.record_ufs` (Tree.py:60), read nowhere. -- The live UF injection (`StrategyService.collect_uf_definitions`, StrategyService.py:78-82 → - `Z3Handler.solve`, SolverHandler.py:407-409) reads **`Node.ufs`**, which is frozen in the `Node` - constructor (Node.py:51) and never merged across runs (`Tree.add_recursive` updates only - `node.constraint`, Tree.py:116-117). So a run's pair is injected only onto branches that run first - created; flipping a pre-existing branch never sees the accumulated table. - -So without the work below, step 2 is (almost) inert: the per-testcase lookup table accumulates in a -dead set and never tightens later solves. - -## Required explorer changes (land these together — see the coupling note) - -### 1. Inject the accumulated per-testcase UF set at solve time -Thread the accumulated set through the existing chokepoint. `collect_uf_definitions(node)` cannot reach -`Tree.ufs` (a `Node` has no tree back-reference; `solve_branch(..., endpoint_id=None)` is called with -no endpoint by 4/5 drivers: SVCompDriver.py:261, TargetDriver.py:154, SimpleDriver.py:160, -HTTPDriver.py:681, SVCompHandler.py:195 — only PassiveDriver passes it). So: -- In `StrategyService.select_branch` (which already holds the post-`add_trace` deepcopy `tree`, - StrategyService.py:16-23), pass `tree.ufs` into `solve_branch` → `collect_uf_definitions`, and union - those definition strings into `path_constraints` alongside the existing `node.ufs` walk. -- Do **NOT** add a `get_tree(endpoint_id)` inside `solve_branch` (endpoint_id is None on the main - paths). Use the snapshot `select_branch` already has. -- **No staleness:** the per-round lifecycle is sequential — `Database.add_trace` calls `tree.add` then - `tree.record_ufs` on the live tree before `retrieve_solution` → `select_branch` deepcopies, so the - snapshot reflects all prior runs. -- Format is identical (both `Node.ufs` defs and `Tree.ufs` strings are the same `fmgr.dumpFormula` - output), and re-asserting the same fact is idempotent in Z3 — double-adds are harmless. Once the - union lands, the per-node `node.ufs` walk is a redundant subset and may be dropped. - -### 2. Contradiction guard at accumulation (structural, not string-set identity) -`Tree.ufs` is a `Set` keyed by full-string identity — it cannot tell "same UF application, different -RHS" from two unrelated facts. Add a guard in `Tree.record_ufs` (Tree.py:52-60), narrowed to -`pure_`-named applications: parse each observed pair (via the existing `ConstraintManager` -parse machinery), key on `(uf-name, args) → rhs`; on a key collision with a **different** rhs, drop -the new pair (do not store both). A contradiction can only arise from a nondeterministic whitelist -entry; the guard prevents it from poisoning the accumulated set. - -### 3. Solve-time UNSAT backstop (verdict soundness — defense in depth) -Before any UNSAT→SAFE conclusion (`SVCompDriver.py:271-273` sets SAFE when no branch is SAT), if the -accumulated `pure_` UF facts ALONE are UNSAT, downgrade SAFE→UNKNOWN instead. This converts the -worst case (a bad/nondeterministic whitelist entry making the injected set self-contradictory → -**false SAFE** for the whole testcase) into a conservative UNKNOWN. ~3 lines, gated on `pure_` ufs -being present so it costs nothing on non-G4 testcases. - -### 4. L3 test (the L2 anchor does NOT cover this) -Drive the explorer directly (no JVM): `Database.instance().reset()` + `clear_constraint_cache()`, -then `add_trace` run A (a branch on `pure_String_trim(s)`, no pair) → the flip is SAT; `add_trace` -run B (same branch + an observed pair that forbids the value the flip needs) → assert -`select_branch`/`solve_branch` on that branch flips SAT→UNSAT. This proves an accumulated pair from an -earlier run constrains a later flip — i.e. injection is live, not inert. - -## Coupling note (important) - -**Items 1, 2, 3 must land together.** The executor emission and item 1 (injection) are what make the -accumulated facts reach the solver; once they do, a nondeterministic whitelist entry can make the set -UNSAT → false SAFE (`SVCompDriver.py:271-273`). So injection MUST NOT ship without the contradiction -guard (2) and the UNSAT backstop (3). Until this lands, the executor's emitted pairs sit in -`Tree.ufs`/`Node.ufs` and (apart from the existing within-run `Node.ufs` injection, which is benign — -a single pair can't contradict) do not affect verdicts. - -This also raises the bar on whitelist curation: with cross-run injection live, a wrongly-whitelisted -non-deterministic/side-effecting method becomes a *soundness* risk (potential false SAFE), not just an -imprecision. Keep `PureMethods` strictly pure + deterministic. - -## Key file references -Executor (done): `symbolic-executor/.../invoke/InvocationHandler.java` (buildPureUF, PureUFModel), -`.../value/PlaceHolder.java` (observedApplication), `.../SymbolicInstructionVisitor.java` -(visitGETVALUE_Object emit), `.../UFs/PureFunctionUF.java`, `.../trace/SymbolicTraceHandler.java:93` -(addConstraint), `.../trace/DTOBuilder.java:78-81` (UFDTO). Test: `PureFunctionUFSpec` U-7. -Explorer (to do): `symbolic-explorer/strategy/StrategyService.py:16-23,78-96`, -`data/BinaryExecutionTree/Tree.py:39,52-60,103-130`, `Node.py:51`, `solver/SolverHandler.py:391-393, -407-409`, `driver/SVCompDriver.py:261,271-273`, `solver/ConstraintCache.py`. diff --git a/docs/heap-redesign-g4-whitelist-survey.md b/docs/heap-redesign-g4-whitelist-survey.md deleted file mode 100644 index 9e08b17..0000000 --- a/docs/heap-redesign-g4-whitelist-survey.md +++ /dev/null @@ -1,92 +0,0 @@ -# G4 purity whitelist — java.lang survey (audited backlog) - -Audited classification of `java.lang` methods for the G4 generic-UF whitelist (`UFs/PureMethods.WHITELIST`). -The whitelist is **soundness-load-bearing** (G4 step-1 fix B skips context-loss for whitelisted calls, and -step-2's cross-run injection would let a wrong entry cause a false SAFE), so the bar is: **pure + -deterministic + side-effect-free + identical across JVM runs/platforms**. Surveyed by 5 parallel agents -(String / Integer+Long+Short+Byte / Float+Double / Boolean+Character / Math+StrictMath). - -## What is active now (committed to PureMethods.WHITELIST) - -Only **String-returning, UNMODELED** methods are active (v1 materializes String returns; whitelisting a -method SWAT already models is inert — the UF never fires). All instance methods on `String` (the proven -receiver shape): - -``` -java/lang/String/trim()Ljava/lang/String; (G4 step 1) -java/lang/String/strip()Ljava/lang/String; (G4 step 1) -java/lang/String/stripLeading()Ljava/lang/String; no-arg, same shape as trim -java/lang/String/stripTrailing()Ljava/lang/String; no-arg -java/lang/String/substring(I)Ljava/lang/String; arg-taking (mixed-sort UF: String,int) -java/lang/String/substring(II)Ljava/lang/String; arg-taking -java/lang/String/repeat(I)Ljava/lang/String; arg-taking -java/lang/String/replace(CC)Ljava/lang/String; arg-taking (String,char,char) -java/lang/String/indent(I)Ljava/lang/String; arg-taking -``` -Verified unmodeled (stubs returning `PlaceHolder.instance` in `StringValue`). Tested: no-arg shape by -`PureFunctionUFSpec` (trim) + L2 `TrimTarget`; arg-taking shape by L2 `SubstringTarget`. - -## Backlog — verified-effective, NOT yet added (needs test-harness support) - -These are sound + unmodeled + String-returning, but introduce shapes the current L1 fixture / test -harness doesn't yet drive (static invoke; the executor handles them, but we add nothing untested to a -soundness-load-bearing list): -- **Cross-class static String returns (UNMODELED — only `valueOf` is modeled in their Invocation handlers):** - `java/lang/Float/toString(F)`, `Float/toHexString(F)`, `java/lang/Double/toString(D)`, - `Double/toHexString(D)`, `java/lang/Character/toString(C)`, `Character/toString(I)`. Add once a - static-invoke L1/L2 test exists. (FP `toString` is spec-deterministic; Character case mapping is - locale-INDEPENDENT, unlike String.) - -## Inert — do NOT add (already modeled → the UF never fires) - -The boxed integral/boolean `toString`-family is modeled in `IntegerInvocation`/`LongInvocation`/ -`ShortInvocation`/`ByteInvocation`/`BooleanInvocation` (cases: `toString`, `toHexString`, -`toBinaryString`, `toOctalString`, `toUnsignedString`, `valueOf`). Whitelisting them is inert. -(`String.concat(String)` single-arg is also modeled.) The SV-COMP autostub corpus exercises -`Integer.toHexString`/`toBinaryString` etc. — already handled by these models, no UF needed. - -## Future tier — pure but NON-String return (activate when UF materialization extends beyond String) - -Large vetted set (sound to model as UFs once non-String return categories are materialized): -- **Integer/Long/Short/Byte:** parse*/valueOf/decode (String→num, deterministic), bit ops (bitCount, - highestOneBit, numberOfLeadingZeros, reverse, rotate*, reverseBytes), compare/compareUnsigned, - divideUnsigned/remainderUnsigned, sum/max/min, toUnsignedInt/Long, static hashCode(prim). -- **Float/Double:** parseFloat/parseDouble, isNaN/isInfinite/isFinite, compare, sum/max/min, static - hashCode, the CANONICALIZING bit ops (`floatToIntBits`/`doubleToLongBits`, `intBitsToFloat`/ - `longBitsToDouble`). -- **Boolean:** parseBoolean, compare, logicalAnd/Or/Xor, static hashCode. -- **Character:** the large predicate/conversion set (isDigit/isLetter/isWhitespace/getNumericValue/ - digit/forDigit/toUpperCase(char)/toLowerCase(char)/toTitleCase, codePoint helpers) — all - locale-independent + deterministic. -- **Math (exactly-specified only):** sqrt, fma, ceil/floor/rint/round/IEEEremainder, toRadians/toDegrees, - all integer arithmetic (*Exact, floorDiv/floorMod, abs, min/max), bit helpers (copySign, ulp, signum, - getExponent, nextAfter/Up/Down, scalb). -- **StrictMath (ALL math, incl. transcendentals):** bit-for-bit reproducible by spec — sin/cos/tan/exp/ - log/pow/cbrt/hypot/sinh/... plus everything Math has. - -Instance accessors on wrappers (`intValue()`, `doubleValue()`, instance `toString()`/`hashCode()`, -`compareTo(boxed)`) are pure-on-value but receiver-keyed — `ufName` keys on arg descriptors only, so two -distinct boxed receivers would collide to one UF term unless the receiver is incorporated. EXCLUDE until -receiver-keyed UFs exist; prefer the static equivalents (`hashCode(I)`, static `toString(I)`, `compare(II)`). - -## EXCLUDE — soundness traps (never whitelist) - -- **Locale-dependent:** `String.toLowerCase()`/`toUpperCase()` no-arg (default locale; Turkish-I). The - `(Locale)` overloads are deterministic given the locale arg, but the Locale arg is a non-value-typed - object so the UF wouldn't fire anyway. -- **Environment/property readers (look pure, are not):** `Integer.getInteger`, `Long.getLong`, - `Boolean.getBoolean` — read `System.getProperty`. -- **Nondeterministic:** `Math.random()` AND `StrictMath.random()` (the "Strict" prefix does NOT rescue it). -- **Cross-JVM nondeterministic:** `Math` transcendentals (sin/cos/tan/exp/log/pow/atan2/sinh/...) — only - within-ulp, not bit-identical across platforms; use the StrictMath twins. The raw-bit variants - `Float.floatToRawIntBits`/`Double.doubleToRawLongBits` (NaN payload varies). -- **Identity / arg-mutating / arbitrary-code / fresh-object:** `String.intern()`; `String.getChars`, - `Character.toChars(I[CI)` (mutate an arg); `String.format`/`valueOf(Object)`/`join(Iterable)`/ - `transform` (default locale or invoke arbitrary `toString`/`Function`); array/stream returners - (`toCharArray`, `getBytes*`, `split*`, `chars/codePoints/lines`); `equals(Object)` (consumes runtime - type + a foreign reference); constructors (`` allocate identity); `describeConstable`/ - `resolveConstantDesc` (Optional/reflection). `String.replaceAll/replaceFirst` excluded for v1 ($-group/ - escape replacement semantics a plain UF won't capture — revisit if regex is modeled). - -Per-class survey counts (INCLUDE/EXCLUDE) and full rationale are in the agents' transcripts; this file is -the actionable distillation. diff --git a/docs/heap-redesign-goob-design.md b/docs/heap-redesign-goob-design.md deleted file mode 100644 index 6b0dec6..0000000 --- a/docs/heap-redesign-goob-design.md +++ /dev/null @@ -1,87 +0,0 @@ -# G_oob — Out-of-band change detection (no havoc): Problem & Proposed Solution - -Phase "G_oob" of the heap redesign. **Status: IMPLEMENTED on `fix/heap-design`** (1 design-review round + 3 independent post-impl reviews for style/refactoring/correctness). Builds on G1 (`67505c4`) + G2 (`e84b46e`). Addresses confirmed bug (b): a tracked object's concrete state diverging from its shadow (e.g. mutated inside unmodeled code) is not handled soundly. - -**Landed scope (user decision: "configurable now, differentiate with G4a"):** a configurable `shadowDivergence` policy = **CRASH** (default; the original hard `SWATAssert`, for dev/CI bug-catching) | **FLAG** (graceful: record context loss → SAFE downgraded to UNKNOWN, adopt the observed concrete, continue; sound, recommended for production). Scoped to the **primitive `GETVALUE`** path only. **Deferred to G4a:** the `DIFFERENTIATED` (escape-aware) policy and the escape bit — so the escape set is built once, faithfully, with the purity whitelist; and StringBuilder/object-ref/array divergence detectors. Reuses the existing `symbolic_context_loss` flag (already SAFE→UNKNOWN) rather than wiring a new `outOfBandChange` flag (a clean fast-follow if per-stat distinction is wanted). Acceptance: E-1/E-2 green (L1, `GoobDetectionSpec`); all heap+processor+agentTest green; default CRASH = byte-for-byte prior behavior. - -## Problem (recap, grounded) - -When a tracked object is passed to unmodeled code that mutates it, the shadow `fields[]` go stale. The divergence surfaces at the next `GETVALUE` concrete-sync (the shadow value's concrete vs the real observed value). Today that divergence is **detected but handled as a hard failure**, not a graceful soundness signal: - -- **Primitives:** `visitGETVALUE_primitive` (`SymbolicInstructionVisitor.java:3631-3641`): on mismatch (`!checkEquality(peek.concrete, inst.v)`) it executes `SWATAssert.check(false, "[GETVALUE_primitive]: Value on stack does not match expected value!...")` — a *literal-false* assert that always fails when reached, so it throws/halts (depending on `exitOnError`). The recovery right after it (pop stale, push a fresh concrete from `inst.v`, `:3635-3641`) is therefore **dead code** — unreachable past the always-failing assert. -- **String / StringBuilder:** the placeholder/`ADDRESS_UNKNOWN` recovery has the same shape — `SWATAssert.check(inst.val.equals(peek.concrete), "Concrete value of the object does not match...")` (`:1334`) and the StringBuilder variant (`:1296`). - -So an out-of-band change is **a crash** (or, with `exitOnError=false`, a thrown `SymbolicInstructionException`), in every mode — never a recorded, recoverable soundness loss. (The earlier audit's "stale value silently trusted" framing was for paths where no `GETVALUE` re-syncs; where a `GETVALUE` does fire, it currently crashes.) - -## Proposed solution (detect, no havoc, all modes) - -**Convert the hard divergence-assert into a graceful, flagged detection.** At each `GETVALUE` concrete-sync, when the observed concrete diverges from the shadow's concrete: - -1. **Record an out-of-band-change soundness flag** (see flag choice below) — the value's symbolic state is now unsound, so downstream verdicts can be downgraded. -2. **Adopt the observed concrete** — replace the stale shadow value with a fresh **non-symbolic** value built from the observed concrete (`ValueFactory.createNumericalValue(type, inst.v)` / `createObjectValue`), restoring the concrete-grounding invariant. This is the recovery that already sits (dead) after the assert. -3. **Continue** — no crash, no havoc of the wider object graph (v1 is *detect-only*: we flag the one diverged value and re-ground it; we do not invalidate other fields or attempt repair). - -This is **mode-independent**: `GETVALUE` is part of the shadow interpreter and runs under every `solver.mode` (LOCAL/HTTP/PRINT/NONE), so detection fires "in all modes." - -Concretely: at `:3631-3641` replace the `SWATAssert.check(false, …)` with `record-flag` + the (now-live) adopt-observed recovery; do the same at the String/StringBuilder asserts (`:1296`, `:1334`). - -## Flag choice (open — leaning new flag) - -`SymbolicTrace` has `symbolicContextLoss` and `referenceSemanticChange`. An out-of-band change is neither exactly. Proposed: add a dedicated trace flag (e.g. `outOfBandChange`) with getter/recorder mirroring the existing two, surfaced on `TraceDTO`, and downgrade verdicts like context loss does (SAFE→UNKNOWN). VIOLATION needs no downgrade (replay-witnessed, as established in G2). Alternative: reuse `referenceSemanticChange` (avoids new wiring) — but it muddies that flag's meaning. Reviewer to weigh in. - -## Scope / non-goals - -- **Detect-only.** No havoc of the object's other fields, no attempt to re-symbolize. Just flag + re-ground the diverged value. -- **No escape-tracking.** We do not (yet) distinguish "legitimate out-of-band mutation (object escaped to unmodeled code)" from "executor-internal desync bug." That distinction needs escape/leak tracking (G4a territory) and is deferred. - -## The key risk (open question for review) - -The divergence-asserts currently serve **two** purposes: catching legitimate out-of-band changes (which we now want to flag gracefully) **and** catching executor-internal desync **bugs** (which we want to keep catching loudly). Blanket flag-and-continue would **mask real executor bugs** as benign out-of-band changes — a regression in bug-detection sharpness. Options: -- **(a)** Flag-and-continue for all divergences (simplest; matches "detect in all modes"; loses dev-time bug catching). -- **(b)** Config-gated: keep the hard assert under a strict/debug flag (e.g. tie to `exitOnError` or a new `strictShadow`), flag-and-continue otherwise. Preserves dev bug-catching, graceful in real runs. *(Recommended.)* -- **(c)** Only flag-and-continue when the object is known to have escaped (needs escape-tracking → defer). - -## Acceptance tests - -- **E-1 (detection):** a tracked value whose concrete diverges from the observed concrete is flagged (not crashed) and the observed concrete is adopted. Cleanest at **L1** (fabricate the divergence: push a tracked `IntValue` concrete=10, run `GETVALUE_int` with `inst.v=20` → assert the out-of-band flag is set, no exception, value re-grounded to 20). Plus an **L2** variant if a real escape-then-mutate target is constructible (mutating mock), to satisfy "in all modes." -- **E-2 (no false positive):** a pure (no-mutation) call leaves no divergence → no flag. - -## Open questions for review - -- Flag choice (new `outOfBandChange` vs reuse `referenceSemanticChange`) + the verdict-downgrade rule. -- The masking-executor-bugs risk — option (a) vs (b) vs (c); is (b) the right call and what gates it? -- Are the three sync points (primitive, String, StringBuilder) the complete set, or are there other `GETVALUE`/reconcile divergence points (object-ref identity, arrays) that should detect too? -- Is adopting the observed concrete (dropping the stale symbolic value) the right v1 behavior, or should the diverged value become a fresh symbol (havoc)? (Plan says no havoc → concrete; confirm.) -- Clean up the now-dead post-assert recovery code as part of this. - -## Review round 1 + user steering — REVISED design - -Round-1 review (and the user) confirmed the draft's grounding was partly wrong and the existing handling is incomplete — so we do NOT trust it: - -- **Not three uniform sync points.** Only the *primitive* path (`:3631-3641`) is "assert + (dead) adopt-observed recovery." The StringBuilder assert (`:1296`) has NO recovery (falls through pushing the stale value); object-ref divergence (`:1377`) and arrays (no per-element check) are uncovered. ⇒ **v1 scopes to the primitive path only**; StringBuilder/object-ref/arrays are explicit follow-ups (each needs a *constructed* recovery, not un-commenting). -- **`SWATAssert.check(false,…)` is config-dependent, not "crash in all modes."** `useAssertions=false` → logs-and-continues (recovery live); `exitOnError=true` (default) → halt; `exitOnError=false` (tests) → throws. And today the crash / `[SWAT Exception]` log / halt is *exactly what forces the verdict to UNKNOWN* (`SVCompDriver.py` ERROR + `[SWAT Exception]` line handling). So a silent flag MUST replace that downgrade or it's a soundness regression. -- **Verdict downgrade must be SAFE→UNKNOWN.** Reusing `referenceSemanticChange` is UNSOUND (it only downgrades VIOLATION, `SVCompDriver.py:316`). The new `outOfBandChange` flag must downgrade SAFE→UNKNOWN like `symbolic_context_loss` (`:304`). The "reuse" option is dropped. (VIOLATION stays replay-witnessed → no false VIOLATION.) -- Flag wiring is ~10 sites (SymbolicTrace field/recorder/getter → DTOBuilder → TraceDTO positional ctor → explorer DataTransferObjects/ConstraintController/ConstraintService/Database/Tree → SVCompDriver downgrade). Enumerated so it's not discovered mid-impl. - -**User steering — the core design change:** - -1. **Configurable policy** `shadowDivergence` = `DIFFERENTIATED` (default) | `FLAG` | `CRASH`. Not gated on `exitOnError` (a test/unrelated knob); a dedicated config (read live via `Config.instance()`, not a static-init snapshot). -2. **Escape-aware differentiation** (the principled "know when out-of-band is possible"): - - Mark a tracked `ObjectValue` `escaped` when it is passed as receiver/arg to an **unmodeled** method (conservative v1: any unmodeled call; G4a's purity whitelist later refines — a *pure* call can't mutate, so it won't mark escape). - - At a divergence under `DIFFERENTIATED`: if attributable to an **escaped** object → legitimate out-of-band → record `outOfBandChange` + adopt observed concrete + continue; if **not escaped** → genuine executor desync → **crash** (preserve the bug-catching net the assert was for). Only the actually-diverged value is re-grounded (not full havoc; non-diverged fields keep their symbolic value). - - `FLAG` = always graceful (production runs); `CRASH` = always strict (dev/CI). - -## Round-2 resolution (converged, pending scope decision) - -Reviewer verified the revised design; key correction + the landable cut: - -- **Correction:** `GETVALUE_primitive` is injected after EVERY value-producing opcode (ILOAD/ARRAYLENGTH/field-read/return), not just GETFIELD. So at the `:3631` divergence there is often **no container** (e.g. a stale local copied out of an escaped object, then ILOAD'd). The escape-aware bridge only covers the **direct field-read-of-escaped-object** case; ILOAD/array/transitive cases fall to the `not-escaped → crash` path. ⇒ the v1 escape set is an **under-approximation** (the *unsafe* direction for differentiation: a legitimate-but-unmarked out-of-band change crashes). `shadowDivergence=FLAG` is the fully-sound production escape hatch (every divergence → flag → SAFE→UNKNOWN). -- **Plumbing:** option (i) thread-local, set in `visitGETFIELD` when `ref.escaped`, **cleared at the start of every `visitGETVALUE_primitive`** (mandatory — else the bit leaks onto a later non-GETFIELD divergence and masks a real executor desync), read at the `:3631` branch. Reject (iii) (partial havoc, breaks V-3 on escaped-unmutated fields). -- **Marking site:** `InvocationHandler.invoke`, inside the existing `retValue instanceof PlaceHolder && !IGNORED` unmodeled block, after `arguments.add(0, instance)` (receiver+args unified); `instanceof ObjectValue` filter; at minimum mark array elements. Conservative "mark on all unmodeled calls" is sound for the flag direction (over-flag → more UNKNOWN, never false SAFE). Transitive closure + purity-refinement deferred to G4a. -- **Production policy:** until G4a makes the escape set faithful, SV-COMP/production should default to **FLAG** (fully sound); DIFFERENTIATED is for dev/CI (catches executor desync, gracefully handles the clean GETFIELD-escaped case). - -**Scope decision (for the user):** the escape-aware differentiation is landable now but partial (above); the faithful version rides with G4a, and production runs FLAG either way. So: (A) land configurable policy + new flag/SAFE-downgrade + shallow escape-aware differentiation now; or (B) land just the configurable policy (CRASH/FLAG) + flag/SAFE-downgrade now, and land escape-aware faithfully with G4a. - ---- - -### (superseded) Open for round-2 review: plumbing the container's `escaped` status to the primitive `GETVALUE` divergence point. The divergence is detected in `visitGETVALUE_primitive`, but `escaped` lives on the *container*, known at the immediately-preceding `GETFIELD` (which pops `ref`). Options: (i) a thread-local "last field-read was from an escaped container" set by `visitGETFIELD`, read+cleared by the next `GETVALUE`; (ii) tag the pushed field value transiently; (iii) `visitGETFIELD`, when `ref.escaped`, pushes a placeholder so `GETVALUE` re-grounds (but that re-grounds even unmutated fields ⇒ partial havoc). Reviewer to assess feasibility + pick the cleanest, and to sanity-check the escape-marking site (which invoke handling, and that it covers receiver + ref args). diff --git a/docs/heap-redesign-tests.md b/docs/heap-redesign-tests.md deleted file mode 100644 index e026368..0000000 --- a/docs/heap-redesign-tests.md +++ /dev/null @@ -1,79 +0,0 @@ -# Shadow Heap Redesign — Test Specification (behavioral) - -Harness-agnostic. Each case is **a scenario + the property that must hold** — *what* we want to verify, not *how* to observe or run it. Setting up the harness/levels for these is a separate task. - -Notation: a *symbolic value* carries a formula over program inputs; a *concrete value* is the observed runtime value; "shadow object" = the symbolic model of a reference object; "flag" = a soundness flag (context-loss / reference-semantic-change); "unmodeled call" = a method SWAT has no model for; "boundary" = an instrumented call site invoking unmodeled code; "re-entry" = a reference returning from unmodeled code. v1/v2 mark staged expectations. - -**Policy — model only what a test needs.** The default for an unmodeled method is concrete + context-loss flag (V-1/V-8); we simply *document it as missing*. We model a method only when a test needs its deeper semantics — those go on an explicit **methods-to-model** list (in the plan doc). `new String(String)` (V-5) is the first committed entry. Most methods will stay unmodeled, and that is fine and expected. - ---- - -## G1 — object identity / canonical heap (stateful objects) - -- **O-1 Aliasing through locals/fields.** *Scenario:* one object reachable via two references; a field written through one. *Expect:* the write is visible through the other; exactly one shadow object represents the concrete object. -- **O-2 Re-entry recovers the same object.** *Scenario:* a tracked object passes into unmodeled code and the same concrete object returns. *Expect:* the recovered shadow object is the same instance (its field state preserved), not a fresh empty duplicate. -- **O-3 Object born in unmodeled code.** *Scenario:* unmodeled code returns a never-before-seen object. *Expect:* a fresh shadow object with unknown (not fabricated) fields; subsequent sightings of that same concrete object recover the same shadow object. -- **O-4 Reference-equality correctness.** *Scenario:* `==` between (i) two references to one object, (ii) two references to distinct objects — for objects born locally and objects re-entered from unmodeled code. *Expect:* (i) true, (ii) false, in all entry combinations. -- **O-5 Distinct objects sharing an identity hash.** *Scenario:* two distinct live objects with a colliding identity hash. *Expect:* distinct shadow objects, no field cross-contamination, `==` between them false. -- **O-6 Identity reuse after death.** *Scenario:* a tracked object becomes unreachable; a new object later reuses its identity hash. *Expect:* the new object does not recover the dead object's shadow. - -## G2 — value-typed recovery (String + boxed wrappers + primitives) - -- **V-1 `this`-return must not alias receiver (the core bug).** *Scenario:* symbolic string `s`, concretely already-lowercase, receiver of an unmodeled `toLowerCase()` that returns `this`. *Expect (v1):* the result does not carry `s`'s symbolic value (it does not depend on `s`); it is concrete; a context-loss flag is raised; its concrete value equals the real result. *(v2: may be a UF of `s` if whitelisted — see U-5.)* -- **V-2 New-object transform is consistent with V-1.** *Scenario:* same as V-1 but `s` is concretely upper-case, so the call returns a *new* object. *Expect (v1):* identical outcome to V-1 (concrete + flag, no aliasing). The this-return vs new-object distinction must not change the symbolic result. -- **V-3 Single-formula round-trip preserved.** *Scenario:* symbolic string `s` stored into unmodeled space and retrieved unchanged, only ever associated with one formula. *Expect:* recovery yields `s`'s formula (precision kept), not a collapse to concrete. -- **V-4 Conflicting formulas collapse to concrete.** *Scenario:* one identity becomes associated with two or more distinct formulas (aliased φs). *Expect:* the multiplicity is *realized/recorded* (the φs are retained, per the data model), but recovery sticks to concrete + flag — we do **not** branch over the formulas. (Branching is a possible future direction, explicitly out of scope now.) -- **V-5 Program-made copy keeps the value (committed model).** *Scenario:* `t = new String(s)` for symbolic `s`. *Expect:* `t` carries `s`'s formula (a content copy is the same value). The copy constructor is one we **commit to modeling** → it goes on the methods-to-model list. (Contrast with the general policy above: most unmodeled methods are documented-as-missing, not modeled.) -- **V-6 Interned-literal reuse.** *Scenario:* the same literal used at several sites. *Expect:* every occurrence carries the same constant formula; no spurious aliasing or conflict. -- **V-7 Boxed-primitive analogue.** *Scenario:* symbolic `Integer`/`Long`; unmodeled method on it, and reuse of an autobox-cached instance. *Expect:* same rules as strings — the box cache / a this-return never transfers a wrong formula. -- **V-8 Primitive context loss.** *Scenario:* unmodeled method returns an `int` derived from a symbolic input. *Expect (v1):* concrete + flag — neither falsely symbolic nor falsely treated as independent. -- **V-9 Concrete grounding (universal).** *Scenario:* any recovery, any case above. *Expect:* the recovered value's concrete component always equals the real runtime value. - -## G_oob — out-of-band change detection (no havoc in v1) - -- **E-1 Detect out-of-band mutation.** *Scenario:* a tracked mutable object is passed to unmodeled code that mutates one of its fields; the field is then read in instrumented code. *Expect (v1):* the system **detects** the divergence (the observed concrete value no longer matches the shadow's stored value) and flags it — **in all execution modes**. It does not silently trust the stale shadow. We detect, not havoc or repair. -- **E-2 Pure call triggers no false detection.** *Scenario:* a tracked object passed to a pure (whitelisted) unmodeled method (no mutation). *Expect:* no out-of-band change is detected; no false flag. - -## G4a — context-loss / leak flagging precision - -- **F-1 Clean state, no symbolic input → no flag.** *Scenario:* unmodeled call with no symbolic receiver/args, nothing symbolic previously leaked. *Expect:* no context-loss flag (the result is genuinely concrete). -- **F-2 Symbolic input → flag.** *Scenario:* unmodeled call with a symbolic argument whose result is used. *Expect:* context-loss flagged at the point the lost value is used. -- **F-3 Leak then retrieve.** *Scenario:* a symbolic value passed into unmodeled space via a call with no usable result (leak); later a different unmodeled call retrieves a value from that space. *Expect:* no flag at the leak; flag at the retrieval. If no retrieval ever occurs, no flag at all. -- **F-4 Pure symbolic call → no leak.** *Scenario:* a pure whitelisted call with a symbolic argument. *Expect:* no contamination is armed; unrelated later retrievals are not flagged on its account. - -## G3 — output-boundary de-interning - -- **D-1 Fresh identity on produced values.** *Scenario:* an unmodeled value-returning call (or literal) at an instrumented site. *Expect:* the produced value has a fresh identity distinct from the inputs, so a this-return cannot alias the receiver there; its candidate set stays a singleton. -- **D-2 De-interning is verdict-neutral.** *Scenario:* a program run with and without output de-interning. *Expect:* the same verdict (de-interning never changes the soundness of the result). -- **D-3 Reference-semantic-change only when warranted.** *Scenario:* `==` on de-interned values. *Expect:* the reference-semantic-change flag fires only when de-interned `==` actually diverges from real reference semantics, not otherwise. - -## G4b — uninterpreted-function modeling + soundness - -- **U-1 Agreement.** *Scenario:* a UF-modeled method on sampled concrete inputs in its axiomatized domain. *Expect:* the UF result equals the real method's result for every sample. -- **U-2 No over-constraint.** *Scenario:* any input the real method admits. *Expect:* the path condition plus the UF's axioms stays satisfiable for it — the UF never excludes a real behavior. -- **U-3 Partiality outside the domain.** *Scenario:* an input outside the axiomatized domain (e.g. non-ASCII for case folding). *Expect:* the UF is unconstrained there — a model may choose a value differing from the real one (over-approximation, not a false pin). -- **U-4 Relational consistency.** *Scenario:* the same method applied twice to equal symbolic inputs. *Expect:* equal results (determinism captured) — the property concrete recovery would lose. -- **U-5 Modeled-result precision.** *Scenario:* a whitelisted method with a symbolic input. *Expect:* the result depends symbolically on that input (carries the UF), not collapsed to concrete. - -## Whole-program regression - -- **R-1 Original failing case.** *Scenario:* the `toLowerCase` securibench valid-assert case. *Expect:* correct verdict with context loss flagged — never a confident wrong verdict. -- **R-2 No regressions.** *Scenario:* a set of golden whole-program cases. *Expect:* verdicts unchanged by the redesign. - ---- - -## Coverage map (case → goal/phase) - -| Goal / phase | Cases | -|---|---| -| G1 canonical heap | O-1 … O-6 | -| G2 value recovery | V-1 … V-9 | -| G_escape | E-1, E-2 | -| G4a flag policy | F-1 … F-4 | -| G3 de-interning | D-1 … D-3 | -| G4b UF + soundness | U-1 … U-5 | -| regression | R-1, R-2 | - -Resolved: **V-5** — model the copy constructor (added to the methods-to-model list); general policy = document-as-missing for the rest. **E-1** — detect out-of-band changes (no havoc), in all modes. **V-4** — realize/record the multiple φs but recover concrete; no branching. - -Living artifact: the **methods-to-model list** (plan doc) grows only as deeper tests demand specific semantics; the default stays document-as-missing. diff --git a/docs/test-architecture.md b/docs/test-architecture.md index f1ffa71..44be897 100644 --- a/docs/test-architecture.md +++ b/docs/test-architecture.md @@ -2,9 +2,8 @@ A reusable test architecture for the `symbolic-executor` module. It defines **four abstraction levels**, the **fixtures/seams** each needs, and the **oracle rules** that keep -tests robust against representation churn. Written as a foundation (not heap-redesign-specific); -the shadow-heap redesign is its first consumer (see the per-case map at the end and -[`heap-redesign-tests.md`](heap-redesign-tests.md)). +tests robust against representation churn. The heap and value-tracking tests are its primary +consumer; see [`heap-tracking.md`](heap-tracking.md) for that subsystem's design. ## Why levels (the core insight) @@ -15,17 +14,17 @@ altitude per case gives fast, non-flaky tests; picking too low makes the test ci makes it slow and coarse. ``` -L0 value & shadow-structure unit in-JVM, no instrumentation fast, CI -L1 shadow-interpreter / processor in-JVM, synthetic instructions fast, CI -L2 real-instrumentation single-run forked JVM, real agent slower, opt-in CI -L3 end-to-end / verdict agent + Python explorer nightly / manual +L0 value & shadow-structure unit in-JVM, no instrumentation fast, CI +L1 shadow-interpreter / processor in-JVM, synthetic instructions fast, CI +L2 real-instrumentation single-run forked JVM, real agent slower, opt-in CI +L3 end-to-end / verdict agent + Python explorer nightly / manual ``` ## Oracle rules (apply at every level) -The legacy `de/uzl/its/value/**` suite (~1670 rows) is brittle because it asserts on -`.formula` via sort-specific managers (`bvmgr.equal`), so it breaks en masse on representation -changes. **Never do that.** Assert only on: +The legacy `de/uzl/its/value/**` suite is brittle because it asserts on `.formula` via +sort-specific managers (`bvmgr.equal`), so it breaks en masse on representation changes. +**Never do that.** Assert only on: - `Value.concrete` (the observed runtime value); - `Value.isSymbolic()`; @@ -38,18 +37,18 @@ changes. **Never do that.** Assert only on: **SAT/UNSAT agreement** with the concrete result — never inspect the formula's sort. **Expected-red mechanism (Spock `@PendingFeature`).** This Spock version (2.2-M1-groovy-4.0) has -**no `spock.lang.Tag`**. Mark each red case `@PendingFeature(reason = " not yet -implemented; …")`: the feature runs and asserts the *desired* behavior, is reported as **pending -(skipped), not a failure**, while red — and **forces a build failure the moment it starts -passing** (the unambiguous "fix landed, remove the annotation" signal). Level/phase/case live in -the package + class name + `@See("docs/heap-redesign-tests.md")`. +**no `spock.lang.Tag`**. Mark each red case +`@PendingFeature(reason = " not yet implemented; …")`: the feature runs and asserts the +*desired* behavior, is reported as **pending (skipped), not a failure** while red — and **forces a +build failure the moment it starts passing** (the unambiguous "fix landed, remove the annotation" +signal). The test level is conveyed by the package and class name. **An expected-red test MUST fail on a specific assertion** (e.g. "result vars must not contain `S_x`"), never on a setup exception — otherwise `@PendingFeature` would mask infra breakage as "pending". Enforce this with the **precondition-first / guard-feature convention**: assert -currently-true preconditions first (or as a *separate non-pending* feature, e.g. O-4's "distinct -objects compare unequal"), so an infra break surfaces as a real failure while the pending feature -isolates only the not-yet-implemented behavior. +currently-true preconditions first (or as a *separate non-pending* feature, e.g. "distinct objects +compare unequal"), so an infra break surfaces as a real failure while the pending feature isolates +only the not-yet-implemented behavior. --- @@ -61,9 +60,11 @@ no instruction stream — construct objects and call methods directly. **Tests well.** Value semantics: `IF_ACMPEQ`/`equals`, `invokeMethod`/`invokeInit` (e.g. the `new String(String)` copy ctor, `StringValue.java:217`), `MAKE_SYMBOLIC`, heap `put/get` and key -behavior, UF-defining-constraint SMT agreement. +behavior, UF-defining-constraint SMT agreement. For example, construct two distinct objects with a +colliding identity hash and assert they compare unequal, and assert that a single identity yields a +single wrapper. -**Driver.** A shared `BaseValueSpec` (extract from the existing `StringValueTest.setup`): +**Driver.** A shared `BaseValueSpec`: `ThreadHandler.init()` → `addThreadContext(currentThread().id, "Test-Thread", -2)` → `getSolverContext` → expose `fmgr/bmgr/smgr/...` and a fresh `ProverEnvironment`; `cleanup` closes the prover/context and removes the thread context. For UF agreement, pull @@ -71,9 +72,7 @@ UF-defining constraints from the trace (`ThreadHandler.getSymbolicTraceHandler(id).getConstraints()`) into the prover — see `StringValueTest.addUFConstraintsFromTrace`. -**Oracle.** Per the rules above. Exemplar already in repo: `StringValueTest.groovy`. - -**Heap cases here:** O-4, O-5, D-3, V-5, V-6, V-9, the "one wrapper per identity" invariant. +**Oracle.** Per the rules above. Exemplar in repo: `StringValueTest.groovy`. --- @@ -87,10 +86,10 @@ locals / frame / heap / trace evolve. symbolic input, the **invoke → recovery** path (`INVOKEVIRTUAL`(unmodeled) → `INVOKEMETHOD_END` → `GETVALUE_Object`), branches, field/array ops. -**Driver.** `BaseSymbolicInstructionProcessorSpec` (existing) provides `setupTestContext` -(unique method names per test — required), the `push*Operand` helpers, and `executeLiftInsnSeq` -(introduce a symbolic input). **New fixture to add — the boundary-recovery fixture** (highest -leverage; shared by V-1/V-2/V-3/V-4/O-2/O-3/F-3): +**Driver.** `BaseSymbolicInstructionProcessorSpec` provides `setupTestContext` (unique method +names per test — required), the `push*Operand` helpers, and `executeLiftInsnSeq` +(introduce a symbolic input). The **boundary-recovery fixture** has the highest leverage and is +shared by all recovery cases: ``` executeBoundaryRecovery(receiver, owner, name, desc, concreteResult, resultAddress) @@ -109,8 +108,8 @@ identity-keyed heap — the aliasing defect. **Honest caveat.** L1 *fabricates* the instruction stream, including the `resultAddress == receiver.address` collision that a real `this`-return produces. So an L1 recovery test pins the **interpreter's recovery logic**, not the instrumentation→processor -contract, and bakes in our assumption of the emitted sequence. Therefore: pair the flagship -recovery case (V-1) with an **L2 anchor** that gets the collision from the real JVM. +contract, and bakes in an assumption about the emitted sequence. Therefore: pair a flagship +recovery case with an **L2 anchor** that gets the collision from the real JVM. **Seams required (see Cross-cutting).** Heap-inspection view on `ShadowContext`; flag getters on `SymbolicTraceHandler`. @@ -118,8 +117,6 @@ recovery case (V-1) with an **L2 anchor** that gets the collision from the real **Oracle.** L0 rules + `Frame`/heap-view/flags. Exemplars: `InternalInvocationSpec`, `INVOKEVIRTUALSpec`, `IADDSpec`. -**Heap cases here:** V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1. - --- ## L2 — Real-Instrumentation / Single-Run (forked JVM, structured TraceDTO) @@ -128,16 +125,15 @@ recovery case (V-1) with an **L2 anchor** that gets the collision from the real program; identity hashes, the `this`-return, the GETVALUE sequence, and the soundness flags all come from the real JVM + instrumenter — nothing is hand-fabricated. -**Key enabler (verified).** `solver.mode=PRINT` makes `Intrinsics.terminate()` do +**Key enabler.** `solver.mode=PRINT` makes `Intrinsics.terminate()` do `System.out.println(getTraceDTO())` (`Intrinsics.java`, terminate() PRINT branch) — the full `TraceDTO` JSON on stdout, **no Python explorer needed**. `LOCAL` mode (default) instead solves in-JVM with Z3 via `LocalSolver.solve()`. `SolverMode = {LOCAL, HTTP, PRINT, NONE}`. -**Driver — new `AgentRunFixture`:** +**Driver — `AgentRunFixture`:** 1. Build the agent jar first: `./gradlew :symbolic-executor:copyJar` - → `symbolic-executor/lib/symbolic-executor.jar` (never `spotlessApply` — see - [the spotless note](#); build with `copyJar`). + → `symbolic-executor/lib/symbolic-executor.jar` (build with `copyJar`, never `spotlessApply`). 2. Target programs live in `src/test/resources/targets/.java` (tiny, hand-written). Designate symbolic inputs with `Verifier.nondetInt(long id)` / `nondetString(id)` / … (SV_COMP transformer) — e.g. `String s = Verifier.nondetString(0);`. @@ -154,75 +150,50 @@ process-global singletons set at startup; resetting them mid-JVM is hacky and fr JVM gives clean isolation per case. **Oracle (structured, no log-scrape).** TraceDTO fields + which input variables appear in branch -constraints. E.g. **V-1/R-1**: plant `if (r.equals("abc"))` on the `toLowerCase` result and assert -`symbolicContextLoss == true` **and** the branch over `r` does **not** reference the symbolic -input variable (so no confident wrong SAFE can be derived). +constraints. For example, plant `if (r.equals("abc"))` on an unmodeled `toLowerCase` result and +assert `symbolicContextLoss == true` **and** that the branch over `r` does **not** reference the +symbolic input variable (so no confident wrong SAFE can be derived). **CI.** Slower + needs the jar; gate behind a separate Gradle task (`agentTest`), not the default -`test`. Tag `level-2`. - -**Heap cases here:** R-1 (flagship anchor), E-1 "all modes", plus an L2 mirror of V-1. +`test`. --- ## L3 — End-to-End / Verdict (explorer in the loop) **Scope.** Full pipeline: agent (`HTTP` mode) + Python explorer + iterative path exploration + -final SV-COMP verdict, including the downgrade rules (SAFE+contextLoss→UNKNOWN, etc. — see -[`heap-redesign` soundness notes]). Tests the *verdict*, not just the trace. +final SV-COMP verdict, including the downgrade rules (SAFE+contextLoss→UNKNOWN, etc.). Tests the +*verdict*, not just the trace. -**Driver.** The existing sv-comp driver / `targets/` harness. Structured per-testcase observation -(`{verdict, contextLoss}`) becomes clean once the **`stats.json`** -work lands (branch `feat/svcomp-testcase-metadata`, PR #27) — until then it is STDOUT/log -scraping (`[VERDICT ]`). +**Driver.** The sv-comp driver / `targets/` harness. Structured per-testcase observation +(`{verdict, contextLoss}`) becomes clean once the consolidated `stats.json` work lands — until +then it is STDOUT/log scraping (`[VERDICT ]`). **CI.** Not gated; nightly / manual regression lane. -**Heap cases here:** R-1, R-2, D-2, E-1 "all modes". - --- -## Cross-cutting infrastructure to build +## Cross-cutting infrastructure -Listed in dependency order (each is a durable seam, reused well beyond the heap redesign): +The durable seams that back the levels, reused well beyond any single subsystem: -1. **Flag-observation seam.** Add getters to `SymbolicTraceHandler`: +1. **Flag-observation seam.** Getters on `SymbolicTraceHandler`: `isSymbolicContextLoss()`, `isReferenceSemanticChange()` (the `SymbolicTrace` fields are - package-private; `processor`-package specs can't see them). Trivial, used by L1 + every flag + package-private; `processor`-package specs can't see them). Used by L1 + every flag assertion. -2. **Heap/registry inspection view.** Put a small read-only view on `ShadowContext` (every spec +2. **Heap/registry inspection view.** A small read-only view on `ShadowContext` (every spec already gets it via `visitor.getStack()`, so no new wiring): `int heapSize()`, `int heapDistinctIdentities()`, `Value heapLookup(long id)`, - `Collection> heapEntries()`. Back it with the legacy `JVMHeap` **now** so O-tests - compile and run as real reds today (O-5: two distinct objects with a colliding hash → - `heapDistinctIdentities()` should be 2, legacy returns 1). Phase 1's canonical registry - implements the **same view** — assertions don't change, they flip red→green. (Chosen over raw - `JVMHeap` getters, which couple tests to a structure we're deleting, and over future-API-only - tests, which wouldn't compile and so give no running red signal.) + `Collection> heapEntries()`. Assertions phrased against this view don't change as + the backing structure evolves; they flip red→green as the behavior is fixed (e.g. two distinct + objects with a colliding hash → `heapDistinctIdentities()` should be 2). Chosen over raw + `JVMHeap` getters, which would couple tests to a structure under active change, and over + future-API-only tests, which wouldn't compile and so give no running red signal. 3. **L1 boundary-recovery fixture** (see L1). Highest leverage; shared by all recovery cases. -4. **L0 `BaseValueSpec`** — extract the `StringValueTest` setup so all value-semantics specs reuse - a prover + solver context. +4. **L0 `BaseValueSpec`** — a shared prover + solver context for all value-semantics specs. 5. **L2 `AgentRunFixture` + `TraceObservation`** (see L2). Forked-JVM + PRINT-mode + JSON parse. -## Suggested build order - -1. Seams (1) + (2) + base specs (4) — unblocks everything, low risk. -2. L1 boundary-recovery fixture (3); land **V-1 as the first concrete `expected-red`** and **O-4** - (`this == o2` wrapper bug, already red). -3. Fan out the L0/L1 case matrix (tagged by phase). -4. L2 `AgentRunFixture`; add the V-1/R-1 L2 anchor. -5. L3 lane after the `stats.json` branch merges. - -## Per-case altitude map (heap redesign) - -| Level | Cases | -|---|---| -| L0 value/structure unit | O-4, O-5, D-3, V-5, V-6, V-9, "one wrapper / identity" | -| L1 processor (workhorse) | V-1, V-2, V-3, V-4, V-7, V-8, O-1, O-2, O-3, O-6, F-1..F-4, U-5, D-1 | -| L2 real-agent single-run | R-1 (anchor), V-1 (mirror), E-1 "all modes" | -| L3 end-to-end verdict | R-1, R-2, D-2, E-1 "all modes" | +## Framework -Framework: **Spock for L0/L1/L2-harness** (the suite is already Spock; `where:` tables fit the -case matrix). L3 stays in the Python sv-comp harness. - - +**Spock for L0/L1/L2-harness** (the suite is already Spock; `where:` tables fit tabular case sets +well). L3 stays in the Python sv-comp harness. From 710f08dd8ec48891b88983977699ce2d6b55d219 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 14:25:04 +0000 Subject: [PATCH 26/33] docs(heap): correct reference-equality description in heap-tracking.md refEquals routes through value equality when EITHER operand is de-interned (not both), and a user-constructed new String/new Integer is never rooted, so it compares unequal to any other instance (matching the real JVM). Replaces the stale residual note that described the earlier value-equality implementation. Co-Authored-By: Claude Opus 4.8 --- docs/heap-tracking.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/heap-tracking.md b/docs/heap-tracking.md index a5b3a70..b335261 100644 --- a/docs/heap-tracking.md +++ b/docs/heap-tracking.md @@ -165,8 +165,8 @@ De-interning would then break `==`, so each fresh copy records the genuine origi canonical)` (stores the fully-resolved root, keeping chains depth-1); `root(x)` returns the canonical or `x` itself. - `common/UtilInstrumented.java` — `refEquals`: uses value equality - (`Provenance.root(a) == Provenance.root(b)`) when both operands are de-interned value types, else - plain `a == b`. + (`Provenance.root(a) == Provenance.root(b)`) when **either** operand is a de-interned value type + (the other operand's `root()` is itself), else plain `a == b`. - `common/Util.java` — `shouldUseValueEquality`, `isDeInternedClass`, and the `deInternedClasses` set: **String + Boolean/Byte/Short/Character/Integer/Long**. Float and Double are **not** de-interned (they are uncached in real Java, so plain reference equality is already correct). @@ -185,9 +185,10 @@ De-interning would then break `==`, so each fresh copy records the genuine origi - `==` on de-interned value types matches real-Java `==`. - Float/Double keep plain reference equality. -**Known residual approximation** — `new String("x") == new String("x")` is modeled as `true` while -the real JVM yields `false`, and this is not flagged. It is a deliberate limitation of routing `==` -through value equality. +**User-constructed value types compare correctly.** A `new String("x")` (or `new Integer(...)`) is +never given a provenance root — only literals, boxed `valueOf`, and returns from un-instrumented code +are — so it roots to itself and compares unequal to any other instance, matching the real JVM (e.g. +`new String("x") == new String("x")` is `false`, and `new String("x") == "x"` is `false`). ### 6. Pure-function model — keeping precision through side-effect-free calls From d91799633e7d705b5c21f2da234ac9eea59588de Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 14:25:04 +0000 Subject: [PATCH 27/33] feat(heap): recover immutable value-typed returns from unmodeled containers When an unmodeled instance method returns a distinct, already-tracked immutable value (e.g. a String or boxed primitive retrieved from a Map/List), recover its registered shadow instead of concretizing, so the value keeps its symbolic formula and the solver can still reason about it. Restricted to the closed set of immutable value types (String + the eight boxed wrappers) since a mutable Number could have drifted, and gated on the return being distinct from the receiver's own shadow so a method returning `this` (e.g. toLowerCase) still concretizes. The receiver is carried on the UNMODELED_RETURN placeholder for the distinctness check; a defensive concrete-equality check falls back to concretize on mismatch. Recovery fires only for values already registered in the heap. Co-Authored-By: Claude Opus 4.8 --- .../java/de/uzl/its/swat/common/Util.java | 22 ++++++++++++++ .../symbolic/SymbolicInstructionVisitor.java | 13 ++++++++ .../symbolic/invoke/InvocationHandler.java | 1 + .../its/swat/symbolic/value/PlaceHolder.java | 13 ++++---- .../heap/ContainerRecoveryAgentSpec.groovy | 26 ++++++++++++++++ .../symbolic/heap/ValueRecoverySpec.groovy | 30 +++++++++++++++++++ .../targets/ContainerRecoveryTarget.java | 28 +++++++++++++++++ 7 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ContainerRecoveryAgentSpec.groovy create mode 100644 symbolic-executor/src/test/resources/targets/ContainerRecoveryTarget.java diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java index 7fdca9f..74e07e3 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/common/Util.java @@ -456,4 +456,26 @@ public static boolean isValueType(Object o) { || o instanceof Character; } + /** + * The closed set of genuinely-immutable value types whose stored shadow recovery may safely + * reuse: String and the eight boxed primitive wrappers. Narrower than {@link #isValueType}, which + * admits mutable {@link Number} subtypes ({@code AtomicInteger}, {@code LongAdder}, ...) — reusing + * a stored shadow for those could be stale. An immutable value cannot have drifted since it was + * registered, so reusing its shadow is sound. + * + * @param o the concrete object (may be null) + * @return true if {@code o} is a String or a boxed primitive wrapper + */ + public static boolean isImmutableValueType(Object o) { + return o instanceof String + || o instanceof Integer + || o instanceof Long + || o instanceof Short + || o instanceof Byte + || o instanceof Character + || o instanceof Boolean + || o instanceof Float + || o instanceof Double; + } + } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index 3ee59e6..c367b7c 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -1294,6 +1294,19 @@ public void visitGETVALUE_Object(GETVALUE_Object inst) throws SymbolicInstruc symbolicTraceHandler.addConstraint( smgr.equal((StringFormula) placeHolder.observedApplication, smgr.makeString(s))); } + } else if (Util.isImmutableValueType(inst.val) + && placeHolder.referenceValue != null + && (tmp = stack.getFromHeap(inst.val)) != null + && tmp != placeHolder.referenceValue + && inst.val.equals(tmp.getConcrete())) { + // The unmodeled method returned a distinct, already-tracked immutable value + // (e.g. a String or boxed primitive retrieved from a container), not the + // receiver. Recover its shadow so the value keeps its symbolic formula instead + // of being concretized. An immutable value cannot have drifted since it was + // registered, so the stored shadow is still exact (the concrete-equality check + // is a defensive guard). A method returning `this` (e.g. toLowerCase) is + // excluded by `tmp != referenceValue` and still concretizes. + shadowStateLogger.info("Recovered distinct registered shadow for unmodeled value-typed result: {}", tmp); } else { tmp = ValueFactory.createObjectValue(inst.val, inst.address); shadowStateLogger.info("Concretized unmodeled value-typed result (no identity recovery): {}", tmp); diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 41fac64..783f7e5 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -154,6 +154,7 @@ public class InvocationHandler { if (retValue == PlaceHolder.instance) { retValue = new PlaceHolder( PlaceHolder.ValueOrigin.UNMODELED_RETURN, + isInstance ? instance : null, pureUF == null ? null : pureUF.result(), pureUF == null ? null : pureUF.observedApplication()); } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java index 76886f0..b1dc274 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/value/PlaceHolder.java @@ -90,16 +90,19 @@ public PlaceHolder(boolean isSymbolic, ValueOrigin origin) { } /** - * UNMODELED_RETURN placeholder carrying, for a whitelisted pure method: the generic UF over - * the symbolic inputs ({@code recoveredFormula}, modeling the result) and the same UF over the - * constant observed inputs ({@code observedApplication}, used to record the observed pair). Either + * UNMODELED_RETURN placeholder. {@code referenceValue} is the receiver of the unmodeled call + * (null for static calls); recovery uses it to tell a returned distinct tracked value from a + * this-return. For a whitelisted pure method it also carries the generic UF over the symbolic + * inputs ({@code recoveredFormula}, modeling the result) and the same UF over the constant + * observed inputs ({@code observedApplication}, used to record the observed pair). Any of these * may be null. */ - public PlaceHolder(ValueOrigin origin, Formula recoveredFormula, Formula observedApplication) { + public PlaceHolder(ValueOrigin origin, ObjectValue referenceValue, + Formula recoveredFormula, Formula observedApplication) { this.origin = origin; this.isSymbolic = false; this.inst = null; - this.referenceValue = null; + this.referenceValue = referenceValue; this.recoveredFormula = recoveredFormula; this.observedApplication = observedApplication; } diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ContainerRecoveryAgentSpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ContainerRecoveryAgentSpec.groovy new file mode 100644 index 0000000..f9b1800 --- /dev/null +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ContainerRecoveryAgentSpec.groovy @@ -0,0 +1,26 @@ +package de.uzl.its.swat.symbolic.heap + +import de.uzl.its.swat.testsupport.agent.AgentRun +import de.uzl.its.swat.testsupport.agent.TraceObservation +import spock.lang.Specification + +/** + * End-to-end check that a value retrieved from an unmodeled container keeps its symbolic value. The + * agent stores a symbolic String in a HashMap and reads it back via a concrete key; recovering the + * stored shadow (rather than concretizing) means a branch on the retrieved value still references the + * symbolic input, so the solver can drive it. + */ +class ContainerRecoveryAgentSpec extends Specification { + + def "a value retrieved from an unmodeled container keeps its symbolic formula"() { + when: + TraceObservation obs = AgentRun.run("targets/ContainerRecoveryTarget.java", "ContainerRecoveryTarget") + String inputVar = obs.inputNames.find { it.startsWith("java/lang/String") } + + then: "the symbolic input is designated" + inputVar != null + + and: "the branch on the retrieved value references the symbolic input (recovered, not concretized)" + obs.anyBranchReferences(inputVar) + } +} diff --git a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy index b2ab5b1..68c923a 100644 --- a/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy +++ b/symbolic-executor/src/test/groovy/de/uzl/its/swat/symbolic/heap/ValueRecoverySpec.groovy @@ -3,6 +3,7 @@ package de.uzl.its.swat.symbolic.heap import de.uzl.its.swat.common.Util import de.uzl.its.swat.symbolic.processor.BaseSymbolicInstructionProcessorSpec import de.uzl.its.swat.symbolic.value.reference.lang.StringValue +import de.uzl.its.swat.thread.ThreadHandler import org.sosy_lab.java_smt.api.Formula /** @@ -61,4 +62,33 @@ class ValueRecoverySpec extends BaseSymbolicInstructionProcessorSpec { result.recovered.concrete == "abc" varsOf(result.recovered).disjoint(receiverVars) } + + def "an unmodeled call returning a distinct tracked value recovers that value's shadow"() { + given: "a symbolic receiver, and a DISTINCT symbolic value registered on the heap under its own concrete" + setupTestContext(Util.formatClassName("de.uzl.its.swat.test.TestClass"), "main") + StringValue receiver = new StringValue(solverContext, "receiver", 0x1000) + receiver.MAKE_SYMBOLIC() + def receiverVars = varsOf(receiver) + + String storedConcrete = "stored" + StringValue stored = new StringValue(solverContext, storedConcrete, 0x2000) + stored.MAKE_SYMBOLIC() + def storedVars = varsOf(stored) + ThreadHandler.getSymbolicVisitor(threadId).getStack().putToHeap(storedConcrete, stored) + + and: "receiver and stored carry different symbolic variables" + assert !receiverVars.isEmpty() && !storedVars.isEmpty() + assert receiverVars.disjoint(storedVars) + + when: "an unmodeled instance call returns the distinct stored object, not the receiver" + // The fixture fabricates the returned identity (see its NOTE); a real example is map.get(k). + def result = executeBoundaryRecovery(receiver, STRING, "toLowerCase", TO_LOWER, storedConcrete) + + then: "the stored value's shadow is recovered - it keeps its own symbolic formula" + varsOf(result.recovered) == storedVars + + and: "so the result is neither concretized nor aliased to the receiver" + !varsOf(result.recovered).isEmpty() + varsOf(result.recovered).disjoint(receiverVars) + } } diff --git a/symbolic-executor/src/test/resources/targets/ContainerRecoveryTarget.java b/symbolic-executor/src/test/resources/targets/ContainerRecoveryTarget.java new file mode 100644 index 0000000..d59e6d1 --- /dev/null +++ b/symbolic-executor/src/test/resources/targets/ContainerRecoveryTarget.java @@ -0,0 +1,28 @@ +import de.uzl.its.swat.annotations.Symbolic; +import java.util.HashMap; + +/** + * Stores a symbolic String in an unmodeled container (HashMap) and retrieves it via a concrete key, + * then branches on the retrieved value. The stored value is first derived via a String operation, so + * it is a tracked (registered) symbolic value by the time it enters the container; recovering its + * shadow on the way out keeps the branch dependent on the symbolic input rather than concretizing it. + * A single String parameter avoids an unrelated instrumenter frame-analysis limitation on mixed + * category-2/reference signatures. + */ +public class ContainerRecoveryTarget { + + public static void main(String[] args) { + test("hello"); + } + + public static int test(@Symbolic String s) { + String name = s.substring(1); // a derived, tracked symbolic String ("ello") + HashMap m = new HashMap<>(); + m.put("k", name); + String v = m.get("k"); + if (v.charAt(0) == 'e') { + return 1; + } + return 0; + } +} From fb577d588e01a78791d18982491de77f3f287d23 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 14:25:58 +0000 Subject: [PATCH 28/33] docs(heap): document value-typed container recovery in the reference Update the boundary-recovery section to reflect that a distinct, already-tracked immutable value returned from an unmodeled method (e.g. from a Map/List) is now recovered rather than blanket-concretized, while a this-return still concretizes. Co-Authored-By: Claude Opus 4.8 --- docs/heap-tracking.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/heap-tracking.md b/docs/heap-tracking.md index b335261..4819208 100644 --- a/docs/heap-tracking.md +++ b/docs/heap-tracking.md @@ -108,10 +108,14 @@ When instrumented code calls a method SWAT does not model, the call runs for rea pushes a **placeholder** on its shadow stack. The real result is reconciled at the next `GETVALUE` synchronization point, where the executor learns the concrete value the JVM produced. -The key rule: a **value-typed return (String or boxed primitive) is not identity-recovered from the -registry** — it is turned into a fresh concrete value. Identity recovery would look up the -*receiver's* own shadow (because e.g. `String.toLowerCase()` can return `this`) and wrongly re-bind -the result to the receiver's formula. Concretizing instead breaks that aliasing. +The subtle case is a **value-typed return (String or boxed primitive)**. Naively identity-recovering +it is unsound when the method returns `this` (e.g. `String.toLowerCase()` on an already-lowercase +string): the result would be re-bound to the *receiver's* formula. So the rule is: recover the +registered shadow only when the returned object is a **distinct, already-tracked immutable value** +(different from the receiver's own shadow) — e.g. a String retrieved from a `Map`/`List` — otherwise +concretize. This keeps a container round-trip symbolic while still breaking the `this`-return +aliasing. Recovery is limited to immutable value types (their value cannot have drifted since it was +registered) and fires only for values already registered in the heap. - `symbolic/value/PlaceHolder.java` — the placeholder; `enum ValueOrigin` (notably `UNMODELED_RETURN`) tags where a placeholder came from; shared singletons `instance` / @@ -120,12 +124,16 @@ the result to the receiver's formula. Concretizing instead breaks that aliasing. placeholder and the call is not on `IGNORED_INVOCATIONS`, records a missing invocation and re-wraps the result as an `UNMODELED_RETURN` placeholder. - `SymbolicInstructionVisitor.visitGETVALUE_Object` — the `UNMODELED_RETURN && Util.isValueType(...)` - branch concretizes via `ValueFactory.createObjectValue` **without touching the registry** (so the - receiver's entry is untouched); non-value results fall through to normal registry recovery. + branch: models a whitelisted pure result as a UF; else recovers the registered shadow when the + return is a distinct immutable value (`Util.isImmutableValueType`, the receiver carried on the + placeholder, a `getFromHeap` hit that is not the receiver's shadow, concrete matches); else + concretizes via `ValueFactory.createObjectValue`. Non-value results fall through to normal registry + recovery. - `SymbolicInstructionVisitor.visitGETVALUE_primitive` — the primitive mirror. **Invariants** -- An unmodeled value-typed return never aliases the receiver's formula. +- An unmodeled value-typed return never aliases the receiver's formula (a `this`-return concretizes). +- A recovered value type is immutable, so its stored shadow still matches the observed value. - Concrete recovery always adopts the JVM-observed value. ### 4. Out-of-band change detection From afaa656d1452debe9e0ff77cc367b01764455c64 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Wed, 1 Jul 2026 16:06:15 +0000 Subject: [PATCH 29/33] fix(explorer): downgrade to UNKNOWN when a diverged branch is left unverified A branch solved via an uninterpreted pure_ UF can diverge on concrete replay: the predicted branch is never taken because the solver cannot drive the UF to the required value. The divergence was detected but ignored, and the driver concluded SAFE - an unsound false SAFE (autostub compareToIgnoreCase scored -32). Track diverged/unverified branches and, before concluding SAFE, downgrade to UNKNOWN if any remain. compareToIgnoreCase: -32 -> 0 (UNKNOWN); Boolean.compare violation and toLowerCase UNKNOWN unchanged. Co-Authored-By: Claude Opus 4.8 --- symbolic-explorer/driver/SVCompDriver.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/symbolic-explorer/driver/SVCompDriver.py b/symbolic-explorer/driver/SVCompDriver.py index 3683f35..e0095ec 100644 --- a/symbolic-explorer/driver/SVCompDriver.py +++ b/symbolic-explorer/driver/SVCompDriver.py @@ -62,6 +62,11 @@ class State: def __init__(self): self.verdict = Verdict.UNKNOWN self.branch_to_explore: Node | None = None + # Branches we solved and intended to explore, but the concrete re-run diverged from (the + # predicted branch was never actually taken - e.g. a branch guarded by an uninterpreted + # pure_ UF the solver cannot drive to the required value). While any remain unverified a + # SAFE verdict is unsound: that branch was not actually explored. + self.unverified_branches: set = set() class SVCompDriver: @@ -213,9 +218,11 @@ def run_testcase(self, java_path, agentpath: str, configpath: str, z3path, port, if branch.id == self.state.branch_to_explore.id: if branch.has_branched == (self.state.branch_to_explore.branched is not None): logger.error(f'[SVCOMP] SWAT Assertion failed: Target did not explore branch {branch.id} as expected! Branch unexpectedly taken/skipped.') + self.state.unverified_branches.add(self.state.branch_to_explore.id) break else: logger.error(f'[SVCOMP] SWAT Assertion failed: Target did not explore branch {self.state.branch_to_explore.id} as expected! Branch does not appear in trace.') + self.state.unverified_branches.add(self.state.branch_to_explore.id) self.state.branch_to_explore = None self.log_output(output) @@ -313,6 +320,10 @@ def run(self): logger.warning(f'[SVCOMP] Found uncaught exceptions during symbolic execution') verdict = Verdict.UNKNOWN + if (verdict == Verdict.SAFE) and self.state.unverified_branches: + logger.warning(f'[SVCOMP] Cannot conclude SAFE: {len(self.state.unverified_branches)} branch(es) diverged and were never verified (downgrading to UNKNOWN)') + verdict = Verdict.UNKNOWN + if verdict == Verdict.NO_SYMBOLIC_VARS: verdict = Verdict.SAFE verdict_logger.info(f'[VERDICT {self.verification_category.value}] {verdict.value}') From b6309d3a24e45a331a81972e1113da46f57d69ed Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 2 Jul 2026 11:15:42 +0000 Subject: [PATCH 30/33] feat(explorer): re-open diverged branches with a bounded attempt count Re-open a branch that diverged on concrete replay (drop its stored solution so the search returns it again) and retry up to MAX_BRANCH_ATTEMPTS, preferring less-tried branches so a repeatedly-diverging one does not starve the rest; after the cap it is abandoned as unverified, forcing UNKNOWN (piece-1 backstop). Key the unverified/attempt bookkeeping on node gid (not the bytecode iid, which is not unique per node). Bounded and terminating; the divergence is now a handled warning, not a spurious SWAT-assertion error. Co-Authored-By: Claude Opus 4.8 --- symbolic-explorer/data/Database.py | 9 +++++ symbolic-explorer/driver/SVCompDriver.py | 51 ++++++++++++++++++++---- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/symbolic-explorer/data/Database.py b/symbolic-explorer/data/Database.py index a52752c..056cfef 100755 --- a/symbolic-explorer/data/Database.py +++ b/symbolic-explorer/data/Database.py @@ -159,6 +159,15 @@ def add_solution(self, branch_id: int, sol: Any, inputs: List[Input], endpoint_i self.new_solutions.append(branch_id) lock.release() + def remove_solution(self, branch_id: int): + # Re-open a branch (e.g. after a concolic divergence): drop its stored solution so the + # selection walk (dfs) returns it again for another attempt. + lock.acquire() + self.solutions.pop(branch_id, None) + if branch_id in self.new_solutions: + self.new_solutions.remove(branch_id) + lock.release() + def get_solutions(self): lock.acquire() ret = copy.deepcopy(self.solutions) diff --git a/symbolic-explorer/driver/SVCompDriver.py b/symbolic-explorer/driver/SVCompDriver.py index e0095ec..e09601d 100644 --- a/symbolic-explorer/driver/SVCompDriver.py +++ b/symbolic-explorer/driver/SVCompDriver.py @@ -27,7 +27,13 @@ # The (unique) endpoint ID for the SV-COMP target. As each target is handled separately, this ID is always 0. -ENDPOINT_ID = 0 +ENDPOINT_ID = 0 + +# A branch guarded by an uninterpreted pure_ UF can diverge on concrete replay (the solver cannot +# drive the UF to the required value). We re-open and retry such a branch - with a fresh solver input +# once observed input->output pairs are injected - but only up to this many attempts, after which it +# is abandoned as unverified (forcing UNKNOWN). This bounds exploration and guarantees termination. +MAX_BRANCH_ATTEMPTS = 3 class ExecutionStatus(Enum): @@ -62,11 +68,14 @@ class State: def __init__(self): self.verdict = Verdict.UNKNOWN self.branch_to_explore: Node | None = None - # Branches we solved and intended to explore, but the concrete re-run diverged from (the - # predicted branch was never actually taken - e.g. a branch guarded by an uninterpreted - # pure_ UF the solver cannot drive to the required value). While any remain unverified a + # Node gids we solved and intended to explore, but abandoned after the concrete re-run kept + # diverging (the predicted branch was never actually taken - e.g. a branch guarded by an + # uninterpreted pure_ UF the solver cannot drive to the required value). While any remain, a # SAFE verdict is unsound: that branch was not actually explored. self.unverified_branches: set = set() + # Per-branch (node gid) count of solve+re-run attempts that diverged; drives attempt-based + # prioritization and the re-open/abandon bound. + self.branch_attempts: dict = {} class SVCompDriver: @@ -214,15 +223,21 @@ def run_testcase(self, java_path, agentpath: str, configpath: str, z3path, port, # check if the target explored the branch as expected if self.state.branch_to_explore: + diverged = False for branch in Database.instance().get_trace(-1): # most recent trace if branch.id == self.state.branch_to_explore.id: + # We solved this branch to flip it; if the re-run took the same direction, the + # predicted branch was not actually explored (an uninterpreted pure_ UF the + # solver could not drive to the required value). if branch.has_branched == (self.state.branch_to_explore.branched is not None): - logger.error(f'[SVCOMP] SWAT Assertion failed: Target did not explore branch {branch.id} as expected! Branch unexpectedly taken/skipped.') - self.state.unverified_branches.add(self.state.branch_to_explore.id) + logger.warning(f'[SVCOMP] Branch {branch.id} diverged: predicted direction not taken.') + diverged = True break else: - logger.error(f'[SVCOMP] SWAT Assertion failed: Target did not explore branch {self.state.branch_to_explore.id} as expected! Branch does not appear in trace.') - self.state.unverified_branches.add(self.state.branch_to_explore.id) + logger.warning(f'[SVCOMP] Branch {self.state.branch_to_explore.id} diverged: does not appear in trace.') + diverged = True + if diverged: + self.handle_divergence(self.state.branch_to_explore) self.state.branch_to_explore = None self.log_output(output) @@ -251,8 +266,28 @@ def run_testcase(self, java_path, agentpath: str, configpath: str, z3path, port, + def handle_divergence(self, branch: Node): + """A solved branch diverged on concrete replay. Re-open it for another attempt (bounded), or + abandon it as unverified once the attempt cap is reached (forcing UNKNOWN).""" + gid = branch.gid + self.state.branch_attempts[gid] = self.state.branch_attempts.get(gid, 0) + 1 + attempts = self.state.branch_attempts[gid] + if attempts < MAX_BRANCH_ATTEMPTS: + # Not actually explored - re-open so it is selectable again (yields a different solver + # input once observed input->output pairs are injected). Bounded by the attempt cap. + Database.instance().remove_solution(gid) + logger.info(f'[SVCOMP] Re-opening diverged branch {branch.id} (gid {gid}), attempt {attempts}/{MAX_BRANCH_ATTEMPTS}') + else: + self.state.unverified_branches.add(gid) + logger.warning(f'[SVCOMP] Abandoning diverged branch {branch.id} (gid {gid}) after {attempts} attempts; verdict cannot be SAFE.') + def retrieve_solution(self): possible_branches = StrategyService.select_branch(endpoint_id=ENDPOINT_ID) + # Prefer branches tried fewer times so a repeatedly-diverging branch does not starve the rest; + # exclude any that hit the attempt cap (abandoned -> unverified -> UNKNOWN). + possible_branches = [b for b in possible_branches + if self.state.branch_attempts.get(b.gid, 0) < MAX_BRANCH_ATTEMPTS] + possible_branches.sort(key=lambda b: self.state.branch_attempts.get(b.gid, 0)) logger.info(f'[SYMBOLIC EXPLORATION] Found {len(possible_branches)} possible branches') logger.info(f'[SYMBOLIC EXPLORATION] Possible branch IDs: {[b.id for b in possible_branches]}') symbolic_vars = None From d6fd4e666adca1add3d9fa72f4e4a823143d6f23 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 2 Jul 2026 11:23:37 +0000 Subject: [PATCH 31/33] feat(uf): record observed input->output pairs for all return sorts and inject them Emit the ground pair pure_(constant inputs) == observed output for every whitelisted pure return, not just String: buildPureUF builds constant inputs for all value-type sorts and the observed application for any return sort; visitGETVALUE_primitive asserts it via PureFunctionUF.equalConstant (bitvector/boolean/FP/string). The explorer injects the accumulated per-testcase pairs (Tree.ufs) at solve time, so re-solving a diverged branch is forced to pick a different input. Pairs are true observed facts (sound). autostub compareToIgnoreCase: each retry now explores a distinct input before abandoning -> UNKNOWN; whitelist L2 tests + Boolean.compare/toLowerCase unchanged. Co-Authored-By: Claude Opus 4.8 --- .../symbolic/SymbolicInstructionVisitor.java | 10 ++++ .../its/swat/symbolic/UFs/PureFunctionUF.java | 51 +++++++++++++++++++ .../symbolic/invoke/InvocationHandler.java | 35 +++++-------- symbolic-explorer/strategy/StrategyService.py | 6 +++ 4 files changed, 81 insertions(+), 21 deletions(-) diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java index c367b7c..6469ab4 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/SymbolicInstructionVisitor.java @@ -41,6 +41,7 @@ import lombok.Getter; import org.objectweb.asm.Type; import org.sosy_lab.java_smt.api.*; +import de.uzl.its.swat.symbolic.UFs.PureFunctionUF; public class SymbolicInstructionVisitor implements IVisitor { // The stack of stack frames (method stacks) @@ -3676,6 +3677,15 @@ private void visitGETVALUE_primitive(GETVALUE_primitive inst, ValueType type) th v = ValueFactory.createNumericalValue(type, inst.v, placeHolder.recoveredFormula); ThreadHandler.getShadowStateLogger(currentThread().getId()) .info("Modeled unmodeled pure primitive result as a generic UF: {}", v); + // Record this run's observed (input -> output) ground pair over the same cached UF + // declaration: pure_(constant inputs) == observed concrete output. A true fact + // that lets the explorer force a different input when re-solving a diverged branch. + if (placeHolder.observedApplication != null) { + FormulaManager fmgr = + ThreadHandler.getSolverContext(currentThread().getId()).getFormulaManager(); + symbolicTraceHandler.addConstraint( + PureFunctionUF.equalConstant(fmgr, placeHolder.observedApplication, inst.v)); + } } else { v = ValueFactory.createNumericalValue(type, inst.v); if (isSymbolic) { diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java index 3a28548..ee14608 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/UFs/PureFunctionUF.java @@ -4,11 +4,15 @@ import java.util.List; import java.util.Map; import java.util.stream.Collectors; +import org.sosy_lab.java_smt.api.BitvectorFormula; +import org.sosy_lab.java_smt.api.BooleanFormula; +import org.sosy_lab.java_smt.api.FloatingPointFormula; import org.sosy_lab.java_smt.api.Formula; import org.sosy_lab.java_smt.api.FormulaManager; import org.sosy_lab.java_smt.api.FormulaType; import org.sosy_lab.java_smt.api.FunctionDeclaration; import org.sosy_lab.java_smt.api.SolverContext; +import org.sosy_lab.java_smt.api.StringFormula; import org.sosy_lab.java_smt.api.UFManager; /** @@ -49,4 +53,51 @@ public Formula apply(String ufName, FormulaType returnType, List arg } return ufmgr.callUF(decl, args); } + + /** + * A constant formula of {@code type} holding the concrete {@code value}, for the ground (observed) + * side of a pure-UF pair. Supports the value-type sorts: boolean, bitvector (any width, from a + * {@link Character} or {@link Number}), floating point (single/double), and String. + */ + public static Formula constant(FormulaManager fmgr, FormulaType type, Object value) { + if (type.isBooleanType()) { + return fmgr.getBooleanFormulaManager().makeBoolean((Boolean) value); + } + if (type.isBitvectorType()) { + int width = ((FormulaType.BitvectorType) type).getSize(); + long bits = (value instanceof Character c) ? (char) c : ((Number) value).longValue(); + return fmgr.getBitvectorFormulaManager().makeBitvector(width, bits); + } + if (type.isFloatingPointType()) { + return fmgr.getFloatingPointFormulaManager() + .makeNumber(((Number) value).doubleValue(), (FormulaType.FloatingPointType) type); + } + if (type.isStringType()) { + return fmgr.getStringFormulaManager().makeString((String) value); + } + throw new IllegalArgumentException("Unsupported observed-pair type: " + type); + } + + /** + * The equality {@code uf == constant(value)} in the theory of {@code uf}'s sort, used to record an + * observed (input -> output) ground pair for a pure UF. + */ + public static BooleanFormula equalConstant(FormulaManager fmgr, Formula uf, Object value) { + FormulaType type = fmgr.getFormulaType(uf); + Formula c = constant(fmgr, type, value); + if (type.isBooleanType()) { + return fmgr.getBooleanFormulaManager().equivalence((BooleanFormula) uf, (BooleanFormula) c); + } + if (type.isBitvectorType()) { + return fmgr.getBitvectorFormulaManager().equal((BitvectorFormula) uf, (BitvectorFormula) c); + } + if (type.isFloatingPointType()) { + return fmgr.getFloatingPointFormulaManager() + .equalWithFPSemantics((FloatingPointFormula) uf, (FloatingPointFormula) c); + } + if (type.isStringType()) { + return fmgr.getStringFormulaManager().equal((StringFormula) uf, (StringFormula) c); + } + throw new IllegalArgumentException("Unsupported observed-pair type: " + type); + } } diff --git a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java index 783f7e5..cbdbbf2 100644 --- a/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java +++ b/symbolic-executor/src/main/java/de/uzl/its/swat/symbolic/invoke/InvocationHandler.java @@ -23,7 +23,7 @@ import org.objectweb.asm.Type; import org.sosy_lab.java_smt.api.Formula; import org.sosy_lab.java_smt.api.FormulaType; -import org.sosy_lab.java_smt.api.StringFormulaManager; +import org.sosy_lab.java_smt.api.FormulaManager; public class InvocationHandler { private static final Logger logger = GlobalLogger.getSymbolicExecutionLogger(); @@ -170,9 +170,8 @@ private record PureUFModel(Formula result, Formula observedApplication) {} * back to concretization. Handles String and all primitive returns (the return sort is * {@link #pureUFReturnType}); the inputs (receiver + args) must all be value-typed so their * formula fully captures the input (sound; no stateful receivers). Also builds the same UF applied - * to the CONSTANT inputs (the observed-pair application) using the SAME cached declaration; - * that is null unless the return is a String and every input is a String (the only case whose - * recovery side asserts the observed pair, and where String concretes become constant formulas). + * to the CONSTANT (observed) inputs, over the SAME cached declaration, so recovery can assert the + * observed (inputs -> output) ground pair. */ private static PureUFModel buildPureUF( String owner, String name, String desc, List> inputs) @@ -183,33 +182,27 @@ private static PureUFModel buildPureUF( if (returnType == null) { return null; } - StringFormulaManager smgr = - ThreadHandler.getSolverContext(Thread.currentThread().getId()) - .getFormulaManager() - .getStringFormulaManager(); + FormulaManager fmgr = + ThreadHandler.getSolverContext(Thread.currentThread().getId()).getFormulaManager(); List symbolicArgs = new ArrayList<>(); List constArgs = new ArrayList<>(); - boolean observable = true; // an observed pair needs constant-buildable (String) inputs for (Value v : inputs) { if (v.formula == null || !Util.isValueType(v.concrete)) { return null; // non-value-typed or formula-less input: defer to concretization. } - symbolicArgs.add((Formula) v.formula); - if (v.concrete instanceof String s) { - constArgs.add(smgr.makeString(s)); - } else { - observable = false; // only String inputs become constant formulas here. - } + Formula sym = (Formula) v.formula; + symbolicArgs.add(sym); + // The ground input constant must match the symbolic argument's own sort, so the observed + // application reuses the same UF signature. + constArgs.add(PureFunctionUF.constant(fmgr, fmgr.getFormulaType(sym), v.concrete)); } PureFunctionUF uf = ThreadHandler.getUFHandler(Thread.currentThread().getId()).getPureFunctionUF(); String ufName = PureMethods.ufName(owner, name, desc); Formula result = uf.apply(ufName, returnType, symbolicArgs); - // Observed pair: built only for String returns, the sole case whose recovery side - // (visitGETVALUE_Object) asserts the (constant inputs -> observed output) equality. The same - // cached declaration is applied to the constant inputs so the pair constrains the very symbol - // used in `result`. Primitive observed pairs are future work (with the explorer side). - Formula observed = (observable && FormulaType.StringType.equals(returnType)) - ? uf.apply(ufName, returnType, constArgs) : null; + // Observed pair: the same cached UF declaration applied to the CONSTANT (observed) inputs, so + // the ground pair asserted at recovery constrains the very symbol used in `result`. Built for + // every supported return sort - the recovery side asserts it == the observed concrete output. + Formula observed = uf.apply(ufName, returnType, constArgs); return new PureUFModel(result, observed); } diff --git a/symbolic-explorer/strategy/StrategyService.py b/symbolic-explorer/strategy/StrategyService.py index 5d97d72..b0ae88f 100755 --- a/symbolic-explorer/strategy/StrategyService.py +++ b/symbolic-explorer/strategy/StrategyService.py @@ -91,6 +91,12 @@ def solve_branch(possible_branch: Node, endpoint_id=None): path_constraints.extend(StrategyService.collect_uf_definitions(possible_branch)) + # Inject the accumulated observed input->output UF pairs from all prior runs of this testcase, + # so re-solving a diverged branch is forced to pick a different input (the previous input's real + # output is pinned). Each entry is a self-contained SMT script parsed in isolation, so re-declared + # UF symbols do not collide; the pairs are true observed facts, so injecting them stays sound. + path_constraints.extend(db.get_tree(0).ufs) + inputs = possible_branch.inputs sat, sol = Z3Handler.solve(possible_branch, path_constraints) From 4f6e42e81ec3ff67f500f83c00a79ede979b5fa8 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 2 Jul 2026 11:23:37 +0000 Subject: [PATCH 32/33] docs(skill): add svcomp-run skill for the SV-COMP harness Operational recipe for parallel scoring and single/debug runs, the debug workflow, result interpretation, the per-testcase wall-clock cap, and key files. Co-Authored-By: Claude Opus 4.8 --- .claude/skills/svcomp-run/SKILL.md | 100 +++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .claude/skills/svcomp-run/SKILL.md diff --git a/.claude/skills/svcomp-run/SKILL.md b/.claude/skills/svcomp-run/SKILL.md new file mode 100644 index 0000000..695b790 --- /dev/null +++ b/.claude/skills/svcomp-run/SKILL.md @@ -0,0 +1,100 @@ +--- +name: svcomp-run +description: Run the SWAT SV-COMP benchmark harness — parallel scoring runs and single-testcase debug runs — and debug one testcase's verdict via single mode. Use when running/scoring sv-benchmarks locally, reproducing or debugging a single testcase, or interpreting points/verdicts/soundness downgrades. Covers setup, the ./svcomp CLI, config files, log locations, and the per-testcase wall-clock cap. +--- + +# Running & debugging the SV-COMP harness + +The custom local harness is in `targets/sv-comp/scripts/`. Per testcase it compiles the target, runs +SWAT (Java agent + Python explorer over HTTP), scores the verdict, and optionally validates witnesses. +Drive everything through the `./svcomp` wrapper (it selects the venv Python) **from +`targets/sv-comp/scripts/`**. This is the *local/custom* runner; the real competition infra uses a +separate wrapper (`scripts/svcomp-package/run_swat.py`) — don't confuse the two. + +## One-time setup + +From the repo root, build the agent + native libs (rebuild the jar after ANY executor change — the +harness runs the jar, not the classes): +```bash +./gradlew copyNativeLibs # z3 -> libs/java-library-path +./gradlew :symbolic-executor:copyJar # -> symbolic-executor/lib/symbolic-executor.jar +./gradlew :targets:sv-comp:WitnessCreator:shadowJar # only if validating violation witnesses +``` +Then in `targets/sv-comp/scripts/`: +```bash +python3 -m venv .venv && .venv/bin/pip install -r requirements.txt +./svcomp setup checkout-benchmarks # SSH clone of SV-Benchmarks (sparse java/) -> ../sv-benchmarks +./svcomp setup checkout-validator # wit4java, only for witness validation +``` + +## Run — parallel (scoring) +```bash +./svcomp test run --mode parallel --workers 50 +./svcomp test run --categories valid-assert.prp --workers 30 # one property only +``` +- Uses `../sv-comp.cfg` (quiet: WARN, no console). Prints per-category points and + `TOTAL POINTS (ALL CATEGORIES): N`, and saves `results/results__.json`. +- `./svcomp analyze results` summarizes the latest results file (context losses / failures). +- `./svcomp test list [--stats]` enumerates testcases; `./svcomp test validate-ports` checks ports. + +## Run — single (one testcase, debug) +```bash +./svcomp test run --mode single \ + --target "autostub/String_public_java_lang_String_java_lang_String_toLowerCase" [--no-witness] +``` +- `--target` is the testcase identifier `/` (matched by suffix against the testcase path). +- Single mode forces `../swat-debug.cfg` (INFO, console on, shadow-stack + symbolic-execution logging on). +- `--no-witness` skips witness gen/validation (avoids wit4java); fine when you only care about the verdict. + +## Debugging a testcase (single mode) +1. Run it single-mode; watch the console. SWAT's own lines are prefixed `[SWAT] -->`. +2. Per-testcase logs land in `logs-debug//_/` (`verdict.log`, symbolic-execution + and shadow-stack logs). Parallel mode instead writes to `logs/`. +3. The exact forked command is logged, e.g. + `java -Xmx32g -Dconfig.path=…/swat-debug.cfg -Dexplorer.port= -javaagent:…/symbolic-executor.jar + -Djava.library.path=… -cp :: -ea Main`. Copy it to run SWAT directly (attach a + debugger, change flags, add `-Dsolver.mode=PRINT` to dump the TraceDTO without the explorer). +4. Markers to grep: + - `[VERDICT ] == TRUE | FALSE | DONT-KNOW` — explorer verdict (safe / violation / unknown). + - `Context loss recorded!` / `Found symbolic context loss`, `Found ... precision loss` — soundness + downgrades (a would-be SAFE becomes UNKNOWN). + - `Invocation of method X in class Y ... cases context loss` (`InvocationHandler.java`) — an unmodeled + method hit with symbolic input. + - `Points: P, Case: -> ` — the scored outcome. + +## Reading the result +`Case: -> `; expected comes from the testcase `.yml` +(`expected_verdict: true` = no violation → TRUE; `false` = violation reachable → FALSE): +- match → `Points: 1` (a violation also needs a validated witness for the point unless `--no-witness`); +- mismatch or unknown → `Points: 0`. +- A `… -> unknown` caused by context/precision loss is **sound** — SWAT declined rather than answered + wrong; not a bug. Example: the flagship `…toLowerCase` case scores `violation -> unknown` because no-arg + `toLowerCase` is locale-dependent and unmodeled, so its result is concretized + context-loss-flagged. + +## Per-testcase wall-clock cap (outside the actual run) +Each testcase's SWAT process is wrapped in `lib/execution.py:run_command_with_timeout` — launched in its +own session (`start_new_session=True`); on `TimeoutExpired` it `os.killpg(…, SIGKILL)`s the whole tree +(JVM + Z3) and records `ExecutionStatus.TIMEOUT` → 0 points, never a wrong verdict. This is entirely +outside the run (SWAT is given no limit; the harness kills it). Default is **900s**; set a shorter +per-testcase cap here (e.g. 120s), or expose it as a `--timeout` option on `swat test`. Do **not** edit +`scripts/svcomp-package/run_swat.py` — that is the separate wrapper the competition infra uses. + +## Key files +- `svcomp.py` + `commands/{setup,test,analyze,util}.py` — the click CLI. +- `lib/execution.py` — `target_execution` (one task), `run_parallel`, `run_single_target`, + `run_command_with_timeout` (the wall-clock cap), scoring + `save_results`. +- `lib/command_gen.py` — builds the per-testcase `java … -javaagent … Main` command. +- `lib/selection.py` — `extract_testcases` (parses the `.yml`s). +- Configs: `../sv-comp.cfg` (parallel), `../swat-debug.cfg` (single/debug) — differ only in logging. +- `../sv-benchmarks/java///` — testcase: `Main.java` + `.yml` (holds `expected_verdict`). + +## Gotchas +- Always run from `targets/sv-comp/scripts/` via `./svcomp` (not `svcomp.py` directly — the wrapper sets + the venv Python). The older standalone `target_execution.py` (invoked by `run_locally.sh`) is a separate + path with its own shorter timeout — prefer the `swat test` CLI. +- Witness validation (wit4java) resolves `python3` via PATH and needs extra venv packages + (`setuptools`, `pyyaml`, `javalang`, `networkx`); prefix with `PATH="$PWD/.venv/bin:$PATH"` or use + `--no-witness`. +- `solver.mode=HTTP`: the explorer runs as a per-testcase HTTP server on an allocated port; parallel runs + use many ports at once. +- Rebuild `:symbolic-executor:copyJar` after executor changes, or you'll score the old jar. From d6cc1822772780049e1599a036641a1e59372965 Mon Sep 17 00:00:00 2001 From: Nils Loose Date: Thu, 2 Jul 2026 15:19:39 +0000 Subject: [PATCH 33/33] chore(svcomp): cap the local runner at 120s per testcase Bound local scoring runs so a diverging/hanging testcase cannot stall the batch; the process group is SIGKILLed on expiry (outside the SWAT run) and the testcase scores 0 (TIMEOUT). The competition-infra wrapper (run_swat.py) is unaffected. Co-Authored-By: Claude Opus 4.8 --- targets/sv-comp/scripts/lib/execution.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/targets/sv-comp/scripts/lib/execution.py b/targets/sv-comp/scripts/lib/execution.py index fbf1f1d..13527e2 100644 --- a/targets/sv-comp/scripts/lib/execution.py +++ b/targets/sv-comp/scripts/lib/execution.py @@ -246,8 +246,14 @@ def log_output(output: List[str]): for line in output: logger.info(line.strip()) -def run_command_with_timeout(cmd: list[str], timeout: int = 900) -> tuple[ExecutionStatus, list[str]]: - """Executes the given command and returns output from both STDOUT and STDERR.""" +def run_command_with_timeout(cmd: list[str], timeout: int = 120) -> tuple[ExecutionStatus, list[str]]: + """Executes the given command and returns output from both STDOUT and STDERR. + + `timeout` is the per-testcase wall-clock cap enforced by this local runner, OUTSIDE the actual SWAT + run: on expiry the whole process group is SIGKILLed and the testcase scores 0 (TIMEOUT). Kept at + 120s to bound local scoring runs; the competition-infra wrapper (scripts/svcomp-package/run_swat.py) + is separate and unaffected. + """ logger.info(f'[TARGET EXECUTION]: Running symbolic-explorer: {cmd}') output = []