Add portable player data and identity backups

This commit is contained in:
Alexander Sellite 2026-07-29 23:51:25 -04:00
parent 141b54261d
commit ba4476511f
23 changed files with 2132 additions and 237 deletions

View file

@ -4,13 +4,19 @@ extends Node
signal bans_changed
const FORMAT_VERSION := 1
const STORE_PATH := "user://host_bans.json"
const TEMP_PATH := STORE_PATH + ".tmp"
const MAX_BANS := 500
var _namespaces: Dictionary = {}
var _loaded := false
var _write_blocked := false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func is_banned(host_fingerprint: String, target_fingerprint: String) -> bool:
@ -83,9 +89,9 @@ func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
var file := FileAccess.open(_store_path, FileAccess.READ)
if file == null:
return
var json := JSON.new()
@ -106,27 +112,23 @@ func _ensure_loaded() -> void:
var records: Dictionary = _namespaces.get(host, {})
records[target] = record.duplicate(true)
_namespaces[host] = records
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _save() -> bool:
var values: Array = []
for records: Dictionary in _namespaces.values():
values.append_array(records.values())
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"records": values,
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))

View file

@ -0,0 +1,207 @@
class_name IdentityBackupService
extends Node
signal operation_finished(success: bool, message: String)
const MAGIC := "NETFISHING_IDENTITY_BACKUP"
const FORMAT_VERSION := 1
const MAX_BACKUP_BYTES := 256 * 1024
const MIN_PASSPHRASE_LENGTH := 12
const MAX_PASSPHRASE_LENGTH := 256
var _data_root: PlayerDataRoot
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
func setup(
data_root: PlayerDataRoot,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
) -> void:
_data_root = data_root
_player_identity = player_identity
_host_identity = host_identity
func default_export_path(identity_type: String) -> String:
var store := _store(identity_type)
if store == null:
return ""
if not store.is_ready() and not store.load_or_create():
return ""
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
return _data_root.identity_backup_directory().path_join(
"%s-%s-%s.nfidentity" % [
identity_type,
NetworkIdentityCrypto.compact_suffix(store.fingerprint),
timestamp,
]
)
func export_backup(
identity_type: String,
path: String,
passphrase: String,
confirmation: String,
) -> bool:
if not _valid_passphrase(passphrase) or passphrase != confirmation:
return _finish(false, "Passphrases must match and contain at least 12 characters.")
if FileAccess.file_exists(path):
return _finish(false, "Choose a new backup filename.")
var store := _store(identity_type)
if store == null or (not store.is_ready() and not store.load_or_create()):
return _finish(false, "The active identity is unavailable.")
var material := store.export_identity_material()
var proof := store.sign("identity_backup_self_test", [
identity_type, material["fingerprint"],
])
var envelope := {
"magic": MAGIC,
"format_version": FORMAT_VERSION,
"identity_type": identity_type,
"algorithm": NetworkIdentityCrypto.ALGORITHM,
"private_pem": material["private_pem"],
"public_pem": material["public_pem"],
"fingerprint": material["fingerprint"],
"created_at_unix": int(Time.get_unix_time_from_system()),
"source_device_id": _data_root.device_id,
"self_signature": Marshalls.raw_to_base64(proof),
}
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.WRITE, passphrase)
if file == null:
return _finish(false, "Could not write this identity backup.")
file.store_string(JSON.stringify(envelope))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return _finish(false, "Could not write this identity backup.")
var verified := inspect_backup(path, passphrase, identity_type)
if not bool(verified.get("ok", false)):
DirAccess.remove_absolute(path)
return _finish(false, "Could not verify this identity backup.")
return _finish(true, "Encrypted identity backup created.")
func inspect_backup(
path: String,
passphrase: String,
expected_type: String,
) -> Dictionary:
if (
not _valid_passphrase(passphrase)
or not FileAccess.file_exists(path)
or FileAccess.get_size(path) > MAX_BACKUP_BYTES
):
return {"ok": false}
var file := FileAccess.open_encrypted_with_pass(path, FileAccess.READ, passphrase)
if file == null:
return {"ok": false}
var text := file.get_as_text()
file.close()
var json := JSON.new()
if json.parse(text) != OK or typeof(json.data) != TYPE_DICTIONARY:
return {"ok": false}
var data: Dictionary = json.data
if (
data.get("magic") != MAGIC
or data.get("format_version") != FORMAT_VERSION
or data.get("identity_type") != expected_type
or data.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
):
return {"ok": false}
var private_pem := str(data.get("private_pem", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
str(data.get("public_pem", ""))
)
var fingerprint := str(data.get("fingerprint", ""))
if (
private_pem.length() > 128 * 1024
or public_pem.length() > 32 * 1024
or NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
):
return {"ok": false}
var key := CryptoKey.new()
if key.load_from_string(private_pem) != OK:
return {"ok": false}
var signature := Marshalls.base64_to_raw(str(data.get("self_signature", "")))
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"identity_backup_self_test",
[expected_type, fingerprint],
signature,
):
return {"ok": false}
var fresh := NetworkIdentityCrypto.sign_fields(
key, "identity_import_self_test", [fingerprint]
)
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"identity_import_self_test",
[fingerprint],
fresh,
):
return {"ok": false}
return {
"ok": true,
"identity_type": expected_type,
"fingerprint": fingerprint,
"private_pem": private_pem,
"public_pem": public_pem,
}
func import_backup(
identity_type: String,
path: String,
passphrase: String,
confirmed_replacement: bool,
) -> Dictionary:
var inspected := inspect_backup(path, passphrase, identity_type)
if not bool(inspected.get("ok", false)):
_finish(false, "Could not open this identity backup.")
return {"ok": false}
var store := _store(identity_type)
var incoming := str(inspected["fingerprint"])
if store.fingerprint == incoming:
_finish(true, "This identity is already active.")
return {"ok": true, "same": true}
if not confirmed_replacement:
return {
"ok": false,
"requires_confirmation": true,
"current_fingerprint": store.fingerprint,
"incoming_fingerprint": incoming,
}
var result := store.install_identity_material(
str(inspected["private_pem"]),
str(inspected["public_pem"]),
incoming,
)
_finish(
bool(result.get("ok", false)),
"Identity imported. Restart NETFISHING before multiplayer."
if bool(result.get("ok", false))
else "Could not install this identity backup.",
)
return result
func _store(identity_type: String) -> LocalSigningIdentityStore:
if identity_type == "player":
return _player_identity
if identity_type == "host":
return _host_identity
return null
func _valid_passphrase(value: String) -> bool:
return value.length() >= MIN_PASSPHRASE_LENGTH and value.length() <= MAX_PASSPHRASE_LENGTH
func _finish(success: bool, message: String) -> bool:
operation_finished.emit(success, message)
return success

