Packages

Reader for the glTF 2.0 3D interchange format — .gltf and .glb, with accessor decoding that handles interleaved strides and sparse substitution.

Current section

Files

Jump to
gltf CHANGELOG.md
Raw

CHANGELOG.md

# Changelog
All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.1.0] - 2026-08-21
Initial release. Read-only, core specification.
### Added
- `Gltf.read/2` and `Gltf.parse/2``.gltf` JSON and `.glb` containers, with
the format detected from content rather than the extension.
- `Gltf.accessor/2` and `Gltf.accessor_binary/2` — decoded elements, or
tightly packed little-endian bytes with any interleaving removed and any
sparse block applied.
- `Gltf.attribute/4`, `Gltf.attribute_binary/4`, `Gltf.indices/3`,
`Gltf.primitives/2` — mesh access by name.
- `Gltf.info/1` — a summary that counts collections without decoding, and
reports `extensions_required` so a caller can refuse a file whose geometry
it cannot actually read.
- `Gltf.bounds/2` — the accessor's own declared `min`/`max` when present,
computed otherwise. Non-finite components are skipped rather than allowed to
win a comparison they should not be in.
- `Gltf.Node``T * R * S` composition, column-major matrices, and
`world_transforms/2` with cycle detection.
- `Gltf.Buffer` — embedded `data:` URIs, the GLB binary chunk, and external
files confined to the asset's own directory unless permitted otherwise.
### Notes on what this gets right
Four behaviours account for most of the difference between a glTF reader that
works and one that appears to:
- **Interleaved `byteStride`** is honoured, so attributes sharing a buffer
view decode separately rather than as a mixture. Verified by decoding
Khronos's `Box` and `BoxInterleaved` — the same cube in both layouts — and
asserting the results are identical.
- **Sparse accessors** are applied, including the case with no base buffer
view, where the base is defined to be zeros.
- **Normalised integers** map onto `0.0..1.0` and `-1.0..1.0`, with the signed
form clamped at `-1.0` as the specification requires.
- **Non-finite floats** decode to `:nan`, `:infinity` and `:neg_infinity`
instead of crashing. The BEAM has no float term for them, so the bit-syntax
match *fails* rather than raising, and a reader that does not expect that
dies on files every other viewer opens.
### Fixed — first review pass
Found by an external review panel and reproduced before acting.
- **A symbolic link defeated the path confinement.** `Path.expand/1` collapses
`..` lexically; the filesystem does not. A symlink inside the asset's own
directory pointing anywhere at all passed the check while `File.read/1`
followed it — with `allow_outside_base` at its default. An asset bundle
carrying a `.gltf` next to a symlink is an ordinary way to receive one.
Links are now resolved, at every path component, before the comparison. The
same bug refused paths that were genuinely inside, since the lexical
collapse cancels a symlink hop.
- **Scene traversal was exponential.** Cycle detection tracked only the current
ancestor chain, so a node reachable twice was walked twice. A chain where
each node lists the next one twice is perfectly acyclic and costs 2ⁿ: a
551-byte document took 71 seconds at depth 24, doubling per level. The
specification requires the node hierarchy to be a forest, so a second parent
is now refused — which is both correct and linear.
- **`count` was unbounded on an accessor with no `bufferView`.** Such an
accessor is materialised entirely from zeros, so a seventy-byte document
could ask for gigabytes. Bounded, and configurable through
`:max_unbacked_elements`.
- **Malformed documents raised instead of returning errors.** A non-integer
`byteOffset` reached the bounds arithmetic before its own guard and raised
`ArithmeticError`; a collection that was an object rather than an array
raised `BadMapError`; a `matrix` of sixteen strings passed the length check
and raised. The contract is `{:ok, _} | {:error, t}` on untrusted input, and
anything that raises is a caller who cannot defend themselves.
- **Negative indices silently resolved to the last element**, because
`Enum.at/2` counts backwards. `"buffer": -1` read the last buffer and
produced geometry that was wrong without being invalid.
- **`"normalized": null` normalised.** Only the literal `false` was treated as
false, so every other value — including the routine JSON emission for an
unset field — inverted the accessor's meaning.
- **GLB structural constraints went unchecked**: a chunk length not divisible
by four, a BIN chunk before the JSON chunk, duplicate chunks of either kind
(last silently won), and bytes past the header's declared total length. That
last one is a smuggling primitive — a validator honouring the declared length
and this parser would disagree about what the file contains.
- **Sparse blocks were under-validated**: `count: 0`, indices that were not
strictly increasing (duplicates resolved last-wins), and signed or float
index component types were all accepted.
- **`buffer.byteLength` was treated as a minimum rather than a bound**, leaving
a GLB chunk's padding and an over-long external file's tail addressable.
### Fixed — second review pass
Two of these the first fix round introduced. A fuzz over 51 field paths × 17
hostile values × 9 entry points went from 834 raises to 106 after that round,
and to none of the reachable ones after this.
- **A GLB header declaring fewer than twelve total bytes raised.** The
truncation added to stop content smuggling was guarded above but not below,
so `binary_part` got a negative size — a twelve-byte file crashed the parser.
- **The symlink resolver was quadratic in path components**, recursing one at
a time and re-splitting the whole prefix each level: a sixteen-kilobyte
`uri` pinned a scheduler for twenty-two seconds. That is the same denial of
service removed from the scene traversal, reintroduced by the function
written to close the symlink hole. It walks once now, and refuses a path
more than 256 components deep, since no real directory is.
- **The `byteLength` bound never applied to a GLB.** The BIN chunk path
returned before the check ran, so a chunk's padding stayed addressable while
the byte-identical `.gltf` correctly refused.
- **The traversal was quadratic, not linear as claimed.** Nodes were reached
through `Enum.at/2` on a list, twice each, and the ancestor chain was a list
whose membership test costs O(depth). Both are constant-time lookups now.
- **`:max_unbacked_elements` bounded the count, not the memory.** An element
is 1 to 64 bytes, so a 94-byte document could still materialise 64 MB.
Replaced by `:max_unbacked_bytes`.
- **The error path itself raised, and could be forged.** A just-rejected
untrusted index was interpolated rather than inspected: a map raised
`Protocol.UndefinedError` out of the error branch, and a JSON array of
integers is an Erlang charlist — so it injected attacker-chosen text into a
message that gets logged.
- **Collection *elements* were unguarded**, so a string where an object
belonged reached `Map.get` and raised `BadMapError` from `attribute/4`,
`indices/3` and `bounds/2`.
- **`bounds/2` returned whatever the document declared**, so `{"min": ["a"]}`
came out of a function whose spec promises `[number()]`.
- **"JSON chunk first" was enforced only against a BIN chunk**, so an unknown
chunk could precede it — a file this accepted and a conformant reader would
refuse.
- `Node.world_transforms/2` and `Buffer.resolve/2` raised on a non-map
document where their siblings returned an error; caller-supplied `:buffers`
was only checked to be a list; and a bad `scenes[0].nodes` reported that
`"children"` was wrong.
### Fixed — third review pass
- **The memory bound got *looser* for the case it names.** `:max_unbacked_bytes`
measured the packed size, but `accessor/2` returns a **list** — roughly 24
bytes per element whatever the component is. A SCALAR int8 accessor sat
exactly on a 16 MB bound and grew the heap by nearly 300 MB, where the
element limit it replaced had capped the same case. It now bounds whichever
of the two output shapes costs more.
- **A symlink target's `..` was collapsed lexically**, so `link/../file`
resolved against where the link was written rather than where it points —
and the file actually opened was not the one the path named. Confinement
held throughout (the collapsed path is inside the base), so this returned
the wrong bytes rather than escaping.
- **The path-depth limit applied only to the literal `uri`**, so a link whose
target was a thousand-component path was followed anyway. Components are
counted across the whole walk now.
- The symlink hop guard let a chain terminating on its 33rd link through.
### Not included
Writing, Draco and meshopt decompression, and extension interpretation.