mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-15 03:02:02 +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:
|
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():
|
if not event.has("event") or not event["event"] is String or String(event["event"]).is_empty():
|
||||||
return false
|
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
|
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
|
return false
|
||||||
if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty():
|
if not event.has("occurred_at") or not event["occurred_at"] is String or String(event["occurred_at"]).is_empty():
|
||||||
return false
|
return false
|
||||||
@@ -437,6 +437,14 @@ static func _valid_websocket_event(event: Dictionary) -> bool:
|
|||||||
return false
|
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:
|
func _on_resync_required(resource_id: String) -> void:
|
||||||
if not _operation.is_empty():
|
if not _operation.is_empty():
|
||||||
_pending_resync_resource_id = resource_id
|
_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:
|
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)
|
return _request_resync(self.ticket_id)
|
||||||
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
|
if ticket_id.is_empty() or String(update["ticket_id"]) != ticket_id:
|
||||||
return _request_resync(self.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:
|
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)
|
return _request_resync(proposal_id)
|
||||||
var incoming_id := String(update["proposal_id"])
|
var incoming_id := String(update["proposal_id"])
|
||||||
if proposal_id.is_empty():
|
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:
|
func _has_string(value: Dictionary, key: String) -> bool:
|
||||||
return value.has(key) and value[key] is String and not String(value[key]).is_empty()
|
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")
|
assert_true(ControlPlaneClient._valid_websocket_event(assignment), "complete assignment event is accepted")
|
||||||
assignment.erase("server_id")
|
assignment.erase("server_id")
|
||||||
assert_true(not ControlPlaneClient._valid_websocket_event(assignment), "incomplete assignment event is rejected")
|
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:
|
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")
|
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:
|
func test_proposal_terminal_states_are_visible_and_not_cancellable() -> void:
|
||||||
var state := MatchmakingState.new()
|
var state := MatchmakingState.new()
|
||||||
state.begin_queue("ticket-1", "casual")
|
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.
|
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.
|
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