View file

@ -0,0 +1 @@
uid://bu5q4a8jfvxkf

View file

@ -2,13 +2,19 @@ class_name KnownPlayerStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://known_players.json"
const TEMP_PATH: String = STORE_PATH + ".tmp"
const MAX_RECORDS: int = 500
var _records: Dictionary = {}
var _loaded: bool = false
var _write_blocked: bool = false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func observe(fingerprint: String, display_name: String) -> String:
@ -61,9 +67,9 @@ func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
var file := FileAccess.open(_store_path, FileAccess.READ)
if file == null:
return
var json := JSON.new()
@ -82,29 +88,25 @@ func _ensure_loaded() -> void:
var fingerprint := str(record.get("fingerprint", ""))
if NetworkIdentityCrypto.valid_fingerprint(fingerprint):
_records[fingerprint] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _save() -> bool:
if _write_blocked:
return false
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))
func _bound_records() -> void:

View file

@ -52,6 +52,69 @@ func verify(domain: String, fields: Array, signature: PackedByteArray) -> bool:
)
func export_identity_material() -> Dictionary:
if not is_ready():
return {}
return {
"private_pem": _private_key.save_to_string(),
"public_pem": public_pem,
"fingerprint": fingerprint,
}
func install_identity_material(
private_pem: String,
public_value: String,
expected_fingerprint: String,
) -> Dictionary:
var normalized_public := NetworkIdentityCrypto.normalize_public_pem(public_value)
var derived := NetworkIdentityCrypto.fingerprint_public_pem(normalized_public)
var key := CryptoKey.new()
if (
derived != expected_fingerprint
or key.load_from_string(private_pem) != OK
):
return {"ok": false}
var probe := NetworkIdentityCrypto.sign_fields(
key, "identity_import_self_test", [derived]
)
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(normalized_public),
"identity_import_self_test",
[derived],
probe,
):
return {"ok": false}
if derived == fingerprint:
return {"ok": true, "same": true, "archive_path": ""}
var had_active_identity := is_ready()
var archive := _archive_current_identity() if had_active_identity else ""
if had_active_identity and archive.is_empty():
return {"ok": false}
var metadata := {
"format_version": FORMAT_VERSION,
"algorithm": NetworkIdentityCrypto.ALGORITHM,
"fingerprint": derived,
"created_at_unix": int(Time.get_unix_time_from_system()),
}
if (
not _write_atomic(_path(".key"), private_pem)
or not _write_atomic(_path(".pub"), normalized_public)
or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
):
if not archive.is_empty():
_restore_archive(archive)
_load_existing()
return {"ok": false}
FileAccess.set_unix_permissions(_path(".key"), 384)
if not _load_existing() or fingerprint != derived:
if not archive.is_empty():
_restore_archive(archive)
_load_existing()
return {"ok": false}
return {"ok": true, "same": false, "archive_path": archive}
func _generate_new() -> bool:
var key := Crypto.new().generate_rsa(3072)
if key == null:
@ -165,6 +228,46 @@ func _path(extension: String) -> String:
return "user://%s%s" % [_prefix, extension]
func identity_type() -> String:
return "player" if _prefix == "player_identity" else "host"
func _archive_current_identity() -> String:
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
var archive := ProjectSettings.globalize_path(
"user://identity-recovery/%s-%s" % [_prefix, timestamp]
)
if DirAccess.make_dir_recursive_absolute(archive) != OK:
return ""
for extension: String in [".key", ".pub", ".json"]:
var source := _path(extension)
var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
if bytes.is_empty():
return ""
var file := FileAccess.open(archive.path_join(_prefix + extension), FileAccess.WRITE)
if file == null:
return ""
file.store_buffer(bytes)
file.close()
FileAccess.set_unix_permissions(
archive.path_join(_prefix + ".key"), 384
)
return archive
func _restore_archive(archive: String) -> void:
for extension: String in [".key", ".pub", ".json"]:
var source := archive.path_join(_prefix + extension)
var bytes := PortableFileGuard.read_bytes(source, 1024 * 1024)
if bytes.is_empty():
continue
var file := FileAccess.open(_path(extension), FileAccess.WRITE)
if file != null:
file.store_buffer(bytes)
file.close()
FileAccess.set_unix_permissions(_path(".key"), 384)
func _rename(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),

View file

