OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_gltf_packer.gd

161 lines · gd ref:
extends SceneTree
## GATE — GltfPacker: a .gltf (JSON + side files) packed into one self-contained .glb
## at import, so the room only ever receives .glb bytes.
##   G1  Godot's own multi-file .gltf packs, passes GlbCheck, and Godot loads it with the
##       same triangles + textures as the .glb export of the same scene
##   G2  base64 data: URIs pack too
##   G3  a percent-encoded file name resolves
##   G4  refused: '..', absolute paths, drive letters, URLs, missing files, a symlink
##       out of the folder, non-base64 data: URIs
##
## Files go under user://gate_gltf_packer/ and are removed at the end.
##
## Run: CI=true "$HYPER_TPS_GODOT" --headless --path . --script res://scenes/test/gate_gltf_packer.gd

const DIR := "user://gate_gltf_packer"

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()
	_rm(ProjectSettings.globalize_path(DIR))
	print("\n%d checks — %s" % [checks, "PASS" if ok else "FAIL"])
	quit(0 if ok else 1)
	return true


## Remove ONLY this gate's own scratch tree.
func _rm(path: String) -> void:
	if not path.contains("gate_gltf_packer"):
		return
	var d := DirAccess.open(path)
	if d == null:
		return
	for f in d.get_files():
		d.remove(f)
	for sub in d.get_directories():
		_rm(path.path_join(sub))
	DirAccess.remove_absolute(path)


func _scene() -> Node3D:
	var root := Node3D.new()
	root.name = "Model"
	var mi := MeshInstance3D.new()
	mi.name = "Box"
	mi.mesh = BoxMesh.new()
	var img := Image.create_empty(16, 16, false, Image.FORMAT_RGBA8)
	img.fill(Color(0.2, 0.6, 0.9))
	var mat := StandardMaterial3D.new()
	mat.albedo_texture = ImageTexture.create_from_image(img)
	mi.material_override = mat
	root.add_child(mi)
	mi.owner = root
	return root


func _json(path: String) -> Dictionary:
	return JSON.parse_string(FileAccess.get_file_as_string(path))


func _write_json(path: String, j: Dictionary) -> void:
	var f := FileAccess.open(path, FileAccess.WRITE)
	f.store_string(JSON.stringify(j))
	f.close()


func _refused(j: Dictionary, base: String, label: String, want: String) -> void:
	var r := GltfPacker.pack(j, base)
	_p(not bool(r["ok"]) and String(r["reason"]).contains(want), label, r["reason"])


func _run() -> void:
	var dir := ProjectSettings.globalize_path(DIR)
	DirAccess.make_dir_recursive_absolute(dir + "/model")
	var gltf := dir + "/model/box.gltf"
	var root := _scene()
	var doc := GLTFDocument.new()
	var st := GLTFState.new()
	_p(doc.append_from_scene(root, st) == OK, "exporter builds the scene")
	_p(doc.write_to_filesystem(st, gltf) == OK, "Godot writes a multi-file .gltf")
	var st2 := GLTFState.new()
	doc.append_from_scene(root, st2)
	var ref := GlbCheck.check(doc.generate_buffer(st2))
	root.free()
	var src := _json(gltf)
	var side: Array = []
	for b: Variant in src.get("buffers", []):
		if (b as Dictionary).has("uri"):
			side.append(String(b["uri"]))
	for im: Variant in src.get("images", []):
		if (im as Dictionary).has("uri"):
			side.append(String(im["uri"]))
	_p(side.size() >= 2, "  with side files (a .bin and a texture)", side)

	# ---- G1 ----
	var r := GltfPacker.pack_file(gltf)
	_p(bool(r["ok"]), "G1 the .gltf packs", r["reason"])
	var v := GlbCheck.check(r["bytes"])
	_p(bool(v["ok"]), "G1 the result passes GlbCheck (no uri left)", v["reason"])
	_p(int(v["triangles"]) == int(ref["triangles"]), "G1 same triangles as the .glb export",
		"%d vs %d" % [int(v["triangles"]), int(ref["triangles"])])
	_p((v["json"] as Dictionary).get("images", []).size() == 1, "G1 the texture came along, as a bufferView")
	var lst := GLTFState.new()
	_p(GLTFDocument.new().append_from_buffer(r["bytes"], "", lst) == OK and lst.get_images().size() == 1,
		"G1 Godot loads the packed .glb with its texture")

	# ---- G2: everything as base64 data: URIs ----
	var emb := src.duplicate(true)
	for b: Variant in emb["buffers"]:
		var raw := FileAccess.get_file_as_bytes(dir + "/model/" + String(b["uri"]).uri_decode())
		b["uri"] = "data:application/octet-stream;base64," + Marshalls.raw_to_base64(raw)
	for im: Variant in emb["images"]:
		var raw2 := FileAccess.get_file_as_bytes(dir + "/model/" + String(im["uri"]).uri_decode())
		im["uri"] = "data:image/png;base64," + Marshalls.raw_to_base64(raw2)
		im.erase("mimeType")
	var r2 := GltfPacker.pack(emb, dir + "/model")
	_p(bool(r2["ok"]) and bool(GlbCheck.check(r2["bytes"])["ok"]), "G2 data: URIs pack and pass", r2["reason"])

	# ---- G3: a percent-encoded name ----
	var img_uri := String((src["images"] as Array)[0]["uri"])
	DirAccess.copy_absolute(dir + "/model/" + img_uri.uri_decode(), dir + "/model/my tex.png")
	var pct := src.duplicate(true)
	(pct["images"] as Array)[0]["uri"] = "my%20tex.png"
	_p(bool(GltfPacker.pack(pct, dir + "/model")["ok"]), "G3 'my%20tex.png' resolves to 'my tex.png'")

	# ---- G4: everything that would read outside the folder ----
	var bin_uri := String((src["buffers"] as Array)[0]["uri"])
	DirAccess.copy_absolute(dir + "/model/" + bin_uri.uri_decode(), dir + "/outside.bin")
	for bad: Array in [["../outside.bin", "'..'"], ["sub/../../outside.bin", "'..'"],
			["/etc/hostname", "absolute"], ["C:\\Windows\\win.ini", "absolute"],
			["http://example.com/x.bin", "absolute"], ["file:///etc/hostname", "absolute"],
			["nothere.bin", "missing"]]:
		var j := src.duplicate(true)
		(j["buffers"] as Array)[0]["uri"] = bad[0]
		_refused(j, dir + "/model", "G4 buffer uri %s refused" % bad[0], String(bad[1]))
	var dj := src.duplicate(true)
	(dj["buffers"] as Array)[0]["uri"] = "data:application/octet-stream,rawbytes"
	_refused(dj, dir + "/model", "G4 a non-base64 data: URI is refused", "base64")
	# A symlink INSIDE the folder that points outside it.
	OS.execute("ln", PackedStringArray(["-s", dir, dir + "/model/escape"]))
	if DirAccess.open(dir + "/model").is_link("escape"):
		var lj := src.duplicate(true)
		(lj["buffers"] as Array)[0]["uri"] = "escape/outside.bin"
		_refused(lj, dir + "/model", "G4 a path through a symlink is refused", "link")
		DirAccess.remove_absolute(dir + "/model/escape")
	else:
		_p(false, "G4 could not make a symlink to test with")