Files
agame/addons/godot_ai/utils/release_verifier.gd
T
2026-09-21 16:16:04 +08:00

877 lines
32 KiB
GDScript

@tool
class_name McpReleaseVerifier
extends RefCounted
## Pure, static verification of a signed v4 release: the canonical manifest,
## its RSA signature, the archive, and the tree hash the installer compares
## after extraction and again after restart.
##
## Every rule here mirrors `src/godot_ai/release_verify.py`. The two must stay
## byte-for-byte compatible: the release pipeline produces the manifest with
## Python's `_canonical()` and the installed-tree hash with Python's
## `hash_tree()`, and this file re-derives both from raw bytes. Every function
## fails closed with a specific error string and never raises.
const REPOSITORY := "hi-godot/godot-ai"
const CHANNEL := "stable"
const ASSET_NAME := "godot-ai-v4-plugin.zip"
const PLUGIN_PREFIX := "addons/godot_ai/"
const PLUGIN_CONFIG := "addons/godot_ai/plugin.cfg"
const SCHEMA_VERSION := 1
const MAX_FILES := 4096
const MAX_FILE_SIZE := 64 * 1024 * 1024
const MAX_ARCHIVE_SIZE := 64 * 1024 * 1024
const MAX_TREE_SIZE := MAX_ARCHIVE_SIZE
const MAX_MANIFEST_SIZE := 1024 * 1024
## Signature length for the production RSA-4096 key. The check itself is
## derived from the supplied key's modulus so a smaller injected test key
## still verifies its own (shorter) signatures.
const SIGNATURE_SIZE := 512
const MIN_RSA_MODULUS_BYTES := 256
## Largest integer JSON can round-trip through Godot's double-based parser.
const MAX_SAFE_INTEGER := 9007199254740992
const READ_CHUNK := 1024 * 1024
const ROOT_KEYS := [
"asset", "channel", "inventory", "repository", "schema_version", "source_commit", "tag", "version",
]
const ASSET_KEYS := ["name", "sha256", "size"]
const ROW_KEYS := ["path", "sha256", "size"]
const RESERVED_NAMES := [
"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"COM¹", "COM²", "COM³",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
"LPT¹", "LPT²", "LPT³",
]
const _UNSAFE_PATH_CHARS := "<>:\"|?*"
## DER-encoded AlgorithmIdentifier for rsaEncryption with NULL parameters.
const _RSA_ALGORITHM := [0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01, 0x05, 0x00]
# ----- public API -------------------------------------------------------
## Authenticate raw manifest bytes against a detached signature and the
## caller's explicit expectations. `expected` carries `repository` and
## `channel`, optionally `version` (must equal the manifest's), and optionally
## `current_version` (the manifest must be strictly newer). Returns
## `{"ok": bool, "error": String, "manifest": Dictionary}`; the returned
## manifest has integer sizes normalised to `int`.
static func verify_manifest(
manifest_bytes: PackedByteArray,
signature: PackedByteArray,
expected: Dictionary,
public_key_pem: String,
) -> Dictionary:
var result := {"ok": false, "error": "", "manifest": {}}
if manifest_bytes.is_empty() or manifest_bytes.size() > MAX_MANIFEST_SIZE:
result.error = "manifest: expected 1..%d bytes, got %d" % [MAX_MANIFEST_SIZE, manifest_bytes.size()]
return result
if not is_valid_utf8(manifest_bytes):
result.error = "manifest: not valid UTF-8"
return result
var json := JSON.new()
var parse_error := json.parse(manifest_bytes.get_string_from_utf8())
if parse_error != OK:
result.error = "manifest: invalid JSON: %s (line %d)" % [json.get_error_message(), json.get_error_line()]
return result
var canonical := canonical_json(json.data)
if not bool(canonical.ok):
result.error = "manifest: encoding is not canonical JSON: %s" % str(canonical.error)
return result
if canonical.bytes != manifest_bytes:
result.error = "manifest: encoding is not canonical JSON"
return result
if not json.data is Dictionary:
result.error = "manifest: expected a JSON object"
return result
var validated := validate_manifest_dict(json.data)
if not bool(validated.ok):
result.error = str(validated.error)
return result
var manifest: Dictionary = validated.manifest
var identity_error := _check_identity(manifest, expected)
if not identity_error.is_empty():
result.error = identity_error
return result
var signature_error := verify_signature(manifest_bytes, signature, public_key_pem)
if not signature_error.is_empty():
result.error = signature_error
return result
result.ok = true
result.manifest = manifest
return result
## Check a zip on disk against an already-verified manifest: whole-file size
## and SHA-256 against `manifest.asset`, then the entry list against the
## inventory (exact names, exact order, no directory entries), then every
## entry's size and SHA-256 against its row. Returns `{"ok", "error"}`.
static func verify_archive(zip_path: String, manifest: Dictionary) -> Dictionary:
var result := {"ok": false, "error": ""}
var validated := validate_manifest_dict(manifest)
if not bool(validated.ok):
result.error = str(validated.error)
return result
var normalised: Dictionary = validated.manifest
var asset: Dictionary = normalised["asset"]
var hashed := sha256_file(zip_path)
if not bool(hashed.ok):
result.error = "archive: %s" % str(hashed.error)
return result
if int(hashed["size"]) != int(asset["size"]):
result.error = "archive: size %d differs from signed manifest size %d" % [int(hashed["size"]), int(asset["size"])]
return result
if str(hashed.sha256) != str(asset["sha256"]):
result.error = "archive: SHA-256 differs from signed manifest"
return result
var reader := ZIPReader.new()
var open_error := reader.open(_absolute(zip_path))
if open_error != OK:
result.error = "archive: cannot open zip: %s" % error_string(open_error)
return result
var entries := reader.get_files()
var rows: Array = normalised["inventory"]
var expected_paths: Array[String] = []
var expected_rows := {}
for row in rows:
expected_paths.append(str(row["path"]))
expected_rows[str(row["path"])] = row
var listing_error := _check_entry_names(entries, expected_paths, expected_rows)
if not listing_error.is_empty():
reader.close()
result.error = listing_error
return result
var total := 0
var config := PackedByteArray()
for entry in entries:
var row: Dictionary = expected_rows[entry]
var data := reader.read_file(entry)
if data.size() != int(row["size"]):
reader.close()
result.error = "archive entry %s: size %d differs from inventory size %d" % [entry, data.size(), int(row["size"])]
return result
if sha256_bytes(data) != str(row["sha256"]):
reader.close()
result.error = "archive entry %s: content differs from inventory" % entry
return result
total += data.size()
if total > MAX_TREE_SIZE:
reader.close()
result.error = "archive: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE
return result
if entry == PLUGIN_CONFIG:
config = data
reader.close()
var config_version := plugin_cfg_version(config)
if config_version != str(normalised["version"]):
result.error = "archive: plugin.cfg version %s differs from signed manifest version %s" % [
config_version if not config_version.is_empty() else "(unreadable)", str(normalised["version"]),
]
return result
result.ok = true
return result
## Hash every regular file under `root` recursively. Returns
## `{"ok", "error", "files": {relative_path: {"size", "sha256"}}, "tree_sha256"}`.
##
## Tree-hash algorithm (shared verbatim with Python `release_verify.hash_tree`):
## relative POSIX paths sorted by UTF-8 byte order; for each file one line
## `sha256 + " " + str(size) + " " + path + "\n"`; `tree_sha256` is the hex
## SHA-256 of those lines concatenated. Symlinks anywhere in the tree fail.
static func hash_tree(root: String) -> Dictionary:
var result := {"ok": false, "error": "", "files": {}, "tree_sha256": ""}
var root_abs := _absolute(root)
if not DirAccess.dir_exists_absolute(root_abs):
result.error = "tree: directory does not exist: %s" % root_abs
return result
var files := {}
var pending: Array[String] = [""]
var total := 0
while not pending.is_empty():
var relative: String = pending.pop_back()
var directory_abs := root_abs if relative.is_empty() else root_abs.path_join(relative)
var directory := DirAccess.open(directory_abs)
if directory == null:
result.error = "tree: cannot open %s: %s" % [directory_abs, error_string(DirAccess.get_open_error())]
return result
directory.include_hidden = true
directory.include_navigational = false
for name in directory.get_directories():
var child_abs := directory_abs.path_join(name)
if directory.is_link(child_abs):
result.error = "tree: link is not allowed: %s" % child_abs
return result
pending.append(name if relative.is_empty() else relative + "/" + name)
for name in directory.get_files():
var child_abs := directory_abs.path_join(name)
if directory.is_link(child_abs):
result.error = "tree: link is not allowed: %s" % child_abs
return result
var hashed := sha256_file(child_abs)
if not bool(hashed.ok):
result.error = "tree: %s" % str(hashed.error)
return result
total += int(hashed["size"])
if files.size() >= MAX_FILES or total > MAX_TREE_SIZE:
result.error = "tree: exceeds %d files or %d bytes" % [MAX_FILES, MAX_TREE_SIZE]
return result
var relative_path := name if relative.is_empty() else relative + "/" + name
files[relative_path] = {"size": int(hashed["size"]), "sha256": str(hashed.sha256)}
var sorted_paths: Array[String] = []
for path in files:
sorted_paths.append(str(path))
sorted_paths.sort()
var ordered := {}
for path in sorted_paths:
ordered[path] = files[path]
result.files = ordered
result.tree_sha256 = tree_hash_from_files(ordered)
result.ok = true
return result
## The tree hash a correct extraction of `manifest.inventory` must produce:
## the same line algorithm as `hash_tree` over the inventory rows with the
## `addons/godot_ai/` prefix stripped. Only `inventory` is read, and row
## order does not matter. Returns "" for a malformed inventory or a row
## outside `addons/godot_ai/`, which can never equal a real hash.
static func inventory_tree_hash(manifest: Dictionary) -> String:
if not manifest.has("inventory") or not manifest["inventory"] is Array:
return ""
var files := {}
for value in manifest["inventory"]:
if not value is Dictionary:
return ""
var row: Dictionary = value
if not _check_keys(row, ROW_KEYS, "row").is_empty():
return ""
var size := _integer(row["size"], "row.size", MAX_FILE_SIZE)
if not row["path"] is String or not bool(size.ok) or not row["sha256"] is String:
return ""
var path := str(row["path"])
var digest := str(row["sha256"])
if not path.begins_with(PLUGIN_PREFIX) or path.length() == PLUGIN_PREFIX.length() or not _is_hex(digest, 64):
return ""
var relative := path.substr(PLUGIN_PREFIX.length())
if files.has(relative):
return ""
files[relative] = {"size": int(size.value), "sha256": digest}
if files.is_empty():
return ""
return tree_hash_from_files(files)
## Apply the tree-hash line algorithm to a `{relative_path: {size, sha256}}`
## dictionary (the `files` shape `hash_tree` returns).
static func tree_hash_from_files(files: Dictionary) -> String:
var paths: Array[String] = []
for path in files:
paths.append(str(path))
paths.sort()
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
for path in paths:
var entry: Dictionary = files[path]
var line := "%s %d %s\n" % [str(entry["sha256"]), int(entry["size"]), path]
context.update(line.to_utf8_buffer())
return context.finish().hex_encode()
## Hex SHA-256 of a byte buffer.
static func sha256_bytes(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
if not bytes.is_empty():
context.update(bytes)
return context.finish().hex_encode()
## Chunked SHA-256 of a file. Returns `{"ok", "error", "size", "sha256"}`;
## files beyond MAX_FILE_SIZE fail without being read to the end.
static func sha256_file(path: String) -> Dictionary:
var result := {"ok": false, "error": "", "size": 0, "sha256": ""}
var absolute := _absolute(path)
var file := FileAccess.open(absolute, FileAccess.READ)
if file == null:
result.error = "cannot read %s: %s" % [absolute, error_string(FileAccess.get_open_error())]
return result
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
var size := 0
while true:
var chunk := file.get_buffer(READ_CHUNK)
if chunk.is_empty():
break
size += chunk.size()
if size > MAX_FILE_SIZE:
file.close()
result.error = "%s exceeds %d-byte bound" % [absolute, MAX_FILE_SIZE]
return result
context.update(chunk)
var read_error := file.get_error()
file.close()
if read_error != OK and read_error != ERR_FILE_EOF:
result.error = "cannot read %s: %s" % [absolute, error_string(read_error)]
return result
result.size = size
result.sha256 = context.finish().hex_encode()
result.ok = true
return result
## Serialise `value` exactly as Python's
## `json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"`
## encodes it: sorted keys, no whitespace, integers (including integral
## floats, which is what Godot's JSON parser yields for them) as integers,
## non-ASCII unescaped. Returns `{"ok", "error", "bytes"}`. Non-integral
## numbers fail: the manifest schema has none and Godot's float formatting
## is not guaranteed to match Python's.
static func canonical_json(value: Variant) -> Dictionary:
var result := {"ok": false, "error": "", "bytes": PackedByteArray()}
var text := _canonical_value(value, result)
if not str(result.error).is_empty():
return result
result.bytes = (text + "\n").to_utf8_buffer()
result.ok = true
return result
## Strict UTF-8 validation: rejects overlong forms, surrogates, and code
## points beyond U+10FFFF, the same input Python's `bytes.decode` rejects.
static func is_valid_utf8(bytes: PackedByteArray) -> bool:
var index := 0
var size := bytes.size()
while index < size:
var lead := bytes[index]
if lead < 0x80:
index += 1
continue
var need := 0
var code := 0
if lead >= 0xC2 and lead <= 0xDF:
need = 1
code = lead & 0x1F
elif lead >= 0xE0 and lead <= 0xEF:
need = 2
code = lead & 0x0F
elif lead >= 0xF0 and lead <= 0xF4:
need = 3
code = lead & 0x07
else:
return false
if index + need >= size:
return false
for offset in range(1, need + 1):
var continuation := bytes[index + offset]
if (continuation & 0xC0) != 0x80:
return false
code = (code << 6) | (continuation & 0x3F)
if need == 2 and code < 0x800:
return false
if need == 3 and (code < 0x10000 or code > 0x10FFFF):
return false
if code >= 0xD800 and code <= 0xDFFF:
return false
index += need + 1
return true
## Validate a parsed manifest's schema, bounds, and inventory path safety
## without checking identity or the signature. Returns
## `{"ok", "error", "manifest"}` where `manifest` is a normalised copy with
## integer fields as `int`.
static func validate_manifest_dict(root: Dictionary) -> Dictionary:
var result := {"ok": false, "error": "", "manifest": {}}
var root_error := _check_keys(root, ROOT_KEYS, "manifest")
if not root_error.is_empty():
result.error = root_error
return result
var schema := _integer(root["schema_version"], "manifest.schema_version", SCHEMA_VERSION)
if not bool(schema.ok):
result.error = str(schema.error)
return result
if int(schema.value) != SCHEMA_VERSION:
result.error = "manifest.schema_version: expected %d" % SCHEMA_VERSION
return result
var manifest := {"schema_version": SCHEMA_VERSION}
for key in ["repository", "channel", "tag", "version", "source_commit"]:
var text := _string(root[key], "manifest.%s" % key)
if not bool(text.ok):
result.error = str(text.error)
return result
manifest[key] = str(text.value)
if not root["asset"] is Dictionary:
result.error = "manifest.asset: expected object"
return result
var asset: Dictionary = root["asset"]
var asset_error := _check_keys(asset, ASSET_KEYS, "manifest.asset")
if not asset_error.is_empty():
result.error = asset_error
return result
if not asset["name"] is String or str(asset["name"]) != ASSET_NAME:
result.error = "manifest.asset.name: expected %s" % ASSET_NAME
return result
var asset_size := _integer(asset["size"], "manifest.asset.size", MAX_ARCHIVE_SIZE)
if not bool(asset_size.ok):
result.error = str(asset_size.error)
return result
if not asset["sha256"] is String or not _is_hex(str(asset["sha256"]), 64):
result.error = "manifest.asset.sha256: expected 64 lowercase hex characters"
return result
manifest["asset"] = {"name": ASSET_NAME, "size": int(asset_size.value), "sha256": str(asset["sha256"])}
if not root["inventory"] is Array:
result.error = "manifest.inventory: expected array"
return result
var rows: Array = root["inventory"]
var inventory: Array = []
var paths: Array[String] = []
var total := 0
for index in rows.size():
var context := "manifest.inventory[%d]" % index
if not rows[index] is Dictionary:
result.error = "%s: expected object" % context
return result
var row: Dictionary = rows[index]
var row_error := _check_keys(row, ROW_KEYS, context)
if not row_error.is_empty():
result.error = row_error
return result
var path := _string(row["path"], context + ".path")
if not bool(path.ok):
result.error = str(path.error)
return result
var size := _integer(row["size"], context + ".size", MAX_FILE_SIZE)
if not bool(size.ok):
result.error = str(size.error)
return result
if not row["sha256"] is String or not _is_hex(str(row["sha256"]), 64):
result.error = "%s.sha256: expected 64 lowercase hex characters" % context
return result
total += int(size.value)
paths.append(str(path.value))
inventory.append({"path": str(path.value), "size": int(size.value), "sha256": str(row["sha256"])})
var paths_error := _validate_paths(paths, "manifest.inventory")
if not paths_error.is_empty():
result.error = paths_error
return result
if total > MAX_TREE_SIZE:
result.error = "manifest.inventory: expanded tree exceeds %d-byte bound" % MAX_TREE_SIZE
return result
if not paths.has(PLUGIN_CONFIG):
result.error = "manifest.inventory: missing %s" % PLUGIN_CONFIG
return result
manifest["inventory"] = inventory
result.manifest = manifest
result.ok = true
return result
## Verify a PKCS#1 v1.5 SHA-256 signature over `message` with a PEM
## SubjectPublicKeyInfo. Returns "" on success, otherwise the reason.
static func verify_signature(message: PackedByteArray, signature: PackedByteArray, public_key_pem: String) -> String:
var modulus_size := rsa_modulus_size(public_key_pem)
if modulus_size < MIN_RSA_MODULUS_BYTES:
return "public key: expected a PEM RSA SubjectPublicKeyInfo of at least %d bits" % (MIN_RSA_MODULUS_BYTES * 8)
if signature.size() != modulus_size:
return "manifest signature: expected exactly %d bytes, got %d" % [modulus_size, signature.size()]
var key := CryptoKey.new()
if key.load_from_string(public_key_pem, true) != OK:
return "public key: could not be loaded"
var crypto := Crypto.new()
var digest := PackedByteArray()
var context := HashingContext.new()
context.start(HashingContext.HASH_SHA256)
context.update(message)
digest = context.finish()
if not crypto.verify(HashingContext.HASH_SHA256, digest, signature, key):
return "manifest signature: RSA PKCS#1 v1.5 SHA-256 verification failed"
return ""
## Byte length of the RSA modulus inside a PEM SubjectPublicKeyInfo, or 0
## when the PEM is not a well-formed rsaEncryption public key.
static func rsa_modulus_size(public_key_pem: String) -> int:
var lines := public_key_pem.replace("\r\n", "\n").strip_edges().split("\n")
if (
lines.size() < 3
or lines[0].strip_edges() != "-----BEGIN PUBLIC KEY-----"
or lines[lines.size() - 1].strip_edges() != "-----END PUBLIC KEY-----"
):
return 0
var encoded := ""
for index in range(1, lines.size() - 1):
encoded += lines[index].strip_edges()
if encoded.is_empty() or not _is_base64(encoded):
return 0
var der := Marshalls.base64_to_raw(encoded)
if der.is_empty():
return 0
var outer := _der_item(der, 0, 0x30)
if not bool(outer.ok) or int(outer.end) != der.size():
return 0
var body: PackedByteArray = outer.value
var algorithm := _der_item(body, 0, 0x30)
if not bool(algorithm.ok) or algorithm.value != PackedByteArray(_RSA_ALGORITHM):
return 0
var bits := _der_item(body, int(algorithm.end), 0x03)
if not bool(bits.ok) or int(bits.end) != body.size():
return 0
var bit_string: PackedByteArray = bits.value
if bit_string.is_empty() or bit_string[0] != 0:
return 0
var key := _der_item(bit_string, 1, 0x30)
if not bool(key.ok) or int(key.end) != bit_string.size():
return 0
var key_body: PackedByteArray = key.value
var modulus := _der_item(key_body, 0, 0x02)
if not bool(modulus.ok):
return 0
var raw: PackedByteArray = modulus.value
if raw.is_empty() or raw[0] & 0x80:
return 0
if raw.size() > 1 and raw[0] == 0:
if not (raw[1] & 0x80):
return 0
raw = raw.slice(1)
var exponent := _der_item(key_body, int(modulus.end), 0x02)
if not bool(exponent.ok) or int(exponent.end) != key_body.size() or exponent.value.is_empty():
return 0
return raw.size()
## The single quoted `version="..."` line of a plugin.cfg, or "" when the
## file does not carry exactly one.
static func plugin_cfg_version(data: PackedByteArray) -> String:
if not is_valid_utf8(data):
return ""
var expression := RegEx.new()
if expression.compile("(?m)^\\s*version\\s*=\\s*\"([^\"]+)\"\\s*$") != OK:
return ""
var matches := expression.search_all(data.get_string_from_utf8())
if matches.size() != 1:
return ""
return matches[0].get_string(1)
## Parse a dotted `major.minor.patch` version into three ints, or [] when
## it is not exactly three unsigned decimal components.
static func version_parts(version: String) -> Array[int]:
var parts := version.split(".")
if parts.size() != 3:
return []
var numbers: Array[int] = []
for part in parts:
if not _all_digits(part):
return []
numbers.append(int(part))
return numbers
## True when `candidate` is strictly newer than `current` by tuple order.
static func is_newer_version(candidate: String, current: String) -> bool:
var left := version_parts(candidate)
var right := version_parts(current)
if left.is_empty() or right.is_empty():
return false
for index in 3:
if left[index] != right[index]:
return left[index] > right[index]
return false
# ----- identity ---------------------------------------------------------
static func _check_identity(manifest: Dictionary, expected: Dictionary) -> String:
var repository := str(manifest["repository"])
if repository != REPOSITORY or repository != str(expected.get("repository", REPOSITORY)):
return "manifest.repository: expected %s" % REPOSITORY
var channel := str(manifest["channel"])
if channel != CHANNEL or channel != str(expected.get("channel", CHANNEL)):
return "manifest.channel: v4 releases are stable-only"
var version := str(manifest["version"])
var parts := version_parts(version)
if parts.is_empty() or parts[0] != 4:
return "manifest.version: expected a 4.x.y version"
if str(manifest["tag"]) != "v" + version:
return "manifest.tag: expected v%s" % version
if not _is_hex(str(manifest["source_commit"]), 40):
return "manifest.source_commit: expected 40 lowercase hex characters"
var expected_version := str(expected.get("version", ""))
if not expected_version.is_empty() and expected_version != version:
return "manifest.version: %s does not match the expected release %s" % [version, expected_version]
var expected_tag := str(expected.get("tag", ""))
if not expected_tag.is_empty() and expected_tag != str(manifest["tag"]):
return "manifest.tag: %s does not match the expected tag %s" % [str(manifest["tag"]), expected_tag]
var expected_commit := str(expected.get("source_commit", ""))
if not expected_commit.is_empty() and expected_commit != str(manifest["source_commit"]):
return "manifest.source_commit: does not match the expected commit"
var current_version := str(expected.get("current_version", ""))
if not current_version.is_empty():
if version_parts(current_version).is_empty():
return "current_version: %s is not a dotted major.minor.patch version" % current_version
if not is_newer_version(version, current_version):
return "manifest.version: %s is not newer than the installed %s" % [version, current_version]
return ""
# ----- archive listing --------------------------------------------------
static func _check_entry_names(
entries: PackedStringArray, expected_paths: Array[String], expected_rows: Dictionary
) -> String:
var seen := {}
for entry in entries:
if entry.ends_with("/"):
return "archive: directory entry is not allowed: %s" % entry
if not expected_rows.has(entry):
return "archive: entry is not in the signed inventory: %s" % entry
if seen.has(entry):
return "archive: duplicate entry: %s" % entry
seen[entry] = true
for path in expected_paths:
if not seen.has(path):
return "archive: inventory file is missing from the zip: %s" % path
if Array(entries) != Array(expected_paths):
return "archive: entries are not in the sorted inventory order"
return ""
# ----- path safety ------------------------------------------------------
static func _validate_paths(paths: Array[String], context: String) -> String:
if paths.is_empty() or paths.size() > MAX_FILES:
return "%s: expected 1..%d paths" % [context, MAX_FILES]
var sorted := paths.duplicate()
sorted.sort()
var exact := {}
var identities := {}
for index in paths.size():
var path := paths[index]
var path_error := _check_path(path, "%s[%d]" % [context, index])
if not path_error.is_empty():
return path_error
if exact.has(path):
return "%s: duplicate path %s" % [context, path]
exact[path] = true
var identity := path.to_lower()
if identities.has(identity):
return "%s: case-colliding paths %s and %s" % [context, str(identities[identity]), path]
identities[identity] = path
if paths != sorted:
return "%s: paths are not in bytewise order" % context
for path in paths:
var parent := path.get_base_dir()
while parent.contains("/"):
if exact.has(parent):
return "%s: file/ancestor collision at %s" % [context, parent]
parent = parent.get_base_dir()
return ""
static func _check_path(path: String, context: String) -> String:
if not path.begins_with(PLUGIN_PREFIX) or path.ends_with("/") or path.contains("\\"):
return "%s: path must be a file beneath %s: %s" % [context, PLUGIN_PREFIX, path]
for part in path.split("/"):
if part.is_empty() or part == "." or part == ".." or part.ends_with(" ") or part.ends_with("."):
return "%s: unsafe path component in %s" % [context, path]
for index in part.length():
var code := part.unicode_at(index)
if code < 32 or _UNSAFE_PATH_CHARS.contains(char(code)):
return "%s: unsafe path character in %s" % [context, path]
var stem := part.split(".", true, 1)[0]
if RESERVED_NAMES.has(stem.to_upper()):
return "%s: reserved path component in %s" % [context, path]
return ""
# ----- schema helpers ---------------------------------------------------
static func _check_keys(value: Dictionary, expected_keys: Array, context: String) -> String:
var keys := value.keys()
for key in keys:
if not key is String:
return "%s: expected object with exactly keys %s" % [context, str(expected_keys)]
keys.sort()
if keys != expected_keys:
return "%s: expected object with exactly keys %s" % [context, str(expected_keys)]
return ""
static func _string(value: Variant, context: String) -> Dictionary:
if not value is String or str(value).is_empty():
return {"ok": false, "error": "%s: expected non-empty string" % context, "value": ""}
return {"ok": true, "error": "", "value": str(value)}
## Accept `int`, or the integral `float` Godot's JSON parser yields, within
## `[0, maximum]`. Booleans are rejected.
static func _integer(value: Variant, context: String, maximum: int) -> Dictionary:
var failure := {"ok": false, "error": "%s: expected integer in [0, %d]" % [context, maximum], "value": 0}
var number := 0
if value is int:
number = value
elif value is float:
var real: float = value
if real != floor(real) or absf(real) > MAX_SAFE_INTEGER:
return failure
number = int(real)
else:
return failure
if number < 0 or number > maximum:
return failure
return {"ok": true, "error": "", "value": number}
static func _is_hex(text: String, length: int) -> bool:
if text.length() != length:
return false
for index in text.length():
var code := text.unicode_at(index)
if not ((code >= 48 and code <= 57) or (code >= 97 and code <= 102)):
return false
return true
static func _all_digits(text: String) -> bool:
if text.is_empty():
return false
for index in text.length():
var code := text.unicode_at(index)
if code < 48 or code > 57:
return false
return true
static func _is_base64(text: String) -> bool:
for index in text.length():
var code := text.unicode_at(index)
var ok := (
(code >= 65 and code <= 90)
or (code >= 97 and code <= 122)
or (code >= 48 and code <= 57)
or code == 43 or code == 47 or code == 61
)
if not ok:
return false
return true
# ----- canonical JSON ---------------------------------------------------
static func _canonical_value(value: Variant, result: Dictionary) -> String:
match typeof(value):
TYPE_NIL:
return "null"
TYPE_BOOL:
return "true" if value else "false"
TYPE_INT:
return String.num_int64(value)
TYPE_FLOAT:
var real: float = value
if real != floor(real) or absf(real) > MAX_SAFE_INTEGER:
result.error = "non-integer number %s" % str(real)
return ""
return String.num_int64(int(real))
TYPE_STRING, TYPE_STRING_NAME:
return _escape_json_string(str(value))
TYPE_ARRAY:
var items: Array[String] = []
for item in value:
items.append(_canonical_value(item, result))
if not str(result.error).is_empty():
return ""
return "[" + ",".join(items) + "]"
TYPE_DICTIONARY:
var dictionary: Dictionary = value
var keys: Array = dictionary.keys()
for key in keys:
if not key is String:
result.error = "non-string object key"
return ""
keys.sort()
var members: Array[String] = []
for key in keys:
var member := _canonical_value(dictionary[key], result)
if not str(result.error).is_empty():
return ""
members.append(_escape_json_string(str(key)) + ":" + member)
return "{" + ",".join(members) + "}"
_:
result.error = "unsupported JSON value type %s" % type_string(typeof(value))
return ""
static func _escape_json_string(text: String) -> String:
var out := "\""
for index in text.length():
var code := text.unicode_at(index)
match code:
0x22:
out += "\\\""
0x5C:
out += "\\\\"
0x0A:
out += "\\n"
0x0D:
out += "\\r"
0x09:
out += "\\t"
0x08:
out += "\\b"
0x0C:
out += "\\f"
_:
if code < 0x20:
out += "\\u%04x" % code
else:
out += char(code)
return out + "\""
# ----- DER --------------------------------------------------------------
static func _der_item(data: PackedByteArray, offset: int, tag: int) -> Dictionary:
var failure := {"ok": false, "value": PackedByteArray(), "end": 0}
var size := data.size()
if offset >= size or data[offset] != tag:
return failure
offset += 1
if offset >= size:
return failure
var first := data[offset]
offset += 1
var length := 0
if first < 0x80:
length = first
else:
var count := first & 0x7F
if count < 1 or count > 4 or offset + count > size or data[offset] == 0:
return failure
for index in count:
length = (length << 8) | data[offset + index]
offset += count
if length < 0x80:
return failure
var end := offset + length
if end > size:
return failure
return {"ok": true, "value": data.slice(offset, end), "end": end}
# ----- paths ------------------------------------------------------------
static func _absolute(path: String) -> String:
if path.begins_with("res://") or path.begins_with("user://"):
return ProjectSettings.globalize_path(path)
return path