2026-07-29 23:51:25 -04:00
|
|
|
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 ""
|
2026-07-30 00:54:53 -04:00
|
|
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null or file.get_length() > MAX_PORTABLE_FILE_BYTES:
|
|
|
|
|
return ""
|
2026-07-30 00:54:53 -04:00
|
|
|
var bytes: PackedByteArray = file.get_buffer(file.get_length())
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
return hash_bytes(bytes)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func hash_bytes(bytes: PackedByteArray) -> String:
|
2026-07-30 00:54:53 -04:00
|
|
|
var context: HashingContext = HashingContext.new()
|
|
|
|
|
var start_error: Error = context.start(HashingContext.HASH_SHA256)
|
|
|
|
|
if start_error != OK:
|
2026-07-29 23:51:25 -04:00
|
|
|
return ""
|
2026-07-30 00:54:53 -04:00
|
|
|
var update_error: Error = context.update(bytes)
|
|
|
|
|
if update_error != OK:
|
2026-07-29 23:51:25 -04:00
|
|
|
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()
|
2026-07-30 00:54:53 -04:00
|
|
|
var file: FileAccess = FileAccess.open(path, FileAccess.READ)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null or file.get_length() > maximum_bytes:
|
|
|
|
|
return PackedByteArray()
|
2026-07-30 00:54:53 -04:00
|
|
|
var bytes: PackedByteArray = file.get_buffer(file.get_length())
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
return bytes
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func write_guarded(
|
|
|
|
|
path: String,
|
|
|
|
|
bytes: PackedByteArray,
|
|
|
|
|
expected_hash: String,
|
|
|
|
|
conflict_directory: String,
|
|
|
|
|
device_id: String,
|
|
|
|
|
) -> Dictionary:
|
2026-07-30 00:54:53 -04:00
|
|
|
var current_exists: bool = FileAccess.file_exists(path)
|
|
|
|
|
var current_hash: String = hash_file(path) if current_exists else ""
|
2026-07-29 23:51:25 -04:00
|
|
|
if current_hash != expected_hash:
|
2026-07-30 00:54:53 -04:00
|
|
|
var conflict_path: String = _write_conflict_copy(
|
2026-07-29 23:51:25 -04:00
|
|
|
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}
|
2026-07-30 00:54:53 -04:00
|
|
|
var temporary: String = path + ".tmp"
|
|
|
|
|
var backup: String = path + ".backup"
|
|
|
|
|
var file: FileAccess = FileAccess.open(temporary, FileAccess.WRITE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null:
|
|
|
|
|
return {"ok": false, "conflict": false, "hash": expected_hash}
|
|
|
|
|
file.store_buffer(bytes)
|
|
|
|
|
file.flush()
|
2026-07-30 00:54:53 -04:00
|
|
|
var write_error: Error = file.get_error()
|
2026-07-29 23:51:25 -04:00
|
|
|
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:
|
2026-07-30 00:54:53 -04:00
|
|
|
var access: DirAccess = DirAccess.open(directory)
|
2026-07-29 23:51:25 -04:00
|
|
|
if access == null:
|
|
|
|
|
return false
|
|
|
|
|
access.list_dir_begin()
|
2026-07-30 00:54:53 -04:00
|
|
|
var name: String = access.get_next()
|
2026-07-29 23:51:25 -04:00
|
|
|
while not name.is_empty():
|
2026-07-30 00:54:53 -04:00
|
|
|
var lower: String = name.to_lower()
|
2026-07-29 23:51:25 -04:00
|
|
|
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 ""
|
2026-07-30 00:54:53 -04:00
|
|
|
var filename: String = canonical_path.get_file()
|
|
|
|
|
var safe_device: String = device_id.left(16)
|
|
|
|
|
var timestamp: String = Time.get_datetime_string_from_system().replace(":", "-")
|
|
|
|
|
var destination: String = conflict_directory.path_join(
|
2026-07-29 23:51:25 -04:00
|
|
|
"%s.local-%s-%s" % [filename, safe_device, timestamp]
|
|
|
|
|
)
|
2026-07-30 00:54:53 -04:00
|
|
|
var file: FileAccess = FileAccess.open(destination, FileAccess.WRITE)
|
2026-07-29 23:51:25 -04:00
|
|
|
if file == null:
|
|
|
|
|
return ""
|
|
|
|
|
file.store_buffer(bytes)
|
|
|
|
|
file.flush()
|
2026-07-30 00:54:53 -04:00
|
|
|
var ok: bool = file.get_error() == OK
|
2026-07-29 23:51:25 -04:00
|
|
|
file.close()
|
|
|
|
|
return destination if ok else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
static func _ensure_parent(path: String) -> bool:
|
2026-07-30 00:54:53 -04:00
|
|
|
var parent: String = path.get_base_dir()
|
2026-07-29 23:51:25 -04:00
|
|
|
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)
|