mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
fix(multiplayer): validate client revisions
This commit is contained in:
@@ -419,9 +419,9 @@ func _handle_websocket_packet(packet: PackedByteArray) -> void:
|
||||
static func _valid_websocket_event(event: Dictionary) -> bool:
|
||||
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
|
||||
return false
|
||||
if not event.has("revision") or not (event["revision"] is int or event["revision"] is float):
|
||||
if not event.has("revision") or not _valid_revision(event["revision"]):
|
||||
return false
|
||||
if int(event["revision"]) < 0 or not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty():
|
||||
if not event.has("resource_id") or not event["resource_id"] is String or String(event["resource_id"]).is_empty():
|
||||
return false
|
||||
if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty():
|
||||
return false
|
||||
@@ -437,6 +437,14 @@ static func _valid_websocket_event(event: Dictionary) -> bool:
|
||||
return false
|
||||
|
||||
|
||||
static func _valid_revision(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
|
||||
func _on_resync_required(resource_id: String) -> void:
|
||||
if not _operation.is_empty():
|
||||
_pending_resync_resource_id = resource_id
|
||||
|
||||
@@ -49,7 +49,7 @@ func begin_queue(new_ticket_id: String, new_playlist: String) -> bool:
|
||||
|
||||
|
||||
func apply_ticket_update(update: Dictionary) -> bool:
|
||||
if not _has_string(update, "ticket_id") or not update.has("revision") or not update.has("state"):
|
||||
if not _has_string(update, "ticket_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||
return _request_resync(self.ticket_id)
|
||||
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
|
||||
return _request_resync(self.ticket_id)
|
||||
@@ -94,7 +94,7 @@ func apply_ticket_update(update: Dictionary) -> bool:
|
||||
|
||||
|
||||
func apply_proposal_update(update: Dictionary) -> bool:
|
||||
if not _has_string(update, "proposal_id") or not update.has("revision") or not update.has("state"):
|
||||
if not _has_string(update, "proposal_id") or not update.has("revision") or not _valid_revision(update["revision"]) or not update.has("state"):
|
||||
return _request_resync(proposal_id)
|
||||
var incoming_id := String(update["proposal_id"])
|
||||
if proposal_id.is_empty():
|
||||
@@ -270,3 +270,11 @@ func _is_legal_ticket_transition(from: String, to: String) -> bool:
|
||||
|
||||
func _has_string(value: Dictionary, key: String) -> bool:
|
||||
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
|
||||
|
||||
|
||||
func _valid_revision(value: Variant) -> bool:
|
||||
if value is int:
|
||||
return int(value) >= 0
|
||||
if value is float:
|
||||
return is_finite(float(value)) and float(value) >= 0.0 and float(value) == floor(float(value))
|
||||
return false
|
||||
|
||||
@@ -60,6 +60,12 @@ func test_websocket_event_validation_requires_contract_specific_fields() -> void
|
||||
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
|
||||
assignment.erase("server_id")
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
|
||||
var fractional := envelope.duplicate()
|
||||
fractional["revision"] = 1.5
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(fractional), "fractional event revision is rejected")
|
||||
var negative := envelope.duplicate()
|
||||
negative["revision"] = -1
|
||||
assert_true(not ControlPlaneClient._valid_websocket_event(negative), "negative event revision is rejected")
|
||||
|
||||
|
||||
func test_websocket_reconnect_defers_recovery_while_http_mutation_is_in_flight() -> void:
|
||||
|
||||
@@ -75,6 +75,13 @@ func test_higher_revision_cannot_jump_or_rewind_the_authoritative_lifecycle() ->
|
||||
assert_eq(state.phase, MatchmakingState.ACCEPTED, "illegal rewind cannot mutate phase")
|
||||
|
||||
|
||||
func test_ticket_and_proposal_revisions_must_be_nonnegative_integers() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-revision", "casual")
|
||||
assert_true(not state.apply_ticket_update({"ticket_id": "ticket-revision", "revision": 1.5, "state": "PROPOSED"}), "fractional ticket revision is rejected")
|
||||
assert_true(not state.apply_proposal_update({"proposal_id": "proposal-revision", "revision": -1, "state": "OPEN"}), "negative proposal revision is rejected")
|
||||
|
||||
|
||||
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
|
||||
var state := MatchmakingState.new()
|
||||
state.begin_queue("ticket-1", "casual")
|
||||
|
||||
@@ -1542,3 +1542,5 @@ The queue projection now also accepts the contract's post-allocation/result stat
|
||||
Client ticket updates now enforce the versioned legal transition graph as well as revision ordering. Same-state heartbeat revisions remain valid, while higher-revision jumps and rewinds request authoritative recovery without mutating the visible phase; adversarial tests cover both boundaries.
|
||||
|
||||
Reconnect recovery now treats `COMPLETED` as terminal, avoiding a needless queue read after a finished match. UI policy tests cover the complete expanded lifecycle, including the completed-to-new-search boundary.
|
||||
|
||||
Ticket, proposal, and WebSocket revisions now fail closed unless they are finite, non-negative integers; fractional values are no longer silently truncated into valid revisions. Adversarial client tests cover fractional and negative inputs.
|
||||
|
||||
Reference in New Issue
Block a user