@ -2,25 +2,43 @@ class_name NetworkProfilePreferences
extends Node
const FORMAT_VERSION: int = 1
const PROFILE_PATH: String = "user://network_profile.json"
const TEMP_PATH: String = "user://network_profile.json.tmp"
const BACKUP_PATH: String = "user://network_profile.json.backup"
var profile_id: String = ""
var display_name: String = "Player"
var created_at_unix: int = 0
var _profile_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
var _future_version := false
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_profile_path = path
_data_root = data_root
func _temp_path() -> String:
return _profile_path + ".tmp"
func _backup_path() -> String:
return _profile_path + ".backup"
func load_or_create() -> bool:
if _profile_path.is_empty():
return false
_recover_interrupted_write()
if FileAccess.file_exists(PROFILE_PATH):
if FileAccess.file_exists(_profile_path):
if _load_existing():
_expected_hash = PortableFileGuard.hash_file(_profile_path)
return true
if _future_version:
return false
var corrupt_path: String = (
"user://network_profile.corrupt.%d.json"
% int(Time.get_unix_time_from_system())
"%s.corrupt.%d.json"
% [_profile_path.get_basename(), int(Time.get_unix_time_from_system())]
)
if not _rename(PROFILE_PATH, corrupt_path):
if not _rename(_profile_path, corrupt_path):
push_warning("Invalid network profile was preserved and not overwritten.")
return false
profile_id = Crypto.new().generate_random_bytes(16).hex_encode()
@ -58,7 +76,7 @@ static func is_valid_display_name(value: String) -> bool:
func _load_existing() -> bool:
var file := FileAccess.open(PROFILE_PATH, FileAccess.READ)
var file := FileAccess.open(_profile_path, FileAccess.READ)
if file == null:
return false
var json := JSON.new()
@ -67,6 +85,12 @@ func _load_existing() -> bool:
if error != OK or typeof(json.data) != TYPE_DICTIONARY:
return false
var data: Dictionary = json.data
if (
typeof(data.get("format_version")) in [TYPE_INT, TYPE_FLOAT]
and int(data.get("format_version")) > FORMAT_VERSION
):
_future_version = true
return false
if (
data.get("format_version") != FORMAT_VERSION
or typeof(data.get("profile_id")) != TYPE_STRING
@ -96,37 +120,31 @@ func _save_atomic() -> bool:
"display_name": display_name,
"created_at_unix": created_at_unix,
}
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify(data, "\t"))
file.flush()
var error: Error = file.get_error()
file.close()
if error != OK:
_remove_if_present(TEMP_PATH)
return false
_remove_if_present(BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(PROFILE_PATH)
if had_primary and not _rename(PROFILE_PATH, BACKUP_PATH):
_remove_if_present(TEMP_PATH)
return false
if not _rename(TEMP_PATH, PROFILE_PATH):
if had_primary:
_rename(BACKUP_PATH, PROFILE_PATH)
return false
_remove_if_present(BACKUP_PATH)
return true
var result := PortableFileGuard.write_guarded(
_profile_path,
JSON.stringify(data, "\t").to_utf8_buffer(),
_expected_hash,
_data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(
str(result.get("message", "")),
str(result.get("conflict_path", "")),
)
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(PROFILE_PATH):
_remove_if_present(TEMP_PATH)
_remove_if_present(BACKUP_PATH)
if FileAccess.file_exists(_profile_path):
_remove_if_present(_temp_path())
_remove_if_present(_backup_path())
return
if FileAccess.file_exists(BACKUP_PATH):
_rename(BACKUP_PATH, PROFILE_PATH)
_remove_if_present(TEMP_PATH)
if FileAccess.file_exists(_backup_path()):
_rename(_backup_path(), _profile_path)
_remove_if_present(_temp_path())
func _rename(from_path: String, to_path: String) -> bool:

View file

@ -292,6 +292,10 @@ func is_joined_client() -> bool:
return state == State.JOINED_CLIENT
func is_session_active() -> bool:
return is_host() or is_joined_client()
func get_player_count() -> int:
return _registry.size()

393
network/player_data_root.gd Normal file
View file

@ -0,0 +1,393 @@
class_name PlayerDataRoot
extends Node
signal status_changed(message: String)
signal conflict_detected(message: String, conflict_path: String)
const BOOTSTRAP_PATH := "user://data_root_bootstrap.json"
const BOOTSTRAP_TEMP_PATH := "user://data_root_bootstrap.json.tmp"
const BOOTSTRAP_VERSION := 1
const MANIFEST_VERSION := 1
const LAYOUT_VERSION := 1
const MANIFEST_FILENAME := "netfishing_data.json"
const README_FILENAME := "README.txt"
const ENVIRONMENT_VARIABLE := "NETFISHING_DATA_DIR"
const APPLICATION_ID := "netfishing"
const APP_DATA_PORTABLE_PATH := "user://portable-data"
enum Mode {
UNRESOLVED,
SELECTED_FOLDER,
APP_DATA,
ENVIRONMENT_OVERRIDE,
COMMAND_LINE_OVERRIDE,
}
var mode := Mode.UNRESOLVED
var root_path := ""
var root_id := ""
var device_id := ""
var error_message := ""
var requires_selection := false
var override_active := false
func resolve() -> bool:
_load_bootstrap_identity()
var command_line := _command_line_override()
if not command_line.is_empty():
override_active = true
mode = Mode.COMMAND_LINE_OVERRIDE
return _activate_existing(command_line, "", false)
if OS.has_environment(ENVIRONMENT_VARIABLE):
override_active = true
mode = Mode.ENVIRONMENT_OVERRIDE
var environment_path := OS.get_environment(ENVIRONMENT_VARIABLE)
if not environment_path.is_absolute_path():
return _fail("NETFISHING_DATA_DIR must be an absolute path.")
return _activate_existing(environment_path, "", false)
var bootstrap := _read_json(BOOTSTRAP_PATH, 64 * 1024)
if bootstrap.is_empty() and FileAccess.file_exists(BOOTSTRAP_PATH + ".backup"):
bootstrap = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
if not bootstrap.is_empty():
if bootstrap.get("format_version") != BOOTSTRAP_VERSION:
return _fail("The data-folder pointer uses an unsupported version.")
var selected := str(bootstrap.get("selected_absolute_path", ""))
var expected := str(bootstrap.get("expected_root_id", ""))
if selected.is_empty():
return _fail("The data-folder pointer is incomplete.")
mode = (
Mode.APP_DATA
if selected == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
else Mode.SELECTED_FOLDER
)
return _activate_existing(selected, expected, false)
requires_selection = true
error_message = ""
return false
func default_visible_path() -> String:
var documents := OS.get_system_dir(OS.SYSTEM_DIR_DOCUMENTS)
return documents.path_join("NETFISHING") if not documents.is_empty() else ""
func select_new_root(path: String, app_data: bool = false) -> bool:
if override_active:
return _fail("The data folder is controlled by a process override.")
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
var normalized := _normalize(path)
if app_data:
normalized = ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
if not _validate_candidate(normalized, true):
return false
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest := _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The selected folder has a malformed NETFISHING manifest.")
root_id = str(manifest["root_id"])
else:
if not _directory_is_empty(normalized) and not app_data:
return _fail(
"Choose an empty folder or an existing NETFISHING data folder."
)
root_id = Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, root_id):
return _fail("The NETFISHING data folder could not be created.")
if not _write_bootstrap(normalized, root_id):
return _fail("The data-folder pointer could not be saved.")
root_path = normalized
mode = Mode.APP_DATA if app_data else Mode.SELECTED_FOLDER
requires_selection = false
error_message = ""
status_changed.emit("Data folder ready.")
return true
func create_unbound_root(path: String) -> Dictionary:
var normalized := _normalize(path)
if not _validate_candidate(normalized, true):
return {"ok": false, "message": error_message}
if not _directory_is_empty(normalized):
return {"ok": false, "message": "The destination staging folder is not empty."}
var id := Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id):
return {"ok": false, "message": "The portable layout could not be created."}
return {"ok": true, "root_id": id}
func create_app_data_layout_for_migration(path: String) -> Dictionary:
var normalized := _normalize(path)
if not _validate_candidate(normalized, false):
return {"ok": false, "message": error_message}
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
if FileAccess.file_exists(manifest_path):
var manifest := _read_json(manifest_path)
return (
{"ok": true, "root_id": str(manifest.get("root_id", ""))}
if _valid_manifest(manifest)
else {"ok": false, "message": "The app-data manifest is malformed."}
)
var id := Crypto.new().generate_random_bytes(16).hex_encode()
if not _create_layout(normalized, id):
return {"ok": false, "message": "The app-data layout could not be created."}
return {"ok": true, "root_id": id}
func use_existing_root(path: String) -> bool:
if override_active:
return _fail("The data folder is controlled by a process override.")
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
var normalized := _normalize(path)
if not _activate_existing(normalized, "", true):
return false
if not _write_bootstrap(root_path, root_id):
root_path = ""
root_id = ""
return _fail("The data-folder pointer could not be saved.")
mode = (
Mode.APP_DATA
if normalized == ProjectSettings.globalize_path(APP_DATA_PORTABLE_PATH)
else Mode.SELECTED_FOLDER
)
return true
func path_for(owner: StringName) -> String:
var relative: String = {
&"player_save": "player/player_save.json",
&"network_profile": "player/network_profile.json",
&"player_appearance": "player/player_appearance.json",
&"saved_servers": "social/saved_servers.json",
&"known_players": "social/known_players.json",
&"player_relationships": "social/player_relationships.json",
&"server_trust": "social/server_trust.json",
&"host_bans": "social/host_bans.json",
}.get(owner, "")
return root_path.path_join(relative) if not relative.is_empty() else ""
func conflict_directory() -> String:
return root_path.path_join("backups/conflicts")
func identity_backup_directory() -> String:
return root_path.path_join("identity-backups")
func migration_backup_directory() -> String:
return root_path.path_join("backups/migrations")
func storage_mode_text() -> String:
return {
Mode.SELECTED_FOLDER: "Selected folder",
Mode.APP_DATA: "App data",
Mode.ENVIRONMENT_OVERRIDE: "Environment override",
Mode.COMMAND_LINE_OVERRIDE: "Command-line override",
}.get(mode, "Unavailable")
func open_folder() -> bool:
return not root_path.is_empty() and OS.shell_open(root_path) == OK
func report_conflict(message: String, conflict_path: String) -> void:
error_message = message
conflict_detected.emit(message, conflict_path)
status_changed.emit(message)
func _activate_existing(path: String, expected_id: String, permit_creation: bool) -> bool:
var normalized := _normalize(path)
if not _validate_candidate(normalized, permit_creation):
return false
var manifest_path := normalized.path_join(MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest_path):
return _fail("The selected folder is not a NETFISHING data folder.")
var manifest := _read_json(manifest_path)
if not _valid_manifest(manifest):
return _fail("The NETFISHING data-folder manifest is malformed.")
var found_id := str(manifest["root_id"])
if not expected_id.is_empty() and expected_id != found_id:
return _fail("The selected data folder does not match this device pointer.")
if not _test_writable(normalized):
return _fail("The NETFISHING data folder is unavailable or unwritable.")
root_path = normalized
root_id = found_id
requires_selection = false
error_message = ""
if PortableFileGuard.has_syncthing_conflict(root_path.path_join("player")):
status_changed.emit("Syncthing conflict copies were found. Review the data folder.")
return true
func _validate_candidate(path: String, create: bool) -> bool:
if path.is_empty() or not path.is_absolute_path():
return _fail("Choose an absolute filesystem folder.")
var project := ProjectSettings.globalize_path("res://").trim_suffix("/")
if path == project or path.begins_with(project + "/"):
return _fail("The project folder cannot be used as the player data folder.")
if not DirAccess.dir_exists_absolute(path):
if not create or DirAccess.make_dir_recursive_absolute(path) != OK:
return _fail("The selected data folder is unavailable.")
return _test_writable(path)
func _test_writable(path: String) -> bool:
var probe := path.path_join(".netfishing-write-%s.tmp" % device_id.left(12))
var file := FileAccess.open(probe, FileAccess.WRITE)
if file == null:
return false
file.store_string("probe")
file.close()
return DirAccess.remove_absolute(probe) == OK
func _create_layout(path: String, id: String) -> bool:
for relative: String in [
"player", "social", "backups/saves", "backups/migrations",
"backups/conflicts", "identity-backups",
]:
if DirAccess.make_dir_recursive_absolute(path.path_join(relative)) != OK:
return false
var now := int(Time.get_unix_time_from_system())
var manifest := {
"format_version": MANIFEST_VERSION,
"layout_version": LAYOUT_VERSION,
"application": APPLICATION_ID,
"root_id": id,
"created_at_unix": now,
"last_opened_at_unix": now,
}
if not _write_text_atomic(
path.path_join(MANIFEST_FILENAME), JSON.stringify(manifest, "\t")
):
return false
var readme := (
"NETFISHING player data\n\n"
+ "This folder is safe to synchronize with tools such as Syncthing.\n"
+ "Active private identity keys remain device-local.\n"
+ "Encrypted identity backups require their passphrase.\n"
+ "Chat and Session Mail are not stored here.\n"
+ "Do not play the same profile on two devices at the same time.\n"
+ "Conflicting edits are preserved under backups/conflicts; they are not merged.\n"
)
return _write_text_atomic(path.path_join(README_FILENAME), readme)
func _write_bootstrap(path: String, id: String) -> bool:
var now := int(Time.get_unix_time_from_system())
var data := {
"format_version": BOOTSTRAP_VERSION,
"selected_absolute_path": path,
"expected_root_id": id,
"device_id": device_id,
"selected_at_unix": now,
"last_successfully_opened_at_unix": now,
}
return _write_text_atomic(
ProjectSettings.globalize_path(BOOTSTRAP_PATH),
JSON.stringify(data, "\t"),
)
func _load_bootstrap_identity() -> void:
var data := _read_json(BOOTSTRAP_PATH, 64 * 1024)
if data.is_empty():
data = _read_json(BOOTSTRAP_PATH + ".backup", 64 * 1024)
device_id = str(data.get("device_id", ""))
if device_id.length() != 32:
device_id = Crypto.new().generate_random_bytes(16).hex_encode()
func _command_line_override() -> String:
var args := OS.get_cmdline_user_args()
for index: int in args.size():
var value := args[index]
if value.begins_with("--data-dir="):
return value.trim_prefix("--data-dir=")
if value == "--data-dir" and index + 1 < args.size():
return args[index + 1]
return ""
func _valid_manifest(data: Dictionary) -> bool:
return (
data.get("format_version") == MANIFEST_VERSION
and data.get("layout_version") == LAYOUT_VERSION
and data.get("application") == APPLICATION_ID
and typeof(data.get("root_id")) == TYPE_STRING
and str(data["root_id"]).length() == 32
)
func _directory_is_empty(path: String) -> bool:
var access := DirAccess.open(path)
if access == null:
return true
access.list_dir_begin()
var name := access.get_next()
while name in [".", ".."]:
name = access.get_next()
access.list_dir_end()
return name.is_empty()
func _normalize(path: String) -> String:
return path.simplify_path().trim_suffix("/")
func _read_json(path: String, maximum := 1024 * 1024) -> Dictionary:
if not FileAccess.file_exists(path):
return {}
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > maximum:
return {}
var json := JSON.new()
var error := json.parse(file.get_as_text())
file.close()
return json.data if error == OK and typeof(json.data) == TYPE_DICTIONARY else {}
func _write_text_atomic(path: String, text: String) -> bool:
var absolute := (
ProjectSettings.globalize_path(path)
if path.begins_with("user://")
else path
)
if DirAccess.make_dir_recursive_absolute(absolute.get_base_dir()) != OK:
return false
var temporary := absolute + ".tmp"
var file := FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return false
file.store_string(text)
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
var backup := absolute + ".backup"
if FileAccess.file_exists(backup):
DirAccess.remove_absolute(backup)
var had_primary := FileAccess.file_exists(absolute)
if had_primary and DirAccess.rename_absolute(absolute, backup) != OK:
DirAccess.remove_absolute(temporary)
return false
if DirAccess.rename_absolute(temporary, absolute) != OK:
if had_primary:
DirAccess.rename_absolute(backup, absolute)
return false
if FileAccess.file_exists(backup):
DirAccess.remove_absolute(backup)
return true
func _fail(message: String) -> bool:
error_message = message
status_changed.emit(message)
return false

