OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_glb_check.gd

176 lines · gd ref:
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