Skip to content

Model StringBuffer, ignore-case comparisons, simple regexes and more String methods - #90

Closed
nils-loose wants to merge 4 commits into
fix/string-model-soundnessfrom
feat/string-models-2
Closed

nils-loose wants to merge 4 commits into
fix/string-model-soundnessfrom
feat/string-models-2

Conversation

@nils-loose

Copy link
Copy Markdown
Contributor

Builds on #75. It models the String, StringBuffer and Character methods that #75 still left unmodelled. Each new model is exact. Where an exact model is impractical, the call stays unmodelled, so it records a context loss.

What changed

StringBuffer

  • StringBuffer is shadowed by StringBuilderValue, with the same models. This covers new, values recovered from the heap, and StringBuffer arguments of String/StringBuilder methods.

Ignore-case comparisons (CaseMapping)

  • equalsIgnoreCase against a concrete string without surrogates is a regular expression: at each position, the chars with the same key toLowerCase(toUpperCase(c)) as the concrete char. The key is tabulated from the running JVM. The model is exact for all chars, and Boolean.parseBoolean uses it too.
  • Two symbolic strings are handled for equalsIgnoreCase, regionMatches(true/symbolic, ...) and compareToIgnoreCase:
    • The call's branches first split on equal lengths and on the region bounds, then pin the lengths, then split off the ASCII strings.
    • ASCII strings are then compared char by char.
    • Strings that are not ASCII are not modelled.
  • The previous equalsIgnoreCase UF is removed. It added "every char is printable ASCII" as a global constraint (an input restriction, unsound for SAFE), was cut at 50 chars, and its table was only added to the first trace.

Regular expressions (JavaRegex)

  • matches(regex): a concrete regex of the simple kind becomes an SMT regex. The simple kind is literals, escaped metacharacters, positive classes ([a-z], \d, \s, \w), groups, |, and the greedy and reluctant quantifiers. These atoms never match a surrogate, so the char-level SMT semantics equal Java's code-point semantics.
  • The translator rejects everything else (., negated classes, anchors, back references, possessive quantifiers, flags, ...), and such a call stays unmodelled.
  • replaceFirst with a literal regex and replacement is str.replace, which is exact.
  • replaceAll with a literal regex is Make the String and StringBuilder models sound #75's replace, which records precision loss.

Other models

  • codePointAt, codePointBefore and codePointCount, with their index checks (codePointCount pins the range length).
  • repeat, with the count >= 0 check and count pinned.
  • String.join(delimiter, array), and String.valueOf of a StringBuilder or of null.
  • new String(char[], offset, count), with its bounds check and pins.
  • StringBuilder.reverse of symbolic chars, by the pinned length, with Java's restoration of surrogate pairs.
  • Character.digit with a symbolic radix, and Character.forDigit.