View file

@ -0,0 +1 @@
uid://dpiv6wttry0j4

View file

@ -4,13 +4,19 @@ extends Node
signal relationship_changed(fingerprint: String)
const FORMAT_VERSION := 1
const STORE_PATH := "user://player_relationships.json"
const TEMP_PATH := STORE_PATH + ".tmp"
const MAX_RECORDS := 500
var _records: Dictionary = {}
var _loaded := false
var _write_blocked := false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func is_muted(fingerprint: String) -> bool:
@ -98,9 +104,9 @@ func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
var file := FileAccess.open(_store_path, FileAccess.READ)
if file == null:
return
var json := JSON.new()
@ -120,24 +126,20 @@ func _ensure_loaded() -> void:
record["blocked"] = bool(record.get("blocked", false))
record["muted"] = bool(record.get("muted", false)) or record["blocked"]
_records[fingerprint] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _save() -> bool:
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))

View file

@ -0,0 +1,325 @@
class_name PortableDataMigration
extends RefCounted
const LEGACY_FILES := {
"player_save.json": "player/player_save.json",
"network_profile.json": "player/network_profile.json",
"player_appearance.json": "player/player_appearance.json",
"saved_servers.json": "social/saved_servers.json",
"known_players.json": "social/known_players.json",
"player_relationships.json": "social/player_relationships.json",
"server_trust.json": "social/server_trust.json",
"host_bans.json": "social/host_bans.json",
}
static func legacy_files_present() -> bool:
for filename: String in LEGACY_FILES:
if FileAccess.file_exists("user://".path_join(filename)):
return true
return false
static func migrate_legacy_to(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
if DirAccess.dir_exists_absolute(normalized):
var existing_manifest := normalized.path_join(
PlayerDataRoot.MANIFEST_FILENAME
)
if FileAccess.file_exists(existing_manifest):
return {
"ok": false,
"requires_existing_root_decision": true,
"message": "The selected folder already contains NETFISHING data.",
}
if not _directory_empty(normalized):
return {
"ok": false,
"message": "Choose an empty folder or create a NETFISHING subfolder.",
}
var staging := "%s.migration-%s" % [
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
]
if DirAccess.make_dir_recursive_absolute(staging) != OK:
return {"ok": false, "message": "Migration staging could not be created."}
var created := data_root.create_unbound_root(staging)
if not bool(created.get("ok", false)):
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
var copied: Array[String] = []
for source_name: String in LEGACY_FILES:
var source := ProjectSettings.globalize_path(
"user://".path_join(source_name)
)
if not FileAccess.file_exists(source):
continue
var target := staging.path_join(LEGACY_FILES[source_name])
var result := _copy_verified_json(source, target)
if not bool(result.get("ok", false)):
_remove_tree(staging)
return {
"ok": false,
"message": "Migration failed while validating %s." % source_name,
}
copied.append(source_name)
if DirAccess.dir_exists_absolute(normalized):
DirAccess.remove_absolute(normalized)
if DirAccess.rename_absolute(staging, normalized) != OK:
_remove_tree(staging)
return {"ok": false, "message": "Migration could not activate its destination."}
var manifest := _read_json(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME))
var migrated_root_id := str(manifest.get("root_id", ""))
if migrated_root_id.is_empty() or not data_root.use_existing_root(normalized):
return {"ok": false, "message": "Migration completed but could not switch roots."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
Time.get_datetime_string_from_system().replace(":", "-")
)
DirAccess.make_dir_recursive_absolute(recovery)
for source_name: String in copied:
_copy_bytes(
ProjectSettings.globalize_path("user://".path_join(source_name)),
recovery.path_join(source_name),
)
return {
"ok": true,
"message": "Player data moved successfully.",
"recovery_path": recovery,
}
static func migrate_active_to(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
if normalized == data_root.root_path:
return {"ok": true, "message": "This data folder is already active."}
if DirAccess.dir_exists_absolute(normalized) and not _directory_empty(normalized):
if FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
return {
"ok": false,
"requires_existing_root_decision": true,
"message": (
"The selected folder already contains NETFISHING data. "
+ "Choose it through the existing-data recovery flow."
),
}
return {"ok": false, "message": "Choose an empty folder or a NETFISHING subfolder."}
var staging := "%s.migration-%s" % [
normalized, Crypto.new().generate_random_bytes(8).hex_encode()
]
if DirAccess.make_dir_recursive_absolute(staging) != OK:
return {"ok": false, "message": "Migration staging could not be created."}
var created := data_root.create_unbound_root(staging)
if not bool(created.get("ok", false)):
_remove_tree(staging)
return {"ok": false, "message": str(created.get("message", ""))}
for relative: String in [
"player/player_save.json",
"player/network_profile.json",
"player/player_appearance.json",
"social/saved_servers.json",
"social/known_players.json",
"social/player_relationships.json",
"social/server_trust.json",
"social/host_bans.json",
]:
var source := data_root.root_path.path_join(relative)
if not FileAccess.file_exists(source):
continue
if not bool(_copy_verified_json(source, staging.path_join(relative)).get("ok", false)):
_remove_tree(staging)
return {"ok": false, "message": "Migration validation failed for %s." % relative}
if DirAccess.dir_exists_absolute(normalized):
DirAccess.remove_absolute(normalized)
if DirAccess.rename_absolute(staging, normalized) != OK:
_remove_tree(staging)
return {"ok": false, "message": "Migration could not activate its destination."}
var old_root := data_root.root_path
if not data_root.use_existing_root(normalized):
return {"ok": false, "message": "Migration copied data but did not change the pointer."}
return {
"ok": true,
"message": "Data folder changed. Previous data was preserved.",
"previous_root": old_root,
}
static func adopt_legacy_app_data(data_root: PlayerDataRoot) -> Dictionary:
var source_root := ProjectSettings.globalize_path("user://").trim_suffix("/")
var root := ProjectSettings.globalize_path(PlayerDataRoot.APP_DATA_PORTABLE_PATH)
if DirAccess.make_dir_recursive_absolute(root) != OK:
return {"ok": false, "message": "The app-data folder could not be created."}
var created := data_root.create_app_data_layout_for_migration(root)
if not bool(created.get("ok", false)):
return created
for source_name: String in LEGACY_FILES:
var source := source_root.path_join(source_name)
if not FileAccess.file_exists(source):
continue
var target := root.path_join(LEGACY_FILES[source_name])
if not bool(_copy_verified_json(source, target).get("ok", false)):
return {
"ok": false,
"message": "Could not validate %s in app-data mode." % source_name,
}
if not data_root.use_existing_root(root):
return {"ok": false, "message": data_root.error_message}
return {
"ok": true,
"message": "Existing player data remains in app data.",
"recovery_path": source_root,
}
static func replace_existing_with_legacy(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
var manifest := normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)
if not FileAccess.file_exists(manifest):
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
)
if not _copy_tree(normalized, recovery):
return {"ok": false, "message": "The selected data could not be backed up."}
_remove_tree(normalized)
var result := migrate_legacy_to(data_root, normalized)
if bool(result.get("ok", false)):
result["replaced_root_backup"] = recovery
return result
static func replace_existing_with_active(
data_root: PlayerDataRoot,
destination: String,
) -> Dictionary:
var normalized := destination.simplify_path().trim_suffix("/")
if not FileAccess.file_exists(normalized.path_join(PlayerDataRoot.MANIFEST_FILENAME)):
return {"ok": false, "message": "The selected folder is not a NETFISHING data root."}
var recovery := ProjectSettings.globalize_path("user://migration-recovery").path_join(
"replaced-root-" + Time.get_datetime_string_from_system().replace(":", "-")
)
if not _copy_tree(normalized, recovery):
return {"ok": false, "message": "The selected data could not be backed up."}
_remove_tree(normalized)
var result := migrate_active_to(data_root, normalized)
if bool(result.get("ok", false)):
result["replaced_root_backup"] = recovery
return result
static func _copy_verified_json(source: String, destination: String) -> Dictionary:
var bytes := PortableFileGuard.read_bytes(source)
if bytes.is_empty() and FileAccess.get_open_error() != OK:
return {"ok": false}
var json := JSON.new()
if (
json.parse(bytes.get_string_from_utf8()) != OK
or typeof(json.data) != TYPE_DICTIONARY
or not _valid_owned_data(source.get_file(), json.data)
):
return {"ok": false}
if DirAccess.make_dir_recursive_absolute(destination.get_base_dir()) != OK:
return {"ok": false}
if not _write_bytes(destination, bytes):
return {"ok": false}
var copied := PortableFileGuard.read_bytes(destination)
return {
"ok": (
PortableFileGuard.hash_bytes(copied)
== PortableFileGuard.hash_bytes(bytes)
)
}
static func _valid_owned_data(filename: String, data: Dictionary) -> bool:
if filename == "player_save.json":
var version := int(data.get("save_version", -1))
return version >= 1 and version <= PlayerSaveManager.SAVE_VERSION
return (
filename not in LEGACY_FILES
or int(data.get("format_version", -1)) == 1
)
static func _copy_bytes(source: String, destination: String) -> bool:
return _write_bytes(destination, PortableFileGuard.read_bytes(source))
static func _copy_tree(source: String, destination: String) -> bool:
if DirAccess.make_dir_recursive_absolute(destination) != OK:
return false
var access := DirAccess.open(source)
if access == null:
return false
access.list_dir_begin()
var name := access.get_next()
while not name.is_empty():
var from := source.path_join(name)
var to := destination.path_join(name)
if access.current_is_dir():
if not _copy_tree(from, to):
access.list_dir_end()
return false
elif not _copy_bytes(from, to):
access.list_dir_end()
return false
name = access.get_next()
access.list_dir_end()
return true
static func _write_bytes(path: String, bytes: PackedByteArray) -> bool:
DirAccess.make_dir_recursive_absolute(path.get_base_dir())
var file := FileAccess.open(path, FileAccess.WRITE)
if file == null:
return false
file.store_buffer(bytes)
file.flush()
var ok := file.get_error() == OK
file.close()
return ok
static func _read_json(path: String) -> Dictionary:
var bytes := PortableFileGuard.read_bytes(path)
var json := JSON.new()
return (
json.data
if json.parse(bytes.get_string_from_utf8()) == OK
and typeof(json.data) == TYPE_DICTIONARY
else {}
)
static func _directory_empty(path: String) -> bool:
var access := DirAccess.open(path)
if access == null:
return true
access.list_dir_begin()
var name := access.get_next()
access.list_dir_end()
return name.is_empty()
static func _remove_tree(path: String) -> void:
var access := DirAccess.open(path)
if access == null:
return
access.list_dir_begin()
var name := access.get_next()
while not name.is_empty():
var child := path.path_join(name)
if access.current_is_dir():
_remove_tree(child)
else:
DirAccess.remove_absolute(child)
name = access.get_next()
access.list_dir_end()
DirAccess.remove_absolute(path)

