fix: use canonical sha256 world hashes

This commit is contained in:
2026-06-22 17:11:21 +04:00
parent 7356238ffb
commit 0b23cf48e7
3 changed files with 144 additions and 20 deletions
Generated
+3
View File
@@ -287,6 +287,9 @@ dependencies = [
[[package]] [[package]]
name = "fparkan-world" name = "fparkan-world"
version = "0.1.0" version = "0.1.0"
dependencies = [
"fparkan-binary",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
+1
View File
@@ -6,6 +6,7 @@ license.workspace = true
repository.workspace = true repository.workspace = true
[dependencies] [dependencies]
fparkan-binary = { path = "../fparkan-binary" }
[lints] [lints]
workspace = true workspace = true
+140 -20
View File
@@ -1,8 +1,11 @@
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
//! Deterministic world identity, queue, lifecycle, and snapshots. //! Deterministic world identity, queue, lifecycle, and snapshots.
use fparkan_binary::sha256;
use std::collections::VecDeque; use std::collections::VecDeque;
const WORLD_STATE_HASH_SCHEMA: &[u8] = b"fparkan-world-state-v2\0";
/// Object handle with generation. /// Object handle with generation.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub struct ObjectHandle { pub struct ObjectHandle {
@@ -418,24 +421,36 @@ where
/// Computes canonical state hash. /// Computes canonical state hash.
#[must_use] #[must_use]
pub fn canonical_state_hash(world: &World) -> StateHash { pub fn canonical_state_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64; StateHash(sha256(&canonical_state_bytes(world)))
hash_u64(&mut state, world.tick.0); }
fn canonical_state_bytes(world: &World) -> Vec<u8> {
let mut out = Vec::new();
out.extend_from_slice(WORLD_STATE_HASH_SCHEMA);
push_u64(&mut out, world.tick.0);
push_u64(&mut out, world.next_sequence);
push_u64(&mut out, world.next_registration_sequence);
push_len(&mut out, world.slots.len());
for (idx, slot) in world.slots.iter().enumerate() { for (idx, slot) in world.slots.iter().enumerate() {
hash_u64(&mut state, idx as u64); push_len(&mut out, idx);
hash_u64(&mut state, u64::from(slot.generation)); push_u32(&mut out, slot.generation);
hash_u64(&mut state, u64::from(u8::from(slot.live))); push_bool(&mut out, slot.live);
hash_u64(&mut state, u64::from(u8::from(slot.registered))); push_bool(&mut out, slot.registered);
hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0))); push_optional_u32(&mut out, slot.original_id.map(|id| id.0));
hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0))); push_optional_u32(&mut out, slot.mirror_id.map(|id| id.0));
hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0))); push_optional_u16(&mut out, slot.owner_id.map(|id| id.0));
hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX)); push_optional_u64(&mut out, slot.registration_sequence);
} }
let mut out = [0; 32]; push_len(&mut out, world.queue.len());
out[..8].copy_from_slice(&state.to_le_bytes()); for command in &world.queue {
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes()); push_u64(&mut out, command.sequence);
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes()); push_optional_handle(&mut out, command.target);
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes()); }
StateHash(out) push_len(&mut out, world.deferred_delete.len());
for handle in &world.deferred_delete {
push_handle(&mut out, *handle);
}
out
} }
/// Creates a fixed-step clock. /// Creates a fixed-step clock.
@@ -552,13 +567,59 @@ pub fn shutdown(mut world: World) -> ShutdownReport {
} }
} }
fn hash_u64(state: &mut u64, value: u64) { fn push_len(out: &mut Vec<u8>, value: usize) {
for byte in value.to_le_bytes() { push_u64(out, u64::try_from(value).unwrap_or(u64::MAX));
*state ^= u64::from(byte); }
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
fn push_u64(out: &mut Vec<u8>, value: u64) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u32(out: &mut Vec<u8>, value: u32) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_u16(out: &mut Vec<u8>, value: u16) {
out.extend_from_slice(&value.to_le_bytes());
}
fn push_bool(out: &mut Vec<u8>, value: bool) {
out.push(u8::from(value));
}
fn push_optional_u64(out: &mut Vec<u8>, value: Option<u64>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u64(out, value);
} }
} }
fn push_optional_u32(out: &mut Vec<u8>, value: Option<u32>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u32(out, value);
}
}
fn push_optional_u16(out: &mut Vec<u8>, value: Option<u16>) {
push_bool(out, value.is_some());
if let Some(value) = value {
push_u16(out, value);
}
}
fn push_optional_handle(out: &mut Vec<u8>, handle: Option<ObjectHandle>) {
push_bool(out, handle.is_some());
if let Some(handle) = handle {
push_handle(out, handle);
}
}
fn push_handle(out: &mut Vec<u8>, handle: ObjectHandle) {
push_u32(out, handle.generation);
push_u32(out, handle.slot);
}
fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> { fn checked_slot(world: &World, handle: ObjectHandle) -> Result<&Slot, WorldError> {
let slot = world let slot = world
.slots .slots
@@ -836,6 +897,34 @@ mod tests {
); );
} }
#[test]
fn state_hash_uses_canonical_sha256_instead_of_legacy_rotated_fnv() {
let mut world = new(WorldConfig);
let handle = construct_object(
&mut world,
ObjectDraft {
original_id: Some(OriginalObjectId(42)),
},
)
.expect("object");
set_mirror_original(&mut world, handle, Some(OriginalObjectId(420))).expect("mirror");
set_owner(&mut world, handle, Some(OwnerId(9))).expect("owner");
register_object(&mut world, handle).expect("register");
enqueue(
&mut world,
WorldCommand {
sequence: 999,
target: Some(handle),
},
)
.expect("enqueue");
let snapshot = step(&mut world, &InputSnapshot).expect("step");
assert_ne!(snapshot.hash, legacy_rotated_fnv_hash(&world));
assert_ne!(snapshot.hash, canonical_state_hash(&new(WorldConfig)));
}
#[test] #[test]
fn fixed_step_pause_and_long_determinism_are_stable() { fn fixed_step_pause_and_long_determinism_are_stable() {
let config = FixedStepConfig { let config = FixedStepConfig {
@@ -966,4 +1055,35 @@ mod tests {
} }
} }
} }
fn legacy_rotated_fnv_hash(world: &World) -> StateHash {
let mut state = 0xcbf2_9ce4_8422_2325_u64;
legacy_hash_u64(&mut state, world.tick.0);
for (idx, slot) in world.slots.iter().enumerate() {
legacy_hash_u64(
&mut state,
u64::try_from(idx).expect("slot index should fit"),
);
legacy_hash_u64(&mut state, u64::from(slot.generation));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.live)));
legacy_hash_u64(&mut state, u64::from(u8::from(slot.registered)));
legacy_hash_u64(&mut state, slot.original_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.mirror_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.owner_id.map_or(0, |id| u64::from(id.0)));
legacy_hash_u64(&mut state, slot.registration_sequence.unwrap_or(u64::MAX));
}
let mut out = [0; 32];
out[..8].copy_from_slice(&state.to_le_bytes());
out[8..16].copy_from_slice(&state.rotate_left(13).to_le_bytes());
out[16..24].copy_from_slice(&state.rotate_left(29).to_le_bytes());
out[24..32].copy_from_slice(&state.rotate_left(47).to_le_bytes());
StateHash(out)
}
fn legacy_hash_u64(state: &mut u64, value: u64) {
for byte in value.to_le_bytes() {
*state ^= u64::from(byte);
*state = state.wrapping_mul(0x0000_0100_0000_01b3);
}
}
} }