extends SceneTree
## GATE — GlbCheck: is a peer's .glb safe to hand to GLTFDocument?
##
## The valid file is MADE HERE by Godot's own exporter (a box with a PNG texture),
## and every hostile file is that one with one thing changed — so a check that
## passes the valid file and refuses each variant is refusing for the stated reason
## and nothing else.
##
## Run: CI=true "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_glb_check.gd
var ok := true
var checks := 0
var _frame := 0
func _p(c: bool, m: String, got: Variant = "") -> void:
checks += 1
print(("ok " if c else "FAIL: ") + m + ("" if str(got) == "" else " (" + str(got) + ")"))
if not c:
ok = false
func _process(_d: float) -> bool:
_frame += 1
if _frame < 3:
return false
_run()
print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
quit(0 if ok else 1)
return true
## A real .glb: one textured box, exported by GLTFDocument.
func _valid_glb() -> PackedByteArray:
var root := Node3D.new()
root.name = "Model"
var mi := MeshInstance3D.new()
mi.name = "Box"
mi.mesh = BoxMesh.new()
var img := Image.create_empty(64, 32, false, Image.FORMAT_RGBA8)
img.fill(Color(0.8, 0.3, 0.1))
var mat := StandardMaterial3D.new()
mat.albedo_texture = ImageTexture.create_from_image(img)
mi.material_override = mat
root.add_child(mi)
mi.owner = root
var doc := GLTFDocument.new()
var st := GLTFState.new()
var err := doc.append_from_scene(root, st)
_p(err == OK, "exporter builds a scene state", err)
var bytes: PackedByteArray = doc.generate_buffer(st)
root.free()
return bytes
func _variant(base: PackedByteArray, edit: Callable) -> PackedByteArray:
var parts := GlbCheck.unpack(base)
var j: Dictionary = (parts["json"] as Dictionary).duplicate(true)
var bin: PackedByteArray = (parts["bin"] as PackedByteArray).duplicate()
var r: Variant = edit.call(j, bin)
if r is PackedByteArray:
bin = r
return GlbCheck.pack(j, bin)
func _refused(bytes: PackedByteArray, label: String, want: String) -> void:
var v := GlbCheck.check(bytes)
_p(not bool(v["ok"]) and String(v["reason"]).contains(want), label, v["reason"])
func _run() -> void:
var good := _valid_glb()
var v := GlbCheck.check(good)
_p(bool(v["ok"]), "the exported box passes", v["reason"])
_p(int(v["triangles"]) == 12, "and counts 12 triangles", v["triangles"])
_p((v["json"] as Dictionary).get("images", []).size() == 1, "with its one texture")
var st := GLTFState.new()
_p(GLTFDocument.new().append_from_buffer(good, "", st) == OK, "and Godot itself loads it")
# pack(unpack(x)) must be a faithful container, or every variant below is suspect.
var re := _variant(good, func(_j: Dictionary, _b: PackedByteArray) -> Variant: return null)
_p(bool(GlbCheck.check(re)["ok"]), "repacking an unchanged file still passes")
# ---- container ----
var bad := good.duplicate()
bad.encode_u32(0, 0x12345678)
_refused(bad, "bad magic is refused", "bad magic")
_refused(good.slice(0, good.size() - 4), "a truncated file is refused", "truncated")
var big := PackedByteArray()
big.resize(GlbCheck.MAX_BYTES + 1)
_refused(big, "a file over 64 MB is refused before parsing", "too large")
# ---- URIs: the local-disk read ----
_refused(_variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
(j["images"] as Array)[0] = {"uri": "../../../../etc/hostname", "mimeType": "image/png"}
return null), "an image uri pointing at local disk is refused", "external")
_refused(_variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
(j["buffers"] as Array).append({"uri": "secrets.bin", "byteLength": 4})
return null), "a buffer uri is refused", "external")
# ---- counts ----
_refused(_variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
for i in GlbCheck.MAX_NODES + 1:
(j["nodes"] as Array).append({"name": "n%d" % i})
return null), "more than 2000 nodes is refused", "too many nodes")
_refused(_variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
var one: Dictionary = (j["images"] as Array)[0]
for i in GlbCheck.MAX_IMAGES:
(j["images"] as Array).append(one.duplicate())
return null), "more than 32 textures is refused", "too many textures")
_refused(_variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
var prim: Dictionary = ((j["meshes"] as Array)[0]["primitives"] as Array)[0]
var ai := int(prim["indices"])
(j["accessors"] as Array)[ai]["count"] = (GlbCheck.MAX_TRIANGLES + 1) * 3
return null), "a mesh over 300k triangles is refused", "too many triangles")
# Instancing counts: one mesh referenced by many nodes is drawn that many times.
# UNDER the node limit, so the refusal can only come from the triangle count: a
# 300-triangle mesh drawn by 1001 nodes is 300 300 triangles.
var inst := _variant(good, func(j: Dictionary, _b: PackedByteArray) -> Variant:
var prim: Dictionary = ((j["meshes"] as Array)[0]["primitives"] as Array)[0]
(j["accessors"] as Array)[int(prim["indices"])]["count"] = 900
for i in 1000:
(j["nodes"] as Array).append({"mesh": 0})
return null)
var iv := GlbCheck.check(inst)
_p(not bool(iv["ok"]) and String(iv["reason"]).begins_with("too many triangles (300300"),
"instancing counts: 1001 nodes x 300 tris is refused BY TRIANGLES", iv["reason"])
# ---- the decompression bomb: refused from the HEADER, never decoded ----
_refused(_variant(good, func(_j: Dictionary, b: PackedByteArray) -> Variant:
var at := _find_png(b)
b[at + 16] = 0; b[at + 17] = 0; b[at + 18] = 0x75; b[at + 19] = 0x30 # width 30000
return b), "a PNG declaring 30000 px is refused from its header", "too large")
_refused(_variant(good, func(_j: Dictionary, b: PackedByteArray) -> Variant:
var at := _find_png(b)
b[at + 1] = 0x00 # not PNG any more
return b), "an image in an unknown format is refused", "unsupported")
# ---- header sizing, each format ----
_p(GlbCheck.header_size(_png_header(640, 480)) == Vector2i(640, 480), "PNG header size")
_p(GlbCheck.header_size(_jpeg_header(1024, 768)) == Vector2i(1024, 768), "JPEG SOF size (after an APP0)")
_p(GlbCheck.header_size(_webp_vp8x(2048, 512)) == Vector2i(2048, 512), "WebP VP8X size")
_p(GlbCheck.header_size(PackedByteArray([1, 2, 3, 4])) == Vector2i.ZERO, "garbage sizes to zero")
func _find_png(b: PackedByteArray) -> int:
for i in b.size() - 8:
if b[i] == 0x89 and b[i + 1] == 0x50 and b[i + 2] == 0x4E and b[i + 3] == 0x47:
return i
return -1
func _png_header(w: int, h: int) -> PackedByteArray:
var b := PackedByteArray([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52])
for x: int in [w, h]:
b.append_array(PackedByteArray([(x >> 24) & 255, (x >> 16) & 255, (x >> 8) & 255, x & 255]))
return b
func _jpeg_header(w: int, h: int) -> PackedByteArray:
var b := PackedByteArray([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10])
b.append_array("JFIF".to_ascii_buffer())
b.append_array(PackedByteArray([0, 1, 1, 0, 0, 1, 0, 1, 0, 0])) # rest of the 16-byte APP0
b.append_array(PackedByteArray([0xFF, 0xC0, 0x00, 0x11, 0x08, (h >> 8) & 255, h & 255, (w >> 8) & 255, w & 255, 3]))
return b
func _webp_vp8x(w: int, h: int) -> PackedByteArray:
var b := "RIFF".to_ascii_buffer()
b.append_array(PackedByteArray([0, 0, 0, 0]))
b.append_array("WEBPVP8X".to_ascii_buffer())
b.append_array(PackedByteArray([10, 0, 0, 0, 0, 0, 0, 0]))
var wm := w - 1
var hm := h - 1
b.append_array(PackedByteArray([wm & 255, (wm >> 8) & 255, (wm >> 16) & 255, hm & 255, (hm >> 8) & 255, (hm >> 16) & 255]))
return b