Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ jobs:
strategy:
fail-fast: false
matrix:
target: [asset_pack, trace_parse, wav, ui_document, ui_text, net_datagram, png_decode, json_parse, stl_read, ply_read]
# Every target `fuzz/Cargo.toml` declares, which a test in the
# stable workspace requires: a harness nothing runs is a claim
# nothing checks, and seven of these were declared, committed
# with corpora, and never once executed.
target: [asset_pack, trace_parse, wav, ui_document, ui_text, net_datagram, png_decode, json_parse, stl_read, ply_read, obj_read, mtl_read, blob_read, glb_read, accessor_view, gltf_read, data_uri_read]
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@nightly
Expand Down
60 changes: 51 additions & 9 deletions REFUSALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ and answer it — because a parser tested only on the files it can already
read is exactly the failure that fuzzing exists to catch, and it passes
its own suite the whole time.

Thirteen readers in this tree already take bytes nobody here wrote. Their
Seventeen readers in this tree already take bytes nobody here wrote. Their
refusals are the worked examples throughout, so what follows describes the
house pattern rather than inventing one.

**The second half was written before any reader here took geometry, and
four of the thirteen now do** — the STL, PLY, OBJ and blob readers in `crates/mesh`, built
seven of the seventeen now do** — the STL, PLY, OBJ and blob readers in `crates/mesh`, and the
three glTF layers (container, accessor, document) that stand behind one another, built
against this list rather than against the handful of files that happened
to be on somebody's disk, which is what the list was for. Where an entry
below says there is no local precedent, check `crates/mesh` first: its
eleven `MeshError` variants answer "not this format at all", "too short
fifteen `MeshError` variants answer "not this format at all", "too short
to hold its own header", "a declared count the bytes present cannot
supply", "a value outside the format's own domain", "zero where zero
has no meaning" (entry 12, which OBJ's one-based indices are exactly),
Expand Down Expand Up @@ -57,17 +58,21 @@ is dead code that reads like safety.
| JSON | `JsonErrorKind` | `crates/json/src/error.rs` | 29 | `json_parse` | 30 |
| Deflate, inside PNG | `InflateError` | `crates/png/src/inflate.rs` | 7 | `png_decode` | — |
| Mesh descriptor | `TargetError::Creation` | `crates/rhi/src/vk/mesh.rs` | 8, as strings | none | none |
| STL | `MeshError` | `crates/mesh/src/error.rs` | 11, shared | `stl_read` | 18 |
| PLY | `MeshError` | `crates/mesh/src/error.rs` | 11, shared | `ply_read` | 16 |
| OBJ | `MeshError` | `crates/mesh/src/error.rs` | 11, shared | `obj_read` | 16 |
| MTL | `MeshError` | `crates/mesh/src/error.rs` | 4 of the 11 | `mtl_read` | 13 |
| Mesh blob | `MeshError` | `crates/mesh/src/error.rs` | 8 of the 11 | `blob_read` | 16 |
| STL | `MeshError` | `crates/mesh/src/error.rs` | 6 of the 15 | `stl_read` | 18 |
| PLY | `MeshError` | `crates/mesh/src/error.rs` | 10 of the 15 | `ply_read` | 16 |
| OBJ | `MeshError` | `crates/mesh/src/error.rs` | 9 of the 15 | `obj_read` | 16 |
| MTL | `MeshError` | `crates/mesh/src/error.rs` | 4 of the 15 | `mtl_read` | 13 |
| Mesh blob | `MeshError` | `crates/mesh/src/error.rs` | 8 of the 15 | `blob_read` | 16 |
| glTF container | `GlbError` | `crates/mesh/src/glb.rs` | 11 | `glb_read` | 18 |
| Accessor | `AccessorError` | `crates/mesh/src/accessor.rs` | 14 | `accessor_view` | 26 |
| glTF document | `GltfError` | `crates/mesh/src/gltf.rs` | 9 | `gltf_read` | 18 |
| Data URI | `DataUriError` | `crates/mesh/src/data_uri.rs` | 7 | `data_uri_read` | 41 |

**The MTL row is the shortest in this table, and that is the honest
number rather than a gap.** A material library indexes nothing, declares
no counts and multiplies nothing, so four refusals is the whole of what
can go wrong in it, and its census writes a sentence for each of the
seven it cannot reach. **A reader with few answers needs a tighter
eleven it cannot reach. **A reader with few answers needs a tighter
corpus floor, not the same one**, which is why its gate takes one seed of
slack where the others take two.

Expand All @@ -81,6 +86,32 @@ what deleted a refusal from the STL reader that no input could produce**:
it demands a sentence per variant per reader, and a sentence that cannot
be written truthfully is a finding.

**Three of the four newest rows read one format between them, and each
has its own error type rather than a share of one.** That is the opposite
of what the five mesh readers do, and the reason is that they are layers
rather than formats: a container fault, a document fault and an accessor
fault send a caller to three different places, and folding them together
would put "not a container" in every other reader's census. The document
reader's type wraps the other two — along with the JSON reader's and
the geometry vocabulary's — so a caller that wants one answer gets
the layer's name and the inner refusal's own numbers one call away.