StringCallBranches

  • A branch is now either a check, where the call throws if it fails, or a split, where both sides return.
  • Before, a split that did not hold was read as "returned normally although a check failed", and the call recorded no branches. Sibling traces then disagreed on the branch at the same depth (Branch id's dont match in the explorer).

Measured

Config: nosa, sv-comp.cfg, 300 s, mem15g. 1168 runs:

The base is #75's final results, plus 12 KB runs that #75 had not measured; I ran those on #75's jar.

runs gained runs lost points new wrong verdicts
vs #75 17 0 +24 0
vs baseline 50 0 +76 0
task prop #75 new pts
securibench Aliasing5, Basic10, Basic15 (StringBuffer) both unknown safe / violation +9
jbmc StringConstructors01 (new String(char[], int, int)) both unknown safe +4
jbmc StringConstructors04 both unknown safe / violation +3
jbmc Validate02 (matches) NRE unknown safe +2
autostub equalsIgnoreCase (was a timeout), regionMatches(boolean, ...), compareToIgnoreCase valid-assert unknown violation +3
autostub Character.digit(char, int), digit(int, int), forDigit valid-assert unknown violation +3

Tests

  • StringCaseRegexModelExactnessTest (L0, about 1300 cases) is in Make the String and StringBuilder models sound #75's style: symbolic strings and ints whose concrete values are the sample, with the call's branches as they hold for the sample. With the variables fixed to the sample, only Java's result is admitted.
    • It covers all the models above, including ß, İ, ı, ſ, µ, ÿ, title-case and Georgian chars, Kelvin-like case classes, surrogate pairs and lone surrogates.
    • It checks that the untranslatable regexes are rejected.
    • It checks Character.digit(c, r) == (digit(c, 36) < r ? digit(c, 36) : -1) for every char and radix.
  • StringModelExactnessTest passes. One case now uses an unmodelled insert instead of reverse, which is modelled.

Known limits

  • Symbolic toLowerCase/toUpperCase stay unmodelled, as a context loss. I tried three exact encodings:
    • The Unicode case tables unrolled per char: Z3 times out even on simple queries.
    • seq.map/seq.foldl: hangs, and once gave an unsound model.
    • ASCII unrolled by a pinned length: the search reached hardly any other length, and the securibench violations Make the String and StringBuilder models sound #75 finds were lost. Unrolled up to a bound instead, a query took seconds.
  • Ignore-case comparisons of two symbolic strings that are not ASCII stay a context loss. That is why StringCompare03 (NRE) is still UNKNOWN.
  • split, chars(), codePoints() and StringTokenizer are not modelled.
  • An extra string constraint before an index check can make Z3 hang on the int2bv of a free str.len. I saw this on securibench Inter10 during development, with the ASCII split before the pin. This is why the pins come first.

🤖 Generated with Claude Code

nils-loose and others added 4 commits October 19, 2026 14:35
… regexes exactly

- StringBuffer is shadowed by StringBuilderValue (same methods), also when
  recovered from the heap.
- toLowerCase/toUpperCase (default locale, or a known Locale other than
  tr/az/lt): char by char from tables of the running JVM, surrogate pairs as
  code points, by the pinned length. Lower case of a string with 'Σ' (final
  sigma) stays unmodelled, behind a branch.
- equalsIgnoreCase, regionMatches(ignoreCase, ...), compareToIgnoreCase:
  against a concrete string without surrogates as a regex of the case
  classes; otherwise Java's comparison loop unrolled over the pinned lengths,
  surrogate pairs included. Replaces the equalsIgnoreCase UF, which assumed
  ASCII input (an input restriction) and was cut at 50 chars.
  Boolean.parseBoolean uses the regex.
- matches(regex) for simple regexes (literals, positive classes, groups,
  alternation, greedy/reluctant quantifiers) as SMT regexes; replaceFirst
  with a literal regex exactly; replaceAll with a literal regex as replace
  (precision loss).
- codePointAt/codePointBefore/codePointCount, repeat, String.join(array),
  new String(char[], int, int), String.valueOf(StringBuilder/null),
  StringBuilder.reverse of symbolic chars, with their index checks and pins.
- Character.digit with a symbolic radix and Character.forDigit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unrolled over a string, the full Unicode case tables (a few hundred
ranges per char) give queries Z3 does not decide, even simple ones time
out. The models now separate the ASCII strings by a branch and leave the
others unmodelled. The ignore-case comparison against a concrete string
stays a regex of the case classes, for all chars.

Splits (a model's cases, which both return) are told apart from index
checks, so a call that took a split's other side still records its
branches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ore the ASCII split

Unrolled over a pinned length, the search reached hardly any other
length (the securibench violations were then missed), and unrolled up to
a bound Z3 took seconds per query. A concrete string is mapped
concretely.

The ignore-case comparisons pin the length before they split off the
ASCII strings: with the length fixed, Z3 decides the other side of the
split at once, where the int2bv of a free string length made it hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The model is exact for the pinned length; the test now takes the call's
branches and asks for other strings of that length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
EqualsIgnoreCaseUF, ToLowerCaseUF: deleted as in #90 (replaced by exact
models); #71's registry entries go with them. UFHandler keeps no models,
as #80 removed SinCosUF too. StringValue.java: keep #71's SymbolRegistry
import.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
Combining #83 and #90: #90's library-call branches say whether the call
throws when one fails (a check) or both sides return (a split). The
NumberFormatException and radix-range branches of #83, and the radix pin,
are checks; whether a parsed float is in the exact fragment is a split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
nils-loose added a commit that referenced this pull request Oct 19, 2026
…tates to every branch

Combining #72, #82 and #84:
- #84 pruned a subtree below a repeated loop-head state by returning from
  the recursive dfs. In #72's iterative walk that return ended the whole
  walk, so the open branches of every later subtree were never offered,
  and an exhausted search could report SAFE. It now skips that subtree
  only (regression test added).
- The pruned-subtree count is reset for each walk, as #82 walks the
  persistent tree instead of a copy.
- The choice branches (#69/#76) and library-call branches (#75/#90) take
  the pending loop-head states too, so a state always goes with the
  branch right after it, which its position assumes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nils-loose nils-loose mentioned this pull request Oct 20, 2026
@nils-loose

Copy link
Copy Markdown
Contributor Author

Included in dev through #92 (merge commit 534c182), together with the fixes for interactions between PRs. This PR stays as the record of the change and its measured flips.

@nils-loose nils-loose closed this Oct 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant