179 lines
5.4 KiB
GDScript
179 lines
5.4 KiB
GDScript
class_name ProgressionSaveCodec
|
|
extends RefCounted
|
|
|
|
const MAGIC := "straywild_PROGRESSION_CONTAINER"
|
|
const FORMAT_VERSION := 1
|
|
const LOCAL_KIND := "local_save"
|
|
const ARCHIVE_KIND := "portable_archive"
|
|
const MAX_CONTAINER_BYTES := 16 * 1024 * 1024
|
|
|
|
# This key keeps routine progression files opaque to casual editing. Since the
|
|
# client is open source, it is intentionally not treated as a security secret.
|
|
const CONTAINER_PASSPHRASE := (
|
|
"straywild progression container v1 / not an identity credential"
|
|
)
|
|
|
|
|
|
static func encode_local_save(
|
|
save_data: Dictionary,
|
|
scratch_path: String,
|
|
) -> PackedByteArray:
|
|
return _encode_container(LOCAL_KIND, save_data, scratch_path)
|
|
|
|
|
|
static func encode_archive(
|
|
save_data: Dictionary,
|
|
scratch_path: String,
|
|
) -> PackedByteArray:
|
|
return _encode_container(ARCHIVE_KIND, save_data, scratch_path)
|
|
|
|
|
|
static func read_local_save(
|
|
path: String,
|
|
allow_legacy_plaintext: bool = false,
|
|
) -> Dictionary:
|
|
if allow_legacy_plaintext and not _looks_encrypted(path):
|
|
var legacy: Dictionary = _read_plaintext_dictionary(path)
|
|
if bool(legacy.get("ok", false)):
|
|
legacy["legacy_plaintext"] = true
|
|
return legacy
|
|
return _read_container(path, LOCAL_KIND)
|
|
|
|
|
|
static func read_archive(path: String) -> Dictionary:
|
|
if not _looks_encrypted(path):
|
|
return {"ok": false, "message": "progression archive is not recognized."}
|
|
return _read_container(path, ARCHIVE_KIND)
|
|
|
|
|
|
static func _encode_container(
|
|
kind: String,
|
|
save_data: Dictionary,
|
|
scratch_path: String,
|
|
) -> PackedByteArray:
|
|
if save_data.is_empty() or scratch_path.is_empty():
|
|
return PackedByteArray()
|
|
var payload_json: String = JSON.stringify(save_data)
|
|
if payload_json.is_empty():
|
|
return PackedByteArray()
|
|
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
|
|
var envelope: Dictionary = {
|
|
"magic": MAGIC,
|
|
"format_version": FORMAT_VERSION,
|
|
"kind": kind,
|
|
"game_version": str(
|
|
ProjectSettings.get_setting("application/config/version", "")
|
|
),
|
|
"created_at_unix": int(Time.get_unix_time_from_system()),
|
|
"payload_sha256": PortableFileGuard.hash_bytes(payload_bytes),
|
|
"payload_json": payload_json,
|
|
}
|
|
if DirAccess.make_dir_recursive_absolute(
|
|
scratch_path.get_base_dir()
|
|
) != OK:
|
|
return PackedByteArray()
|
|
_remove_if_present(scratch_path)
|
|
var file: FileAccess = FileAccess.open_encrypted_with_pass(
|
|
scratch_path,
|
|
FileAccess.WRITE,
|
|
CONTAINER_PASSPHRASE,
|
|
)
|
|
if file == null:
|
|
return PackedByteArray()
|
|
file.store_string(JSON.stringify(envelope))
|
|
file.flush()
|
|
var write_ok: bool = file.get_error() == OK
|
|
file.close()
|
|
if not write_ok:
|
|
_remove_if_present(scratch_path)
|
|
return PackedByteArray()
|
|
var encoded: PackedByteArray = PortableFileGuard.read_bytes(
|
|
scratch_path,
|
|
MAX_CONTAINER_BYTES,
|
|
)
|
|
_remove_if_present(scratch_path)
|
|
return encoded
|
|
|
|
|
|
static func _read_container(path: String, expected_kind: String) -> Dictionary:
|
|
if (
|
|
path.is_empty()
|
|
or not FileAccess.file_exists(path)
|
|
or FileAccess.get_size(path) > MAX_CONTAINER_BYTES
|
|
):
|
|
return {"ok": false, "message": "progression file is unavailable."}
|
|
var file: FileAccess = FileAccess.open_encrypted_with_pass(
|
|
path,
|
|
FileAccess.READ,
|
|
CONTAINER_PASSPHRASE,
|
|
)
|
|
if file == null:
|
|
return {"ok": false, "message": "progression file could not be opened."}
|
|
var envelope_text: String = file.get_as_text()
|
|
file.close()
|
|
var envelope_json := JSON.new()
|
|
if (
|
|
envelope_json.parse(envelope_text) != OK
|
|
or typeof(envelope_json.data) != TYPE_DICTIONARY
|
|
):
|
|
return {"ok": false, "message": "progression container is malformed."}
|
|
var envelope: Dictionary = envelope_json.data
|
|
if (
|
|
envelope.get("magic") != MAGIC
|
|
or int(envelope.get("format_version", -1)) != FORMAT_VERSION
|
|
or str(envelope.get("kind", "")) != expected_kind
|
|
):
|
|
return {"ok": false, "message": "progression container is unsupported."}
|
|
var payload_json: String = str(envelope.get("payload_json", ""))
|
|
var payload_bytes: PackedByteArray = payload_json.to_utf8_buffer()
|
|
if (
|
|
payload_json.is_empty()
|
|
or str(envelope.get("payload_sha256", ""))
|
|
!= PortableFileGuard.hash_bytes(payload_bytes)
|
|
):
|
|
return {"ok": false, "message": "progression container failed validation."}
|
|
var payload_parser := JSON.new()
|
|
if (
|
|
payload_parser.parse(payload_json) != OK
|
|
or typeof(payload_parser.data) != TYPE_DICTIONARY
|
|
):
|
|
return {"ok": false, "message": "progression payload is malformed."}
|
|
return {
|
|
"ok": true,
|
|
"data": payload_parser.data,
|
|
"game_version": str(envelope.get("game_version", "")),
|
|
"created_at_unix": int(envelope.get("created_at_unix", 0)),
|
|
"legacy_plaintext": false,
|
|
}
|
|
|
|
|
|
static func _read_plaintext_dictionary(path: String) -> Dictionary:
|
|
var bytes: PackedByteArray = PortableFileGuard.read_bytes(
|
|
path,
|
|
MAX_CONTAINER_BYTES,
|
|
)
|
|
if bytes.is_empty():
|
|
return {"ok": false}
|
|
var text: String = bytes.get_string_from_utf8()
|
|
if not text.strip_edges().begins_with("{"):
|
|
return {"ok": false}
|
|
var parser := JSON.new()
|
|
if parser.parse(text) != OK or typeof(parser.data) != TYPE_DICTIONARY:
|
|
return {"ok": false}
|
|
return {"ok": true, "data": parser.data}
|
|
|
|
|
|
static func _looks_encrypted(path: String) -> bool:
|
|
if not FileAccess.file_exists(path) or FileAccess.get_size(path) < 4:
|
|
return false
|
|
var file := FileAccess.open(path, FileAccess.READ)
|
|
if file == null:
|
|
return false
|
|
var header: PackedByteArray = file.get_buffer(4)
|
|
file.close()
|
|
return header == PackedByteArray([0x47, 0x44, 0x45, 0x43])
|
|
|
|
|
|
static func _remove_if_present(path: String) -> void:
|
|
if FileAccess.file_exists(path):
|
|
DirAccess.remove_absolute(path)
|