1mod known_players;
4mod permissions;
5
6use std::{
7 io::Cursor,
8 path::{Path, PathBuf},
9 sync::Arc,
10};
11
12use rustc_hash::FxHashMap;
13use simdnbt::{ToNbtTag, borrow::read_compound as read_borrowed_compound, owned::NbtTag};
14use tokio::{
15 fs,
16 io::{self, AsyncWriteExt},
17};
18use uuid::Uuid;
19use wincode::{SchemaRead, SchemaWrite};
20
21#[cfg(test)]
22use self::permissions::set_permission_subject;
23use self::{
24 known_players::{KnownPlayersFile, decode_known_players_file, encode_known_players_file},
25 permissions::{PlayerPermissionsFile, serialize_player_permissions_file},
26};
27use super::player_data::{
28 PLAYER_DATA_VERSION, PersistentAbilities, PersistentEnderPearl, PersistentPlayerData,
29 PersistentRootVehicle, PersistentSlot,
30};
31use crate::chunk_saver::PersistentEntity;
32use crate::config::StorageSelection;
33use crate::permission::PermissionSubjectIndex;
34#[cfg(test)]
35use crate::permission::PermissionSubjectState;
36use crate::player::KnownPlayers;
37use crate::player::Player;
38use steel_registry::item_stack::ItemStack;
39use steel_utils::Identifier;
40use steel_utils::locks::{AsyncMutex, SyncMutex};
41
42const PLAYER_MAGIC: [u8; 4] = *b"STLP";
43const GLOBAL_MAGIC: [u8; 4] = *b"STLG";
44const PLAYER_STORAGE_VERSION: u16 = 7;
45const GLOBAL_STORAGE_VERSION: u16 = 1;
46const GLOBAL_PLAYER_DATA_VERSION: i32 = 1;
47
48#[derive(Debug, Clone)]
50pub struct GlobalPlayerData {
51 pub last_active_domain: String,
53}
54
55pub struct PlayerDataStorage {
57 backend: PlayerDataStorageBackend,
58}
59
60enum PlayerDataStorageBackend {
61 File(FilePlayerDataStorage),
62}
63
64struct FilePlayerDataStorage {
65 save_root: PathBuf,
66 file_locks: SyncMutex<FxHashMap<PathBuf, Arc<AsyncMutex<()>>>>,
67}
68
69#[derive(SchemaWrite, SchemaRead)]
70struct PlayerDataFile {
71 data_version: i32,
72 pos: [f64; 3],
73 motion: [f64; 3],
74 rotation: [f32; 2],
75 on_ground: bool,
76 fall_flying: bool,
77 remaining_fire_ticks: i32,
78 ticks_frozen: i32,
79 is_in_powder_snow: bool,
80 was_in_powder_snow: bool,
81 has_visual_fire: bool,
82 health: f32,
83 game_mode: i32,
84 prev_game_mode: Option<i32>,
85 abilities: AbilitiesFile,
86 inventory: Vec<SlotFile>,
87 selected_slot: i32,
88 world: String,
89 food_level: i32,
90 food_saturation_level: f32,
91 food_exhaustion_level: f32,
92 food_tick_timer: i32,
93 experience_level: i32,
94 experience_progress: f32,
95 experience_total: i32,
96 score: i32,
97 seen_credits: bool,
98 root_vehicle: Option<RootVehicleFile>,
99 ender_pearls: Vec<EnderPearlFile>,
100}
101
102#[derive(SchemaWrite, SchemaRead)]
103struct RootVehicleFile {
104 attach: [u8; 16],
105 entity: PersistentEntity,
106}
107
108#[derive(SchemaWrite, SchemaRead)]
109struct EnderPearlFile {
110 world: String,
111 entity: PersistentEntity,
112}
113
114#[derive(SchemaWrite, SchemaRead)]
115struct AbilitiesFile {
116 invulnerable: bool,
117 flying: bool,
118 may_fly: bool,
119 instabuild: bool,
120 may_build: bool,
121 flying_speed: f32,
122 walking_speed: f32,
123}
124
125#[derive(SchemaWrite, SchemaRead)]
126struct SlotFile {
127 slot: i8,
128 item_nbt: Vec<u8>,
129}
130
131#[derive(SchemaWrite, SchemaRead)]
132struct GlobalPlayerDataFile {
133 data_version: i32,
134 last_active_domain: String,
135}
136
137impl PlayerDataStorage {
138 pub async fn new(save_root: PathBuf, selection: StorageSelection) -> io::Result<Self> {
140 if selection.kind != Identifier::from_steel("file") {
141 return Err(io::Error::new(
142 io::ErrorKind::InvalidInput,
143 format!("unknown player storage {}", selection.kind),
144 ));
145 }
146 let backend = PlayerDataStorageBackend::File(FilePlayerDataStorage::new(save_root).await?);
147 Ok(Self { backend })
148 }
149
150 pub async fn save(&self, player: &Player) -> io::Result<()> {
152 let domain = player.get_world().domain().to_owned();
153 self.save_domain(&domain, player).await?;
154 self.save_global(
155 player.gameprofile.id,
156 &GlobalPlayerData {
157 last_active_domain: domain,
158 },
159 )
160 .await
161 }
162
163 pub async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
165 match &self.backend {
166 PlayerDataStorageBackend::File(storage) => storage.save_domain(domain, player).await,
167 }
168 }
169
170 pub async fn save_domain_data(
172 &self,
173 domain: &str,
174 uuid: Uuid,
175 data: &PersistentPlayerData,
176 ) -> io::Result<()> {
177 match &self.backend {
178 PlayerDataStorageBackend::File(storage) => {
179 storage.save_domain_data(domain, uuid, data).await
180 }
181 }
182 }
183
184 pub async fn load_domain(
186 &self,
187 domain: &str,
188 uuid: Uuid,
189 ) -> io::Result<Option<PersistentPlayerData>> {
190 match &self.backend {
191 PlayerDataStorageBackend::File(storage) => storage.load_domain(domain, uuid).await,
192 }
193 }
194
195 pub async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
197 match &self.backend {
198 PlayerDataStorageBackend::File(storage) => storage.load_global(uuid).await,
199 }
200 }
201
202 pub async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
204 match &self.backend {
205 PlayerDataStorageBackend::File(storage) => storage.load_permission_subjects().await,
206 }
207 }
208
209 pub async fn load_known_players(&self) -> io::Result<KnownPlayers> {
211 match &self.backend {
212 PlayerDataStorageBackend::File(storage) => storage.load_known_players().await,
213 }
214 }
215
216 pub async fn save_known_players_if_current(
218 &self,
219 players: &KnownPlayers,
220 is_current: impl FnOnce() -> bool + Send,
221 ) -> io::Result<bool> {
222 match &self.backend {
223 PlayerDataStorageBackend::File(storage) => {
224 storage
225 .save_known_players_if_current(players, is_current)
226 .await
227 }
228 }
229 }
230
231 pub async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
233 match &self.backend {
234 PlayerDataStorageBackend::File(storage) => storage.save_global(uuid, data).await,
235 }
236 }
237
238 pub async fn save_permission_subjects(
240 &self,
241 subjects: &PermissionSubjectIndex,
242 ) -> io::Result<()> {
243 match &self.backend {
244 PlayerDataStorageBackend::File(storage) => {
245 storage.save_permission_subjects(subjects).await
246 }
247 }
248 }
249}
250
251impl FilePlayerDataStorage {
252 async fn new(save_root: PathBuf) -> io::Result<Self> {
253 fs::create_dir_all(save_root.join("global").join("players")).await?;
254 Ok(Self {
255 save_root,
256 file_locks: SyncMutex::new(FxHashMap::default()),
257 })
258 }
259
260 async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
261 let uuid = player.gameprofile.id;
262 let data = PersistentPlayerData::from_player(player);
263 self.save_domain_data(domain, uuid, &data).await
264 }
265
266 async fn save_domain_data(
267 &self,
268 domain: &str,
269 uuid: Uuid,
270 data: &PersistentPlayerData,
271 ) -> io::Result<()> {
272 let file = PlayerDataFile::from_persistent(data)?;
273 let bytes = encode_player_file(&file)?;
274 self.write_atomic(&self.domain_players_dir(domain), uuid, bytes)
275 .await?;
276 log::debug!("Saved player data for {uuid} in domain {domain}");
277 Ok(())
278 }
279
280 async fn load_domain(
281 &self,
282 domain: &str,
283 uuid: Uuid,
284 ) -> io::Result<Option<PersistentPlayerData>> {
285 let path = Self::player_file(&self.domain_players_dir(domain), uuid);
286 let lock = self.file_lock(&path);
287 let _guard = lock.lock().await;
288 if !Self::recover_missing_atomic_path_locked(&path).await? {
289 return Ok(None);
290 }
291 let bytes = fs::read(&path).await?;
292 let file = decode_player_file(&bytes)?;
293 let data = file.into_persistent()?;
294 log::debug!("Loaded player data for {uuid} in domain {domain}");
295 Ok(Some(data))
296 }
297
298 async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
299 let path = Self::player_file(&self.global_players_dir(), uuid);
300 let lock = self.file_lock(&path);
301 let _guard = lock.lock().await;
302 if !Self::recover_missing_atomic_path_locked(&path).await? {
303 return Ok(None);
304 }
305 let bytes = fs::read(&path).await?;
306 let file = decode_global_file(&bytes)?;
307 Ok(Some(GlobalPlayerData {
308 last_active_domain: file.last_active_domain,
309 }))
310 }
311
312 async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
313 self.load_player_permissions_file()
314 .await?
315 .into_subject_index()
316 }
317
318 async fn load_known_players(&self) -> io::Result<KnownPlayers> {
319 let path = self.known_players_file();
320 let lock = self.file_lock(&path);
321 let _guard = lock.lock().await;
322 match Self::read_known_players_file_locked(&path).await {
323 Ok(players) => Ok(players),
324 Err(error) => {
325 log::warn!(
326 "Failed to load known player cache from {}: {error}. Starting with an empty cache",
327 path.display()
328 );
329 Ok(KnownPlayers::new())
330 }
331 }
332 }
333
334 async fn read_known_players_file_locked(path: &Path) -> io::Result<KnownPlayers> {
335 if !Self::recover_missing_atomic_path_locked(path).await? {
336 return Ok(KnownPlayers::new());
337 }
338 let bytes = fs::read(path).await?;
339 decode_known_players_file(&bytes)?.into_known_players()
340 }
341
342 async fn save_known_players_if_current(
343 &self,
344 players: &KnownPlayers,
345 is_current: impl FnOnce() -> bool + Send,
346 ) -> io::Result<bool> {
347 let path = self.known_players_file();
348 let lock = self.file_lock(&path);
349 let _guard = lock.lock().await;
350 if !is_current() {
351 return Ok(false);
352 }
353 let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(players))?;
354 Self::write_atomic_path_locked(&path, bytes).await?;
355 Ok(true)
356 }
357
358 async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
359 let file = GlobalPlayerDataFile {
360 data_version: GLOBAL_PLAYER_DATA_VERSION,
361 last_active_domain: data.last_active_domain.clone(),
362 };
363 let bytes = encode_global_file(&file)?;
364 self.write_atomic(&self.global_players_dir(), uuid, bytes)
365 .await
366 }
367
368 async fn save_permission_subjects(&self, subjects: &PermissionSubjectIndex) -> io::Result<()> {
369 let path = self.player_permissions_file();
370 let lock = self.file_lock(&path);
371 let _guard = lock.lock().await;
372 let file = PlayerPermissionsFile::from_subject_index(subjects);
373 self.write_player_permissions_file_locked(&path, &file)
374 .await
375 }
376
377 async fn load_player_permissions_file(&self) -> io::Result<PlayerPermissionsFile> {
378 let path = self.player_permissions_file();
379 let lock = self.file_lock(&path);
380 let _guard = lock.lock().await;
381 self.read_player_permissions_file_locked(&path).await
382 }
383
384 async fn read_player_permissions_file_locked(
385 &self,
386 path: &Path,
387 ) -> io::Result<PlayerPermissionsFile> {
388 if !Self::recover_missing_atomic_path_locked(path).await? {
389 return Ok(PlayerPermissionsFile::default());
390 }
391 let contents = fs::read_to_string(path).await?;
392 let file = toml::from_str::<PlayerPermissionsFile>(&contents).map_err(|error| {
393 io::Error::new(
394 io::ErrorKind::InvalidData,
395 format!(
396 "invalid player permissions TOML in {}: {error}",
397 path.display()
398 ),
399 )
400 })?;
401 file.validate()?;
402 Ok(file)
403 }
404
405 async fn write_player_permissions_file_locked(
406 &self,
407 path: &Path,
408 file: &PlayerPermissionsFile,
409 ) -> io::Result<()> {
410 let contents = serialize_player_permissions_file(file).map_err(|error| {
411 io::Error::new(
412 io::ErrorKind::InvalidData,
413 format!("failed to serialize player permissions TOML: {error}"),
414 )
415 })?;
416 Self::write_atomic_path_locked(path, contents.into_bytes()).await
417 }
418
419 fn global_dir(&self) -> PathBuf {
420 self.save_root.join("global")
421 }
422
423 fn global_players_dir(&self) -> PathBuf {
424 self.global_dir().join("players")
425 }
426
427 fn player_permissions_file(&self) -> PathBuf {
428 self.global_dir().join("player_permissions.toml")
429 }
430
431 fn known_players_file(&self) -> PathBuf {
432 self.global_dir().join("known_players.dat")
433 }
434
435 fn domain_players_dir(&self, domain: &str) -> PathBuf {
436 self.save_root.join(domain).join("players")
437 }
438
439 fn player_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
440 players_dir.join(format!("{uuid}.dat"))
441 }
442
443 fn file_lock(&self, path: &Path) -> Arc<AsyncMutex<()>> {
444 let mut locks = self.file_locks.lock();
445 locks
446 .entry(path.to_path_buf())
447 .or_insert_with(|| Arc::new(AsyncMutex::new(())))
448 .clone()
449 }
450
451 async fn write_atomic(&self, players_dir: &Path, uuid: Uuid, bytes: Vec<u8>) -> io::Result<()> {
452 let final_path = Self::player_file(players_dir, uuid);
453 let lock = self.file_lock(&final_path);
454 let _guard = lock.lock().await;
455 Self::write_atomic_path_locked(&final_path, bytes).await
456 }
457
458 async fn write_atomic_path_locked(final_path: &Path, bytes: Vec<u8>) -> io::Result<()> {
459 let Some(parent) = final_path.parent() else {
460 return Err(io::Error::new(
461 io::ErrorKind::InvalidInput,
462 "atomic write path has no parent",
463 ));
464 };
465 fs::create_dir_all(parent).await?;
466 let temp_path = Self::atomic_temp_path(final_path);
467 let backup_path = Self::atomic_backup_path(final_path);
468 let backup_temp_path = Self::atomic_temp_path(&backup_path);
469
470 Self::write_synced_file(&temp_path, &bytes).await?;
471 if fs::try_exists(final_path).await? {
472 Self::copy_synced_file(final_path, &backup_temp_path).await?;
473 fs::rename(&backup_temp_path, &backup_path).await?;
474 }
475 fs::rename(&temp_path, final_path).await?;
476 if let Err(error) = Self::sync_parent(parent).await {
477 tracing::error!(
478 %error,
479 path = %final_path.display(),
480 "Atomic data-file replacement committed, but directory sync failed; crash durability is uncertain"
481 );
482 }
483 Ok(())
484 }
485
486 fn atomic_temp_path(path: &Path) -> PathBuf {
487 let extension = path.extension().and_then(|value| value.to_str());
488 path.with_extension(match extension {
489 Some(extension) => format!("{extension}.tmp"),
490 None => "tmp".to_owned(),
491 })
492 }
493
494 fn atomic_backup_path(path: &Path) -> PathBuf {
495 let extension = path.extension().and_then(|value| value.to_str());
496 path.with_extension(match extension {
497 Some(extension) => format!("{extension}_old"),
498 None => "old".to_owned(),
499 })
500 }
501
502 async fn recover_missing_atomic_path_locked(final_path: &Path) -> io::Result<bool> {
503 if fs::try_exists(final_path).await? {
504 return Ok(true);
505 }
506
507 let backup_path = Self::atomic_backup_path(final_path);
508 if fs::try_exists(&backup_path).await? {
509 fs::rename(&backup_path, final_path).await?;
510 let Some(parent) = final_path.parent() else {
511 return Err(io::Error::new(
512 io::ErrorKind::InvalidInput,
513 "atomic recovery path has no parent",
514 ));
515 };
516 Self::sync_parent(parent).await?;
517 let temp_path = Self::atomic_temp_path(final_path);
518 if fs::try_exists(&temp_path).await?
519 && let Err(error) = fs::remove_file(&temp_path).await
520 {
521 tracing::warn!(
522 %error,
523 path = %temp_path.display(),
524 "Failed to remove an uncommitted atomic-write temporary file"
525 );
526 }
527 tracing::warn!(
528 path = %final_path.display(),
529 backup = %backup_path.display(),
530 "Recovered a missing data file from its last committed backup"
531 );
532 return Ok(true);
533 }
534
535 let temp_path = Self::atomic_temp_path(final_path);
536 if fs::try_exists(&temp_path).await? {
537 if let Err(error) = fs::remove_file(&temp_path).await {
538 tracing::warn!(
539 %error,
540 path = %temp_path.display(),
541 "Failed to remove an uncommitted atomic-write temporary file"
542 );
543 }
544 tracing::warn!(
545 path = %final_path.display(),
546 temporary = %temp_path.display(),
547 "Discarded an interrupted data-file publication with no committed generation"
548 );
549 }
550
551 Ok(false)
552 }
553
554 async fn write_synced_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
555 let mut file = fs::File::create(path).await?;
556 file.write_all(bytes).await?;
557 file.sync_all().await
558 }
559
560 async fn copy_synced_file(source: &Path, destination: &Path) -> io::Result<()> {
561 let mut source = fs::File::open(source).await?;
562 let mut destination = fs::File::create(destination).await?;
563 io::copy(&mut source, &mut destination).await?;
564 destination.sync_all().await
565 }
566
567 async fn sync_parent(parent: &Path) -> io::Result<()> {
568 #[cfg(unix)]
569 fs::File::open(parent).await?.sync_all().await?;
570 #[cfg(not(unix))]
571 let _ = parent;
572 Ok(())
573 }
574}
575
576impl PlayerDataFile {
577 fn from_persistent(data: &PersistentPlayerData) -> io::Result<Self> {
578 let mut inventory = Vec::with_capacity(data.inventory.len());
579 for slot in &data.inventory {
580 inventory.push(SlotFile {
581 slot: slot.slot,
582 item_nbt: item_to_nbt_bytes(&slot.item)?,
583 });
584 }
585
586 Ok(Self {
587 data_version: data.data_version,
588 pos: data.pos,
589 motion: data.motion,
590 rotation: data.rotation,
591 on_ground: data.on_ground,
592 fall_flying: data.fall_flying,
593 remaining_fire_ticks: data.remaining_fire_ticks,
594 ticks_frozen: data.ticks_frozen,
595 is_in_powder_snow: data.is_in_powder_snow,
596 was_in_powder_snow: data.was_in_powder_snow,
597 has_visual_fire: data.has_visual_fire,
598 health: data.health,
599 game_mode: data.game_mode,
600 prev_game_mode: data.prev_game_mode,
601 abilities: AbilitiesFile {
602 invulnerable: data.abilities.invulnerable,
603 flying: data.abilities.flying,
604 may_fly: data.abilities.may_fly,
605 instabuild: data.abilities.instabuild,
606 may_build: data.abilities.may_build,
607 flying_speed: data.abilities.flying_speed,
608 walking_speed: data.abilities.walking_speed,
609 },
610 inventory,
611 selected_slot: data.selected_slot,
612 world: data.world.clone(),
613 food_level: data.food_level,
614 food_saturation_level: data.food_saturation_level,
615 food_exhaustion_level: data.food_exhaustion_level,
616 food_tick_timer: data.food_tick_timer,
617 experience_level: data.experience_level,
618 experience_progress: data.experience_progress,
619 experience_total: data.experience_total,
620 score: data.score,
621 seen_credits: data.seen_credits,
622 root_vehicle: data
623 .root_vehicle
624 .clone()
625 .map(|root_vehicle| RootVehicleFile {
626 attach: root_vehicle.attach,
627 entity: root_vehicle.entity,
628 }),
629 ender_pearls: data
630 .ender_pearls
631 .iter()
632 .map(|pearl| EnderPearlFile {
633 world: pearl.world.clone(),
634 entity: pearl.entity.clone(),
635 })
636 .collect(),
637 })
638 }
639
640 fn into_persistent(self) -> io::Result<PersistentPlayerData> {
641 if self.data_version != PLAYER_DATA_VERSION {
642 return Err(io::Error::new(
643 io::ErrorKind::InvalidData,
644 format!(
645 "unsupported player data payload version {}",
646 self.data_version
647 ),
648 ));
649 }
650
651 let mut inventory = Vec::with_capacity(self.inventory.len());
652 for slot in self.inventory {
653 inventory.push(PersistentSlot {
654 slot: slot.slot,
655 item: item_from_nbt_bytes(&slot.item_nbt)?,
656 });
657 }
658
659 Ok(PersistentPlayerData {
660 pos: self.pos,
661 motion: self.motion,
662 rotation: self.rotation,
663 on_ground: self.on_ground,
664 fall_flying: self.fall_flying,
665 remaining_fire_ticks: self.remaining_fire_ticks,
666 ticks_frozen: self.ticks_frozen,
667 is_in_powder_snow: self.is_in_powder_snow,
668 was_in_powder_snow: self.was_in_powder_snow,
669 has_visual_fire: self.has_visual_fire,
670 health: self.health,
671 game_mode: self.game_mode,
672 prev_game_mode: self.prev_game_mode,
673 abilities: PersistentAbilities {
674 invulnerable: self.abilities.invulnerable,
675 flying: self.abilities.flying,
676 may_fly: self.abilities.may_fly,
677 instabuild: self.abilities.instabuild,
678 may_build: self.abilities.may_build,
679 flying_speed: self.abilities.flying_speed,
680 walking_speed: self.abilities.walking_speed,
681 },
682 inventory,
683 selected_slot: self.selected_slot,
684 world: self.world,
685 food_level: self.food_level,
686 food_saturation_level: self.food_saturation_level,
687 food_exhaustion_level: self.food_exhaustion_level,
688 food_tick_timer: self.food_tick_timer,
689 data_version: self.data_version,
690 experience_level: self.experience_level,
691 experience_progress: self.experience_progress,
692 experience_total: self.experience_total,
693 score: self.score,
694 seen_credits: self.seen_credits,
695 root_vehicle: self.root_vehicle.map(|root_vehicle| PersistentRootVehicle {
696 attach: root_vehicle.attach,
697 entity: root_vehicle.entity,
698 }),
699 ender_pearls: self
700 .ender_pearls
701 .into_iter()
702 .map(|pearl| PersistentEnderPearl {
703 world: pearl.world,
704 entity: pearl.entity,
705 })
706 .collect(),
707 })
708 }
709}
710
711fn item_to_nbt_bytes(item: &ItemStack) -> io::Result<Vec<u8>> {
712 let NbtTag::Compound(compound) = item.clone().to_nbt_tag() else {
713 return Err(io::Error::new(
714 io::ErrorKind::InvalidData,
715 "item stack did not serialize to a compound",
716 ));
717 };
718 let mut bytes = Vec::new();
719 compound.write(&mut bytes);
720 Ok(bytes)
721}
722
723fn item_from_nbt_bytes(bytes: &[u8]) -> io::Result<ItemStack> {
724 let nbt = read_borrowed_compound(&mut Cursor::new(bytes)).map_err(|e| {
725 io::Error::new(
726 io::ErrorKind::InvalidData,
727 format!("failed to parse item NBT: {e}"),
728 )
729 })?;
730 let compound = simdnbt::borrow::NbtCompound::from(&nbt);
731 ItemStack::from_borrowed_compound(&compound)
732 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "invalid item stack data"))
733}
734
735fn encode_player_file(file: &PlayerDataFile) -> io::Result<Vec<u8>> {
736 encode_file(
737 PLAYER_MAGIC,
738 PLAYER_STORAGE_VERSION,
739 wincode::serialize(file),
740 )
741}
742
743fn decode_player_file(bytes: &[u8]) -> io::Result<PlayerDataFile> {
744 let payload = decode_file(PLAYER_MAGIC, PLAYER_STORAGE_VERSION, bytes)?;
745 wincode::deserialize(&payload)
746 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
747}
748
749fn encode_global_file(file: &GlobalPlayerDataFile) -> io::Result<Vec<u8>> {
750 encode_file(
751 GLOBAL_MAGIC,
752 GLOBAL_STORAGE_VERSION,
753 wincode::serialize(file),
754 )
755}
756
757fn decode_global_file(bytes: &[u8]) -> io::Result<GlobalPlayerDataFile> {
758 let payload = decode_file(GLOBAL_MAGIC, GLOBAL_STORAGE_VERSION, bytes)?;
759 wincode::deserialize(&payload)
760 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
761}
762
763fn encode_file(
764 magic: [u8; 4],
765 version: u16,
766 serialized: wincode::WriteResult<Vec<u8>>,
767) -> io::Result<Vec<u8>> {
768 let payload =
769 serialized.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))?;
770 let compressed = zstd::encode_all(&payload[..], 3)?;
771 let mut bytes = Vec::with_capacity(6 + compressed.len());
772 bytes.extend_from_slice(&magic);
773 bytes.extend_from_slice(&version.to_le_bytes());
774 bytes.extend_from_slice(&compressed);
775 Ok(bytes)
776}
777
778fn decode_file(
779 expected_magic: [u8; 4],
780 expected_version: u16,
781 bytes: &[u8],
782) -> io::Result<Vec<u8>> {
783 if bytes.len() < 6 {
784 return Err(io::Error::new(
785 io::ErrorKind::InvalidData,
786 "player data file is too short",
787 ));
788 }
789 if bytes[0..4] != expected_magic {
790 return Err(io::Error::new(
791 io::ErrorKind::InvalidData,
792 "invalid player data magic",
793 ));
794 }
795 let version = u16::from_le_bytes([bytes[4], bytes[5]]);
796 if version != expected_version {
797 return Err(io::Error::new(
798 io::ErrorKind::InvalidData,
799 format!("unsupported player data storage version {version}"),
800 ));
801 }
802 zstd::decode_all(&bytes[6..])
803}
804
805#[cfg(test)]
806mod tests {
807 use super::*;
808 use crate::entity::DEFAULT_MAX_AIR_SUPPLY;
809 use crate::permission::PermissionSet;
810 use crate::player::KnownPlayer;
811 use std::{
812 env,
813 time::{SystemTime, UNIX_EPOCH},
814 };
815
816 fn temp_storage_root(name: &str) -> PathBuf {
817 let suffix = SystemTime::now()
818 .duration_since(UNIX_EPOCH)
819 .expect("system clock should be after unix epoch")
820 .as_nanos();
821 env::temp_dir().join(format!("steelmc-player-storage-{name}-{suffix}"))
822 }
823
824 fn sample_player_file(data_version: i32) -> PlayerDataFile {
825 PlayerDataFile {
826 data_version,
827 pos: [1.0, 2.0, 3.0],
828 motion: [0.0, 0.0, 0.0],
829 rotation: [90.0, 10.0],
830 on_ground: true,
831 fall_flying: false,
832 remaining_fire_ticks: 0,
833 ticks_frozen: 0,
834 is_in_powder_snow: false,
835 was_in_powder_snow: false,
836 has_visual_fire: false,
837 health: 20.0,
838 game_mode: 2,
839 prev_game_mode: Some(0),
840 abilities: AbilitiesFile {
841 invulnerable: false,
842 flying: false,
843 may_fly: false,
844 instabuild: false,
845 may_build: true,
846 flying_speed: 0.05,
847 walking_speed: 0.1,
848 },
849 inventory: Vec::new(),
850 selected_slot: 4,
851 world: "lobby:void".to_owned(),
852 food_level: 20,
853 food_saturation_level: 5.0,
854 food_exhaustion_level: 0.0,
855 food_tick_timer: 0,
856 experience_level: 7,
857 experience_progress: 0.5,
858 experience_total: 32,
859 score: 9,
860 seen_credits: true,
861 root_vehicle: None,
862 ender_pearls: Vec::new(),
863 }
864 }
865
866 fn sample_persistent_entity() -> PersistentEntity {
867 PersistentEntity {
868 entity_type: Identifier::vanilla_static("minecart"),
869 uuid: [7; 16],
870 pos: [4.0, 65.0, 6.0],
871 motion: [0.0, 0.0, 0.0],
872 rotation: [45.0, 0.0],
873 fall_distance: 0.0,
874 remaining_fire_ticks: 0,
875 ticks_frozen: 0,
876 is_in_powder_snow: false,
877 was_in_powder_snow: false,
878 has_visual_fire: false,
879 on_ground: true,
880 no_gravity: false,
881 invulnerable: false,
882 air_supply: DEFAULT_MAX_AIR_SUPPLY,
883 portal_cooldown: 0,
884 custom_name_nbt: Vec::new(),
885 custom_name_visible: false,
886 silent: false,
887 glowing: false,
888 tags: Vec::new(),
889 custom_data_nbt: Vec::new(),
890 nbt_data: Vec::new(),
891 passengers: Vec::new(),
892 }
893 }
894
895 #[tokio::test]
896 async fn atomic_path_replacement_retains_the_last_committed_generation() {
897 let root = temp_storage_root("atomic-replacement");
898 let path = root.join("state.dat");
899
900 FilePlayerDataStorage::write_atomic_path_locked(&path, b"first".to_vec())
901 .await
902 .expect("first generation should publish");
903 FilePlayerDataStorage::write_atomic_path_locked(&path, b"second".to_vec())
904 .await
905 .expect("second generation should publish");
906
907 assert_eq!(
908 fs::read(&path).await.expect("live file should be readable"),
909 b"second"
910 );
911 assert_eq!(
912 fs::read(FilePlayerDataStorage::atomic_backup_path(&path))
913 .await
914 .expect("backup should be readable"),
915 b"first"
916 );
917
918 fs::remove_dir_all(root)
919 .await
920 .expect("temporary storage should be removable");
921 }
922
923 #[tokio::test]
924 async fn interrupted_permission_publication_recovers_before_the_next_update() {
925 let root = temp_storage_root("permission-recovery");
926 let storage = FilePlayerDataStorage::new(root.clone())
927 .await
928 .expect("test storage should initialize");
929 let mut subjects = PermissionSubjectIndex::new();
930 for (uuid, group) in [
931 (Uuid::from_u128(10), "builder"),
932 (Uuid::from_u128(20), "moderator"),
933 ] {
934 subjects.set(
935 uuid,
936 PermissionSubjectState::new(vec![group.to_owned()], PermissionSet::new()),
937 );
938 }
939 storage
940 .save_permission_subjects(&subjects)
941 .await
942 .expect("permission subjects should persist");
943
944 let path = storage.player_permissions_file();
945 let backup = FilePlayerDataStorage::atomic_backup_path(&path);
946 let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
947 fs::rename(&path, &backup)
948 .await
949 .expect("legacy publication should reach its interrupted state");
950 fs::write(&temporary, b"uncommitted replacement")
951 .await
952 .expect("uncommitted replacement should be staged");
953
954 let mut recovered = storage
955 .load_permission_subjects()
956 .await
957 .expect("last committed permissions should recover");
958 assert_eq!(recovered.len(), 2);
959 assert_eq!(
960 recovered
961 .get(Uuid::from_u128(10))
962 .map(PermissionSubjectState::groups),
963 Some(["builder".to_owned()].as_slice())
964 );
965 assert_eq!(
966 recovered
967 .get(Uuid::from_u128(20))
968 .map(PermissionSubjectState::groups),
969 Some(["moderator".to_owned()].as_slice())
970 );
971 assert!(!temporary.exists());
972
973 recovered.set(
974 Uuid::from_u128(30),
975 PermissionSubjectState::new(vec!["operator".to_owned()], PermissionSet::new()),
976 );
977 storage
978 .save_permission_subjects(&recovered)
979 .await
980 .expect("an update after recovery should preserve existing subjects");
981 let updated = storage
982 .load_permission_subjects()
983 .await
984 .expect("updated permissions should load");
985 assert_eq!(updated.len(), 3);
986
987 fs::remove_dir_all(root)
988 .await
989 .expect("temporary storage should be removable");
990 }
991
992 #[tokio::test]
993 async fn corrupt_live_permission_file_does_not_fall_back_to_its_backup() {
994 let root = temp_storage_root("corrupt-live-permissions");
995 let storage = FilePlayerDataStorage::new(root.clone())
996 .await
997 .expect("test storage should initialize");
998 let mut subjects = PermissionSubjectIndex::new();
999 subjects.set(
1000 Uuid::from_u128(42),
1001 PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1002 );
1003 storage
1004 .save_permission_subjects(&subjects)
1005 .await
1006 .expect("permission subject should persist");
1007 let path = storage.player_permissions_file();
1008 let backup = FilePlayerDataStorage::atomic_backup_path(&path);
1009 fs::copy(&path, &backup)
1010 .await
1011 .expect("valid backup should be staged");
1012 fs::write(&path, b"not valid permission TOML")
1013 .await
1014 .expect("live permission file should be corrupted for the test");
1015
1016 let error = storage
1017 .load_permission_subjects()
1018 .await
1019 .expect_err("a corrupt live permission file must remain startup-fatal");
1020 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1021 assert_eq!(
1022 fs::read_to_string(&path)
1023 .await
1024 .expect("corrupt live file should remain in place"),
1025 "not valid permission TOML"
1026 );
1027
1028 fs::remove_dir_all(root)
1029 .await
1030 .expect("temporary storage should be removable");
1031 }
1032
1033 #[tokio::test]
1034 async fn interrupted_first_known_player_publication_discards_its_temporary_file() {
1035 let root = temp_storage_root("known-player-interrupted-first-write");
1036 let storage = FilePlayerDataStorage::new(root.clone())
1037 .await
1038 .expect("test storage should initialize");
1039 let uuid = Uuid::from_u128(42);
1040 let players =
1041 KnownPlayers::from_entries([KnownPlayer::with_expiration(uuid, "Steve", 1_234_567)]);
1042 let path = storage.known_players_file();
1043 let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
1044 let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(&players))
1045 .expect("known players should encode");
1046 fs::write(&temporary, bytes)
1047 .await
1048 .expect("first publication should reach its interrupted state");
1049
1050 let loaded = storage
1051 .load_known_players()
1052 .await
1053 .expect("uncommitted known-player state should be ignored");
1054 assert!(loaded.entries().is_empty());
1055 assert!(!path.exists());
1056 assert!(!temporary.exists());
1057
1058 fs::remove_dir_all(root)
1059 .await
1060 .expect("temporary storage should be removable");
1061 }
1062
1063 #[tokio::test]
1064 async fn corrupt_known_player_cache_loads_as_empty() {
1065 let root = temp_storage_root("corrupt-known-players");
1066 let storage = FilePlayerDataStorage::new(root.clone())
1067 .await
1068 .expect("test storage should initialize");
1069 let path = storage.known_players_file();
1070 fs::write(&path, b"not a known-player cache")
1071 .await
1072 .expect("known-player cache should be corrupted for the test");
1073
1074 let loaded = storage
1075 .load_known_players()
1076 .await
1077 .expect("a corrupt optional cache should not prevent startup");
1078 assert!(loaded.entries().is_empty());
1079 assert_eq!(
1080 fs::read(&path)
1081 .await
1082 .expect("the corrupt cache should remain available for diagnosis"),
1083 b"not a known-player cache"
1084 );
1085
1086 fs::remove_dir_all(root)
1087 .await
1088 .expect("temporary storage should be removable");
1089 }
1090
1091 #[tokio::test]
1092 async fn incompatible_known_player_cache_version_loads_as_empty() {
1093 let root = temp_storage_root("incompatible-known-player-version");
1094 let storage = FilePlayerDataStorage::new(root.clone())
1095 .await
1096 .expect("test storage should initialize");
1097 let uuid = Uuid::from_u128(42);
1098 let players = KnownPlayers::from_entries([KnownPlayer::new(uuid, "Steve")]);
1099 let mut bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(&players))
1100 .expect("known-player cache should encode");
1101 bytes[4..6].copy_from_slice(&u16::MAX.to_le_bytes());
1102 fs::write(storage.known_players_file(), bytes)
1103 .await
1104 .expect("incompatible known-player cache should be seeded");
1105
1106 let loaded = storage
1107 .load_known_players()
1108 .await
1109 .expect("an incompatible optional cache should not prevent startup");
1110 assert!(loaded.entries().is_empty());
1111 assert!(loaded.by_uuid(uuid).is_none());
1112
1113 fs::remove_dir_all(root)
1114 .await
1115 .expect("temporary storage should be removable");
1116 }
1117
1118 #[tokio::test]
1119 async fn interrupted_first_permission_publication_does_not_apply_uncommitted_access() {
1120 let root = temp_storage_root("permission-interrupted-first-write");
1121 let storage = FilePlayerDataStorage::new(root.clone())
1122 .await
1123 .expect("test storage should initialize");
1124 let path = storage.player_permissions_file();
1125 let temporary = FilePlayerDataStorage::atomic_temp_path(&path);
1126 let mut file = PlayerPermissionsFile::default();
1127 set_permission_subject(
1128 &mut file,
1129 Uuid::from_u128(42),
1130 &PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1131 );
1132 let contents = serialize_player_permissions_file(&file)
1133 .expect("uncommitted permissions should serialize");
1134 fs::write(&temporary, contents)
1135 .await
1136 .expect("uncommitted permissions should be staged");
1137
1138 let loaded = storage
1139 .load_permission_subjects()
1140 .await
1141 .expect("uncommitted permissions should be ignored");
1142 assert!(loaded.is_empty());
1143 assert!(!path.exists());
1144 assert!(!temporary.exists());
1145
1146 fs::remove_dir_all(root)
1147 .await
1148 .expect("temporary storage should be removable");
1149 }
1150
1151 #[tokio::test]
1152 async fn known_player_cache_round_trips_and_rejects_stale_writes() {
1153 let root = temp_storage_root("known-players");
1154 let storage = match FilePlayerDataStorage::new(root.clone()).await {
1155 Ok(storage) => storage,
1156 Err(error) => panic!("test storage should initialize: {error}"),
1157 };
1158 let uuid = Uuid::from_u128(42);
1159 let players =
1160 KnownPlayers::from_entries([KnownPlayer::with_expiration(uuid, "Steve", 1_234_567)]);
1161
1162 let stale = storage
1163 .save_known_players_if_current(&players, || false)
1164 .await;
1165 assert!(matches!(stale, Ok(false)));
1166 assert!(!storage.known_players_file().exists());
1167
1168 let saved = storage
1169 .save_known_players_if_current(&players, || true)
1170 .await;
1171 assert!(matches!(saved, Ok(true)));
1172 let loaded = storage.load_known_players().await;
1173 let Ok(loaded) = loaded else {
1174 panic!("known players should load");
1175 };
1176 assert_eq!(
1177 loaded.by_uuid(uuid).map(KnownPlayer::last_known_name),
1178 Some("Steve")
1179 );
1180 assert_eq!(
1181 loaded.by_uuid(uuid).map(KnownPlayer::expires_at_millis),
1182 Some(1_234_567)
1183 );
1184
1185 fs::remove_dir_all(root)
1186 .await
1187 .expect("temporary storage should be removable");
1188 }
1189
1190 #[test]
1191 fn known_player_cache_persists_vanillas_mru_limit() {
1192 let players = KnownPlayers::from_entries((0_u128..=1_000).map(|value| {
1193 KnownPlayer::with_expiration(
1194 Uuid::from_u128(value),
1195 format!("Player{value}"),
1196 1_234_567,
1197 )
1198 }));
1199 let encoded = encode_known_players_file(&KnownPlayersFile::from_known_players(&players));
1200 let Ok(encoded) = encoded else {
1201 panic!("known player cache should encode");
1202 };
1203 let decoded =
1204 decode_known_players_file(&encoded).and_then(KnownPlayersFile::into_known_players);
1205 let Ok(decoded) = decoded else {
1206 panic!("known player cache should decode");
1207 };
1208
1209 assert_eq!(decoded.entries().len(), 1_000);
1210 assert!(decoded.by_uuid(Uuid::from_u128(999)).is_some());
1211 assert!(decoded.by_uuid(Uuid::from_u128(1_000)).is_none());
1212 }
1213
1214 #[test]
1215 fn player_file_roundtrip_preserves_domain_world_data() {
1216 let file = sample_player_file(PLAYER_DATA_VERSION);
1217
1218 let encoded = encode_player_file(&file).expect("player file should encode");
1219 let decoded = decode_player_file(&encoded).expect("player file should decode");
1220
1221 assert_eq!(
1222 u16::from_le_bytes([encoded[4], encoded[5]]),
1223 PLAYER_STORAGE_VERSION
1224 );
1225 assert_eq!(decoded.world, "lobby:void");
1226 assert_eq!(decoded.game_mode, 2);
1227 assert_eq!(decoded.selected_slot, 4);
1228 assert_eq!(decoded.experience_level, 7);
1229 assert_eq!(decoded.experience_progress.to_bits(), 0.5_f32.to_bits());
1230 assert_eq!(decoded.experience_total, 32);
1231 assert_eq!(decoded.score, 9);
1232 assert!(decoded.seen_credits);
1233 }
1234
1235 #[test]
1236 fn player_file_roundtrip_preserves_absent_previous_game_mode() {
1237 let mut file = sample_player_file(PLAYER_DATA_VERSION);
1238 file.prev_game_mode = None;
1239
1240 let encoded = encode_player_file(&file).expect("player file should encode");
1241 let decoded = decode_player_file(&encoded).expect("player file should decode");
1242 let persistent = decoded
1243 .into_persistent()
1244 .expect("player file should convert");
1245
1246 assert_eq!(persistent.prev_game_mode, None);
1247 }
1248
1249 #[test]
1250 fn global_file_roundtrip_preserves_last_active_domain() {
1251 let file = GlobalPlayerDataFile {
1252 data_version: GLOBAL_PLAYER_DATA_VERSION,
1253 last_active_domain: "minecraft".to_owned(),
1254 };
1255
1256 let encoded = encode_global_file(&file).expect("global file should encode");
1257 let decoded = decode_global_file(&encoded).expect("global file should decode");
1258
1259 assert_eq!(
1260 u16::from_le_bytes([encoded[4], encoded[5]]),
1261 GLOBAL_STORAGE_VERSION
1262 );
1263 assert_eq!(decoded.last_active_domain, "minecraft");
1264 }
1265
1266 #[tokio::test]
1267 async fn permission_subject_snapshot_removes_noncanonical_uuid_key() {
1268 let root = temp_storage_root("permission-uuid-key");
1269 let storage = match FilePlayerDataStorage::new(root.clone()).await {
1270 Ok(storage) => storage,
1271 Err(error) => panic!("test storage should initialize: {error}"),
1272 };
1273 let target_uuid = Uuid::from_u128(42);
1274 let control_uuid = Uuid::from_u128(84);
1275 let mut seed = PermissionSubjectIndex::new();
1276 seed.set(
1277 target_uuid,
1278 PermissionSubjectState::new(vec!["op".to_owned()], PermissionSet::new()),
1279 );
1280 seed.set(
1281 control_uuid,
1282 PermissionSubjectState::new(vec!["builder".to_owned()], PermissionSet::new()),
1283 );
1284 let file = PlayerPermissionsFile::from_subject_index(&seed);
1285 let canonical = target_uuid.to_string();
1286 let noncanonical = target_uuid.simple().to_string();
1287 let contents = serialize_player_permissions_file(&file)
1288 .expect("permission subjects should serialize")
1289 .replace(&canonical, &noncanonical);
1290 fs::write(storage.player_permissions_file(), contents)
1291 .await
1292 .expect("noncanonical permission UUID should be seeded");
1293
1294 let mut subjects = storage
1295 .load_permission_subjects()
1296 .await
1297 .expect("valid UUID spellings should load");
1298 assert_eq!(subjects.len(), 2);
1299 let removed = subjects
1300 .remove(target_uuid)
1301 .expect("target should be indexed by UUID");
1302 assert_eq!(removed.groups(), ["op"]);
1303 storage
1304 .save_permission_subjects(&subjects)
1305 .await
1306 .expect("updated UUID index should persist");
1307
1308 let reloaded = storage
1309 .load_permission_subjects()
1310 .await
1311 .expect("updated permission subjects should load");
1312 assert!(reloaded.get(target_uuid).is_none());
1313 assert_eq!(
1314 reloaded
1315 .get(control_uuid)
1316 .map(PermissionSubjectState::groups),
1317 Some(["builder".to_owned()].as_slice())
1318 );
1319 let persisted = fs::read_to_string(storage.player_permissions_file())
1320 .await
1321 .expect("updated permissions should be readable");
1322 assert!(!persisted.contains(&canonical));
1323 assert!(!persisted.contains(&noncanonical));
1324
1325 fs::remove_dir_all(root)
1326 .await
1327 .expect("temporary storage should be removable");
1328 }
1329
1330 #[test]
1331 fn player_file_roundtrip_preserves_root_vehicle() {
1332 let mut file = sample_player_file(PLAYER_DATA_VERSION);
1333 file.root_vehicle = Some(RootVehicleFile {
1334 attach: [3; 16],
1335 entity: sample_persistent_entity(),
1336 });
1337
1338 let encoded = encode_player_file(&file).expect("player file should encode");
1339 let decoded = decode_player_file(&encoded).expect("player file should decode");
1340 let persistent = decoded
1341 .into_persistent()
1342 .expect("player file should convert");
1343
1344 let Some(root_vehicle) = persistent.root_vehicle else {
1345 panic!("root vehicle should survive roundtrip");
1346 };
1347 assert_eq!(root_vehicle.attach, [3; 16]);
1348 assert_eq!(root_vehicle.entity.uuid, [7; 16]);
1349 assert_eq!(
1350 root_vehicle.entity.entity_type,
1351 Identifier::vanilla_static("minecart")
1352 );
1353 assert_eq!(
1354 root_vehicle.entity.pos.map(f64::to_bits),
1355 [4.0_f64.to_bits(), 65.0_f64.to_bits(), 6.0_f64.to_bits()]
1356 );
1357 }
1358
1359 #[test]
1360 fn player_file_roundtrip_preserves_ender_pearls() {
1361 let mut file = sample_player_file(PLAYER_DATA_VERSION);
1362 file.ender_pearls = vec![
1363 EnderPearlFile {
1364 world: "minecraft:overworld".to_owned(),
1365 entity: sample_persistent_entity(),
1366 },
1367 EnderPearlFile {
1368 world: "minecraft:the_nether".to_owned(),
1369 entity: sample_persistent_entity(),
1370 },
1371 ];
1372
1373 let encoded = encode_player_file(&file).expect("player file should encode");
1374 let decoded = decode_player_file(&encoded).expect("player file should decode");
1375 let persistent = decoded
1376 .into_persistent()
1377 .expect("player file should convert");
1378
1379 assert_eq!(persistent.ender_pearls.len(), 2);
1380 assert_eq!(persistent.ender_pearls[0].world, "minecraft:overworld");
1381 assert_eq!(persistent.ender_pearls[1].world, "minecraft:the_nether");
1382 assert_eq!(persistent.ender_pearls[0].entity.uuid, [7; 16]);
1383 assert_eq!(
1384 persistent.ender_pearls[0].entity.pos.map(f64::to_bits),
1385 [4.0_f64.to_bits(), 65.0_f64.to_bits(), 6.0_f64.to_bits()]
1386 );
1387 }
1388
1389 #[test]
1390 fn stale_player_payload_version_is_rejected() {
1391 let file = sample_player_file(PLAYER_DATA_VERSION - 1);
1392
1393 let error = file
1394 .into_persistent()
1395 .expect_err("stale payload should fail");
1396
1397 assert_eq!(error.kind(), io::ErrorKind::InvalidData);
1398 }
1399}