View file

@ -0,0 +1 @@
uid://bjkaght4qyylm

View file

@ -0,0 +1,146 @@
class_name PortableFileGuard
extends RefCounted
const MAX_PORTABLE_FILE_BYTES := 16 * 1024 * 1024
static func hash_file(path: String) -> String:
if not FileAccess.file_exists(path):
return ""
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > MAX_PORTABLE_FILE_BYTES:
return ""
var bytes := file.get_buffer(file.get_length())
file.close()
return hash_bytes(bytes)
static func hash_bytes(bytes: PackedByteArray) -> String:
var context := HashingContext.new()
if context.start(HashingContext.HASH_SHA256) != OK:
return ""
if context.update(bytes) != OK:
return ""
return context.finish().hex_encode()
static func read_bytes(path: String, maximum_bytes: int = MAX_PORTABLE_FILE_BYTES) -> PackedByteArray:
if not FileAccess.file_exists(path):
return PackedByteArray()
var file := FileAccess.open(path, FileAccess.READ)
if file == null or file.get_length() > maximum_bytes:
return PackedByteArray()
var bytes := file.get_buffer(file.get_length())
file.close()
return bytes
static func write_guarded(
path: String,
bytes: PackedByteArray,
expected_hash: String,
conflict_directory: String,
device_id: String,
) -> Dictionary:
var current_exists := FileAccess.file_exists(path)
var current_hash := hash_file(path) if current_exists else ""
if current_hash != expected_hash:
var conflict_path := _write_conflict_copy(
path, bytes, conflict_directory, device_id
)
return {
"ok": false,
"conflict": true,
"hash": expected_hash,
"conflict_path": conflict_path,
"message": (
"This file changed on another device.\n"
+ "Your current data was preserved as a conflict copy."
),
}
if not _ensure_parent(path):
return {"ok": false, "conflict": false, "hash": expected_hash}
var temporary := path + ".tmp"
var backup := path + ".backup"
var file := FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return {"ok": false, "conflict": false, "hash": expected_hash}
file.store_buffer(bytes)
file.flush()
var write_error := file.get_error()
file.close()
if write_error != OK:
_remove(temporary)
return {"ok": false, "conflict": false, "hash": expected_hash}
_remove(backup)
if current_exists and not _rename(path, backup):
_remove(temporary)
return {"ok": false, "conflict": false, "hash": expected_hash}
if not _rename(temporary, path):
if current_exists:
_rename(backup, path)
return {"ok": false, "conflict": false, "hash": expected_hash}
_remove(backup)
return {
"ok": true,
"conflict": false,
"hash": hash_bytes(bytes),
"conflict_path": "",
}
static func has_syncthing_conflict(directory: String) -> bool:
var access := DirAccess.open(directory)
if access == null:
return false
access.list_dir_begin()
var name := access.get_next()
while not name.is_empty():
var lower := name.to_lower()
if "sync-conflict" in lower or ".syncthing." in lower:
access.list_dir_end()
return true
name = access.get_next()
access.list_dir_end()
return false
static func _write_conflict_copy(
canonical_path: String,
bytes: PackedByteArray,
conflict_directory: String,
device_id: String,
) -> String:
if DirAccess.make_dir_recursive_absolute(conflict_directory) != OK:
return ""
var filename := canonical_path.get_file()
var safe_device := device_id.left(16)
var timestamp := Time.get_datetime_string_from_system().replace(":", "-")
var destination := conflict_directory.path_join(
"%s.local-%s-%s" % [filename, safe_device, timestamp]
)
var file := FileAccess.open(destination, FileAccess.WRITE)
if file == null:
return ""
file.store_buffer(bytes)
file.flush()
var ok := file.get_error() == OK
file.close()
return destination if ok else ""
static func _ensure_parent(path: String) -> bool:
var parent := path.get_base_dir()
return (
DirAccess.dir_exists_absolute(parent)
or DirAccess.make_dir_recursive_absolute(parent) == OK
)
static func _rename(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(from_path, to_path) == OK
static func _remove(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(path)

View file

@ -0,0 +1 @@
uid://biw72bdyctluv

View file

@ -2,9 +2,6 @@ class_name SavedServerStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://saved_servers.json"
const TEMP_PATH: String = "user://saved_servers.json.tmp"
const BACKUP_PATH: String = "user://saved_servers.json.backup"
const MAX_SAVED_ENTRIES: int = 100
const MAX_RECENT_ENTRIES: int = 20
const MAX_DISPLAY_NAME_LENGTH: int = 80
@ -17,6 +14,22 @@ var _recent_entries: Array[Dictionary] = []
var _loaded: bool = false
var _recovery_warning: String = ""
var _write_blocked: bool = false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func _temp_path() -> String:
return _store_path + ".tmp"
func _backup_path() -> String:
return _store_path + ".backup"
func get_saved_entries() -> Array[SavedServerEntry]:
@ -242,15 +255,15 @@ func _ensure_loaded() -> void:
return
_loaded = true
_recover_interrupted_write()
if not FileAccess.file_exists(STORE_PATH):
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var data: Dictionary = _read_store(STORE_PATH)
if data.is_empty() and FileAccess.file_exists(BACKUP_PATH):
data = _read_store(BACKUP_PATH)
var data: Dictionary = _read_store(_store_path)
if data.is_empty() and FileAccess.file_exists(_backup_path()):
data = _read_store(_backup_path())
if not data.is_empty():
_set_warning("Recovered saved servers from the local backup.")
if data.is_empty():
if FileAccess.file_exists(STORE_PATH):
if FileAccess.file_exists(_store_path):
_set_warning(
"Saved servers could not be read. Direct connection is still available."
)
@ -263,6 +276,7 @@ func _ensure_loaded() -> void:
return
_load_collection(data.get("saved_entries", data.get("entries", [])), true)
_load_collection(data.get("recent_entries", []), false)
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _load_collection(raw: Variant, is_saved: bool) -> void:
@ -389,31 +403,20 @@ func _sort_saved_entries(
func _save_atomic() -> bool:
if _write_blocked:
return false
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"saved_entries": _saved_entries,
"recent_entries": _recent_entries,
}, "\t"))
file.flush()
var error: Error = file.get_error()
file.close()
if error != OK:
_remove_if_present(TEMP_PATH)
return false
_remove_if_present(BACKUP_PATH)
var had_primary: bool = FileAccess.file_exists(STORE_PATH)
if had_primary and not _rename(STORE_PATH, BACKUP_PATH):
_remove_if_present(TEMP_PATH)
return false
if not _rename(TEMP_PATH, STORE_PATH):
if had_primary:
_rename(BACKUP_PATH, STORE_PATH)
return false
_remove_if_present(BACKUP_PATH)
return true
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))
func _read_store(path: String) -> Dictionary:
@ -427,13 +430,13 @@ func _read_store(path: String) -> Dictionary:
func _recover_interrupted_write() -> void:
if FileAccess.file_exists(STORE_PATH):
_remove_if_present(TEMP_PATH)
if FileAccess.file_exists(_store_path):
_remove_if_present(_temp_path())
return
if FileAccess.file_exists(BACKUP_PATH):
_rename(BACKUP_PATH, STORE_PATH)
if FileAccess.file_exists(_backup_path()):
_rename(_backup_path(), _store_path)
_set_warning("Recovered saved servers after an interrupted write.")
_remove_if_present(TEMP_PATH)
_remove_if_present(_temp_path())
func _set_warning(message: String) -> void:

