Files
ConformalLabpp/doc/reviewer/input-validation-audit-2026-05-31.md
Tarik Moussa bad3c4a9ab docs(reviewer): add 4 gap audits — numerical, input-validation, thread-safety, dependency/license
Closes the dimensions the prior audits did not cover:

- numerical-stability: tolerance hierarchy (FD-step vs Newton tol), discontinuous
  clamps, cancellation in cotangent/area, magic constants.
- input-validation: malformed JSON/XML deserialization, NaN/Inf vertex coords on
  load, schema-drift handling.
- thread-safety: no mutable static state (good); undocumented same-mesh concurrency
  contract; Eigen threading.
- dependency-license: existing THIRD-PARTY-LICENSES matrix is sound but predicated
  on the MIT premise that G0 undermines; test meshes have no provenance/license.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 10:21:24 +02:00

198 lines
8.6 KiB
Markdown

# Input-Validation & Malformed-Input Audit — ConformalLabpp
**Date:** 2026-05-31
**Auditor:** External reviewer (Claude Opus 4.8)
**Scope:** Robustness of the library's input boundaries — mesh I/O (OFF/OBJ/PLY) and
result (de)serialization (JSON/XML) — against malformed, adversarial, or
out-of-domain input.
**Focus:** What happens with *bad* input. Distinct from the error-handling audit,
which covered *internal* error propagation and the deliberate `throw` paths.
Status legend: 🔴 Critical · 🟡 Important · 🔵 Polish
> **Threat model:** this is a scientific library, not a network service, so the bar
> is "fail cleanly and diagnosably", not "resist attackers". But meshes and result
> files routinely come from other tools, other people, and older versions of this
> code — so malformed input is a *when*, not an *if*.
---
## Summary table
| ID | Sev | Title | Location |
|----|-----|-------|----------|
| V1 | 🟡 | JSON deserialization has no error handling — nlohmann exceptions leak the abstraction, not the library's `runtime_error` | `serialization.hpp:96-110` |
| V2 | 🟡 | XML parser uses unchecked `std::stoi`/`std::stod` on attributes → uncaught `std::invalid_argument` on garbage | `serialization.hpp:258-261` |
| V3 | 🟡 | No validation of vertex coordinate values on load — NaN/Inf coordinates flow silently into the solver | `mesh_io.hpp:56-66` |
| V4 | 🟡 | JSON loader accesses nested keys (`j["solver"]["converged"]`) without `contains` checks → throws `type_error`/creates nulls on schema drift | `serialization.hpp:104-108` |
| V5 | 🔵 | Hand-rolled XML parser assumes one element per line and no escaping — silently mis-reads valid-but-reformatted XML | `serialization.hpp:248-270` |
| V6 | 🔵 | No upper bound / sanity check on DOF-vector length vs mesh size when loading | `serialization.hpp` |
---
## V1 — 🟡 JSON deserialization leaks nlohmann exceptions
### Evidence
`serialization.hpp:96-108`:
```cpp
std::ifstream ifs(path);
if (!ifs) throw std::runtime_error("Cannot open: " + path);
json j; ifs >> j; // ← malformed JSON → nlohmann::parse_error
...
res->converged = j["solver"]["converged"].get<bool>(); // ← wrong type → type_error
```
### Problem
The "file missing" case is handled with the library's own `std::runtime_error`
(consistent with `mesh_io.hpp`). But the moment the file *exists and is malformed*,
`ifs >> j` throws `nlohmann::json::parse_error` and the typed accessors throw
`nlohmann::json::type_error` — neither is the library's documented error type, and
neither carries the file path. A caller writing `catch (const std::runtime_error&)`
(the pattern the examples use) will **miss** these. (`nlohmann` exceptions derive from
`std::exception`, not `std::runtime_error`.)
### Fix
Wrap the parse + extraction in `try { ... } catch (const json::exception& e)` and
rethrow as `std::runtime_error("conformallab: malformed JSON in " + path + ": " + e.what())`,
matching the `mesh_io.hpp` boundary convention.
### Acceptance criteria
- Loading a truncated/garbage JSON throws `std::runtime_error` with the path.
- A negative test (`EXPECT_THROW(..., std::runtime_error)`) covers it.
---
## V2 — 🟡 XML parser: unchecked stoi/stod on attributes
### Evidence
`serialization.hpp:258-261`:
```cpp
res->iterations = std::stoi(detail_xml::xml_get_attr(line, "iterations"));
res->grad_inf_norm = std::stod(detail_xml::xml_get_attr(line, "grad_inf_norm"));
```
### Problem
If the attribute is missing or non-numeric (`xml_get_attr` returns `""` or garbage),
`std::stoi("")` throws `std::invalid_argument` and out-of-range values throw
`std::out_of_range` — both uncaught, both not the library's `runtime_error`, neither
carrying the path. Same abstraction leak as V1, different exception family.
### Fix
Parse defensively: check the attribute is non-empty and numeric, or wrap in
try/catch and rethrow as `std::runtime_error` with context (path + attribute name).
### Acceptance criteria
- An XML file with a non-numeric `iterations` attribute throws `std::runtime_error`,
not `std::invalid_argument`.
---
## V3 — 🟡 No validation of vertex coordinates on load
### Evidence
`mesh_io.hpp:56-66``load_mesh` checks read success and `is_triangle_mesh`, but
does **not** inspect the vertex coordinate values.
### Problem
A mesh file containing `nan`/`inf` coordinates (common from a crashed upstream tool,
or `1e308`-style overflow) reads in cleanly. The NaN then propagates: edge lengths
`lambda0` → gradient → Hessian → the Newton solver, where it silently poisons the
result (`converged` may even read `true` if `NaN < tol` evaluates falsely in a way
that exits the loop). This is the worst kind of failure: silent, late, and
hard to trace back to the input.
### Fix
After `read_mesh`, scan vertex coordinates for finiteness
(`std::isfinite`) and throw `std::runtime_error("conformallab: non-finite vertex
coordinate in " + filename)` on the first offender. O(V), negligible cost at the I/O
boundary — the same philosophy as the existing `is_triangle_mesh` guard.
### Acceptance criteria
- Loading a mesh with a NaN/Inf coordinate throws `std::runtime_error`.
- Covered by a negative test.
---
## V4 — 🟡 JSON loader assumes schema without checking
### Evidence
`serialization.hpp:104-108` reads `j["solver"]["converged"]`, `["iterations"]`,
`["grad_inf_norm"]` and `j.at("dof_vector")` — only `geometry` is guarded with
`j.contains(...)` (`:99`).
### Problem
`operator[]` on a missing key in nlohmann **inserts a null** (for non-const json) or
throws; `.get<bool>()` on null throws `type_error`. So a result file written by a
*future or older* schema version fails with an opaque nlohmann error rather than a
clear "missing field X" message. (`dof_vector` correctly uses `.at()`, which throws a
clear `out_of_range` — but still not the library's type.)
### Fix
Validate the expected keys up front (or use `.at()` uniformly + the V1 try/catch),
producing a single `std::runtime_error("conformallab: result JSON missing field
'solver.converged'")`-style message.
### Acceptance criteria
- A JSON missing `solver.iterations` yields a `runtime_error` naming the field.
---
## V5 — 🔵 Hand-rolled XML parser is format-fragile
### Evidence
`serialization.hpp:248-270` parses line-by-line with `line.find("<Solver")` etc. and
assumes each element (and the `<DOFVector>…</DOFVector>` text) fits patterns on one
line.
### Problem
This is not an XML parser — it is a line-pattern matcher. A semantically identical
file that pretty-prints elements across multiple lines, or adds an XML declaration /
namespace / attribute reordering, will be **silently mis-read** (fields default to
zero/empty) rather than rejected. Round-trips written by this same code work; nothing
else is guaranteed.
### Fix
Either (a) document the XML format as a strict internal-only subset (and reject input
that doesn't match, rather than mis-reading it), or (b) if interop matters, use a real
XML parser. Given JSON is the primary format, (a) is the pragmatic choice — but the
"silently mis-read" behavior must become "explicitly reject".
### Acceptance criteria
- Reformatted-but-valid XML is either parsed correctly or rejected with an error —
never silently mis-read into zeros.
---
## V6 — 🔵 No DOF-vector vs mesh sanity check
### Evidence
`load_result_json` returns `x = j.at("dof_vector")` with no check that `x.size()`
matches the DOF count of the mesh it will be applied to.
### Problem
A result file from a *different* mesh loads happily; the size mismatch only surfaces
later (out-of-bounds or wrong-answer) when `x` is indexed against the new mesh.
### Fix
Where the loaded `x` is paired with a mesh, assert/throw on a size mismatch with a
clear message. (May belong at the call site rather than the loader.)
---
## What is already good
- The **file-missing** path is handled correctly and consistently
(`runtime_error` + path) in both `mesh_io.hpp` and `serialization.hpp`.
- `load_mesh` already enforces the triangle-mesh precondition at the boundary — the
right place and the right philosophy; V3 just extends it to coordinate finiteness.
- `dof_vector` uses `.at()` (throwing) rather than `operator[]` — partial good
practice that V4 asks to make uniform.
- JSON (nlohmann) is a robust, well-tested parser — the gap is only the missing
try/catch *around* it (V1), not the parser itself.
## Suggested order
1. **V3** (NaN/Inf coordinate guard) — highest impact, prevents silent solver poisoning.
2. **V1 + V4** (JSON error handling + schema checks) — do together.
3. **V2** (XML stoi/stod) — same pattern as V1.
4. **V5 + V6** (XML strictness, size check) — polish.