**The fourth is not part of that stack yet.** The data URI reader is a
decoder for payloads a document may embed, and nothing calls it: the
glTF reader still refuses every buffer but the container's own chunk,
so the row describes a reader that is complete, attacked and unwired.

**The data URI row has the highest floor of any mesh reader and one of
the smallest error types**, which is not a contradiction: its seeds are
short, so a corpus that covers every rule is cheap, and a rule it stopped
covering would be invisible in a smaller one. (The highest floor in the
table is the input trace's hundred; seven refusals ties the deflate row.)

**Three censuses here list nothing as unreachable** — the container,
the document and the data URI — and the reason is the same for all
three: each owns its error type outright, so there is no other reader's
refusal in it to be out of reach.

The mesh-descriptor row is the one to read before writing an importer
that touches the GPU. Its refusals are formatted strings rather than
variants — the shape the rest of this document argues against — and it
Expand Down Expand Up @@ -718,6 +749,17 @@ effect.
`UnsetPatchBits`, `TraceErrorKind::DuplicateHeaderKey`,
`HeaderFieldOutOfOrder`, `HeaderAfterEvents` and `ByteOrderMark`.

**The base64 decoder is the newest instance, and it is the one that shows
the category is not about file formats.** `QQ==` and `QR==` decode to the
same single byte: the last four bits of the second character reach no
output byte, so an encoder writes them as zero and a lenient decoder
ignores whatever is there. `DataUriError::NonCanonical { at, bits }`
refuses the second spelling, and the fuzz target holds the line from the
other side — everything the decoder accepts is re-encoded and must come
back character for character. **A decoder that tolerated those bits would
pass every test that decodes a payload written by an encoder**, which is
exactly the shape of bug this entry exists for.

`PoolNotCanonical` is the most complete instance and worth reading in
full: scanning every table entry in document order, each pooled index must
first appear exactly when the count of already-seen entries equals it, and
Expand Down
2 changes: 1 addition & 1 deletion crates/mesh/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ readme = "README.md"
homepage.workspace = true

[package.metadata.renew]
purpose = "Turn the bytes of a mesh file this engine did not write into validated geometry, refusing by name everything it cannot vouch for, and write that geometry back out in the one format this repository owns."
purpose = "Turn the bytes of a mesh file this engine did not write into validated geometry, decoding the payloads such a file may embed, refusing by name everything it cannot vouch for, and write that geometry back out in the one format this repository owns."
maturity = "bootstrap"
core = false
extension_points = []
Expand Down
54 changes: 54 additions & 0 deletions crates/mesh/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,60 @@ boundaries, so their element size is not the product of their parts, and
they carry inverse bind matrices — which is skinning, which this reads
nothing of. They are unrepresentable rather than accepted and mis-sized.

## The document, which is where the indices are

`gltf::read` takes the bytes of a binary glTF and returns geometry. It is
the only layer here that knows the format has a document at all: the
container hands back two byte strings and parses neither, the accessor
layer is arithmetic over a range, and this is where the numbers that
drive both come from.

**A document is a handful of parallel arrays and a great many indices
into them.** A primitive names an accessor by number, an accessor names a
buffer view by number, a view names a buffer by number. A number naming a
row that is not there is the commonest thing wrong with a hand-edited or
truncated document, so it has one refusal that carries the table, the
index, and how many rows there were.

**Its refusals name the layer that failed**, not just the fault: a
`GltfError` says whether the container, the document, an accessor or the
geometry objected, and the inner refusal's own numbers are one call away
through the value. A caller that only wants geometry can go
through `format::detect` and then `Format::read`, which wraps the same
answer in `MeshError`.

The scene walk is an explicit stack with a visited mark checked before
children are pushed, so a document whose nodes point at each other is a
refusal rather than a walk that never ends.

## `data:` URIs, and why a decoder is strict about spelling

`data_uri::read` turns a URI whose payload *is* the resource back into
bytes — RFC 2397, base64 — and does nothing else. It does not know what
the bytes are for and never touches the filesystem, so a URI naming a
second file is not its to refuse, because it is not its to fetch.

**The rule it is built on is that a difference which cannot change an
output byte is forgiven, and one that can is refused.** The `;base64`
marker is read in any letter case. Whitespace, the URL-safe alphabet, a
payload that is not whole four-character groups, padding anywhere but the
end — all refused, and the three that are about one character name
its offset.

**The one that is easy to miss is the last group's unused bits.** `QQ==`
and `QR==` would both decode to the single byte `A`: the last four bits
of the second character reach no output byte. A decoder that ignores them
lets two different texts name one resource, which is the same trade the
container refuses when it insists its declared length is exactly the
file's length rather than at most it. Refusing the second spelling keeps
text and bytes one to one — and the fuzz target holds that line from the
other side, re-encoding everything the decoder accepts and demanding the
input back character for character.

The media type is reported and never judged. Which types are acceptable
is a fact about what the caller is reading, and belongs where that is
known.

## Manifest

`Cargo.toml` is authoritative for maturity, core status, dependencies and
Expand Down
Loading
Loading