View file

@ -2,9 +2,6 @@ class_name ServerTrustStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://server_trust.json"
const TEMP_PATH: String = STORE_PATH + ".tmp"
enum Verification {
FIRST_SEEN,
MATCH,
@ -14,6 +11,14 @@ enum Verification {
var _records: Dictionary = {}
var _loaded: bool = false
var _write_blocked: bool = false
var _store_path := ""
var _expected_hash := ""
var _data_root: PlayerDataRoot
func configure_storage(path: String, data_root: PlayerDataRoot) -> void:
_store_path = path
_data_root = data_root
func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification:
@ -80,9 +85,9 @@ func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
if _store_path.is_empty() or not FileAccess.file_exists(_store_path):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
var file := FileAccess.open(_store_path, FileAccess.READ)
if file == null:
return
var json := JSON.new()
@ -102,26 +107,22 @@ func _ensure_loaded() -> void:
var fingerprint := str(record.get("fingerprint", ""))
if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint):
_records[endpoint] = record.duplicate(true)
_expected_hash = PortableFileGuard.hash_file(_store_path)
func _save() -> bool:
if _write_blocked:
return false
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
var bytes := JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK
}, "\t").to_utf8_buffer()
var result := PortableFileGuard.write_guarded(
_store_path, bytes, _expected_hash, _data_root.conflict_directory(),
_data_root.device_id,
)
if bool(result.get("conflict", false)):
_data_root.report_conflict(str(result.get("message", "")), str(result.get("conflict_path", "")))
if bool(result.get("ok", false)):
_expected_hash = str(result["hash"])
return bool(result.get("ok", false))