Skip to main content

steel_core/player/player_data_storage/
file_storage.rs

1use super::known_players::{
2    KnownPlayersFile, decode_known_players_file, encode_known_players_file,
3};
4use super::permissions::{PlayerPermissionsFile, serialize_player_permissions_file};
5use super::stats::{PlayerStatsFile, serialize_player_stats_file};
6use super::{
7    GLOBAL_PLAYER_DATA_VERSION, GlobalPlayerData, GlobalPlayerDataFile, PlayerDataFile,
8    decode_global_file, decode_player_file, encode_global_file, encode_player_file,
9};
10use crate::permission::PermissionSubjectIndex;
11use crate::player::player_data::{PersistentPlayerData, PersistentStat};
12use crate::player::{KnownPlayers, Player};
13use rustc_hash::FxHashMap;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use steel_utils::locks::{AsyncMutex, SyncMutex};
17use tokio::io::AsyncWriteExt;
18use tokio::sync::OwnedMutexGuard as OwnedAsyncMutexGuard;
19use tokio::{fs, io};
20use uuid::Uuid;
21
22pub(crate) struct FilePlayerDataStorage {
23    save_root: PathBuf,
24    file_locks: SyncMutex<FxHashMap<PathBuf, Arc<AsyncMutex<()>>>>,
25}
26
27impl FilePlayerDataStorage {
28    pub(crate) async fn new(save_root: PathBuf) -> io::Result<Self> {
29        fs::create_dir_all(save_root.join("global").join("players")).await?;
30        Ok(Self {
31            save_root,
32            file_locks: SyncMutex::new(FxHashMap::default()),
33        })
34    }
35
36    pub(crate) async fn save_domain(&self, domain: &str, player: &Player) -> io::Result<()> {
37        let uuid = player.gameprofile.id;
38        let data = PersistentPlayerData::from_player(player);
39        self.save_domain_data(domain, uuid, &data).await
40    }
41
42    pub(crate) async fn save_domain_data(
43        &self,
44        domain: &str,
45        uuid: Uuid,
46        data: &PersistentPlayerData,
47    ) -> io::Result<()> {
48        self.save_domain_player_data(domain, uuid, data).await?;
49        self.save_domain_player_stats(domain, uuid, data).await?;
50        Ok(())
51    }
52
53    pub(crate) async fn save_domain_player_data(
54        &self,
55        domain: &str,
56        uuid: Uuid,
57        data: &PersistentPlayerData,
58    ) -> io::Result<()> {
59        let file = PlayerDataFile::from_persistent(data)?;
60        let bytes = encode_player_file(&file)?;
61        self.write_atomic_player_data(&self.domain_players_dir(domain), uuid, &bytes)
62            .await?;
63        log::debug!("Saved player data for {uuid} in domain {domain}");
64
65        Ok(())
66    }
67
68    pub(crate) async fn save_domain_player_stats(
69        &self,
70        domain: &str,
71        uuid: Uuid,
72        data: &PersistentPlayerData,
73    ) -> io::Result<()> {
74        let player_stats_file = PlayerStatsFile::from_persistent_stats(&data.stats)?;
75        let toml_string = serialize_player_stats_file(&player_stats_file);
76        let final_path = Self::player_stats_file(&self.domain_players_dir(domain), uuid);
77        let _guard = self.file_lock(&final_path).await;
78        Self::write_atomic_path_locked(&final_path, toml_string.as_bytes()).await?;
79        log::debug!("Saved player stats for {uuid} in domain {domain}");
80
81        Ok(())
82    }
83
84    pub(crate) async fn load_domain(
85        &self,
86        domain: &str,
87        uuid: Uuid,
88    ) -> io::Result<Option<PersistentPlayerData>> {
89        let Some(mut data) = self.load_domain_player_data(domain, uuid).await? else {
90            return Ok(None);
91        };
92
93        data.stats = match self.load_domain_player_stats(domain, uuid).await {
94            Ok(Some(stats)) => stats,
95            Ok(None) => {
96                log::debug!("Using empty stats for {uuid} in domain {domain}");
97                Vec::new()
98            }
99            Err(e) => {
100                log::error!("Could not load stats for {uuid} in domain {domain}: {e}");
101                Vec::new()
102            }
103        };
104
105        Ok(Some(data))
106    }
107
108    pub(crate) async fn load_domain_player_data(
109        &self,
110        domain: &str,
111        uuid: Uuid,
112    ) -> io::Result<Option<PersistentPlayerData>> {
113        let domain_dir = self.domain_players_dir(domain);
114        let path = Self::player_data_file(&domain_dir, uuid);
115        let _guard = self.file_lock(&path).await;
116        if !Self::recover_missing_atomic_path_locked(&path).await? {
117            return Ok(None);
118        }
119        let bytes = fs::read(&path).await?;
120        let file = decode_player_file(&bytes)?;
121        let data = file.into_persistent()?;
122        log::debug!("Loaded player data for {uuid} in domain {domain}");
123
124        Ok(Some(data))
125    }
126
127    pub(crate) async fn load_domain_player_stats(
128        &self,
129        domain: &str,
130        uuid: Uuid,
131    ) -> io::Result<Option<Vec<PersistentStat>>> {
132        let domain_dir = self.domain_players_dir(domain);
133        let path = Self::player_stats_file(&domain_dir, uuid);
134        let _guard = self.file_lock(&path).await;
135        if !Self::recover_missing_atomic_path_locked(&path).await? {
136            return Ok(None);
137        }
138
139        let string = fs::read_to_string(&path).await?;
140        let stats_file: PlayerStatsFile = toml::from_str(&string).map_err(|e| {
141            io::Error::new(
142                io::ErrorKind::InvalidData,
143                format!("Invalid player stats for {uuid} in domain {domain}: {e}"),
144            )
145        })?;
146        let persistent_stats = stats_file.into_persistent_stats();
147        log::debug!("Loaded player stats for {uuid} in domain {domain}");
148
149        Ok(Some(persistent_stats))
150    }
151
152    pub(crate) async fn load_global(&self, uuid: Uuid) -> io::Result<Option<GlobalPlayerData>> {
153        let path = Self::player_data_file(&self.global_players_dir(), uuid);
154        let _guard = self.file_lock(&path).await;
155        if !Self::recover_missing_atomic_path_locked(&path).await? {
156            return Ok(None);
157        }
158        let bytes = fs::read(&path).await?;
159        let file = decode_global_file(&bytes)?;
160        Ok(Some(GlobalPlayerData {
161            last_active_domain: file.last_active_domain,
162        }))
163    }
164
165    pub(crate) async fn load_permission_subjects(&self) -> io::Result<PermissionSubjectIndex> {
166        self.load_player_permissions_file()
167            .await?
168            .into_subject_index()
169    }
170
171    pub(crate) async fn load_known_players(&self) -> io::Result<KnownPlayers> {
172        let path = self.known_players_file();
173        let _guard = self.file_lock(&path).await;
174        match Self::read_known_players_file_locked(&path).await {
175            Ok(players) => Ok(players),
176            Err(error) => {
177                log::warn!(
178                    "Failed to load known player cache from {}: {error}. Starting with an empty cache",
179                    path.display()
180                );
181                Ok(KnownPlayers::new())
182            }
183        }
184    }
185
186    async fn read_known_players_file_locked(path: &Path) -> io::Result<KnownPlayers> {
187        if !Self::recover_missing_atomic_path_locked(path).await? {
188            return Ok(KnownPlayers::new());
189        }
190        let bytes = fs::read(path).await?;
191        decode_known_players_file(&bytes)?.into_known_players()
192    }
193
194    pub(crate) async fn save_known_players_if_current(
195        &self,
196        players: &KnownPlayers,
197        is_current: impl FnOnce() -> bool + Send,
198    ) -> io::Result<bool> {
199        let path = self.known_players_file();
200        let _guard = self.file_lock(&path).await;
201        if !is_current() {
202            return Ok(false);
203        }
204        let bytes = encode_known_players_file(&KnownPlayersFile::from_known_players(players))?;
205        Self::write_atomic_path_locked(&path, &bytes).await?;
206        Ok(true)
207    }
208
209    pub(crate) async fn save_global(&self, uuid: Uuid, data: &GlobalPlayerData) -> io::Result<()> {
210        let file = GlobalPlayerDataFile {
211            data_version: GLOBAL_PLAYER_DATA_VERSION,
212            last_active_domain: data.last_active_domain.clone(),
213        };
214        let bytes = encode_global_file(&file)?;
215        self.write_atomic_player_data(&self.global_players_dir(), uuid, &bytes)
216            .await
217    }
218
219    pub(crate) async fn save_permission_subjects(
220        &self,
221        subjects: &PermissionSubjectIndex,
222    ) -> io::Result<()> {
223        let path = self.player_permissions_file();
224        let _guard = self.file_lock(&path).await;
225        let file = PlayerPermissionsFile::from_subject_index(subjects);
226        self.write_player_permissions_file_locked(&path, &file)
227            .await
228    }
229
230    pub(crate) async fn load_player_permissions_file(&self) -> io::Result<PlayerPermissionsFile> {
231        let path = self.player_permissions_file();
232        let _guard = self.file_lock(&path).await;
233        self.read_player_permissions_file_locked(&path).await
234    }
235
236    pub(crate) async fn read_player_permissions_file_locked(
237        &self,
238        path: &Path,
239    ) -> io::Result<PlayerPermissionsFile> {
240        if !Self::recover_missing_atomic_path_locked(path).await? {
241            return Ok(PlayerPermissionsFile::default());
242        }
243        let contents = fs::read_to_string(path).await?;
244        let file = toml::from_str::<PlayerPermissionsFile>(&contents).map_err(|error| {
245            io::Error::new(
246                io::ErrorKind::InvalidData,
247                format!(
248                    "invalid player permissions TOML in {}: {error}",
249                    path.display()
250                ),
251            )
252        })?;
253        file.validate()?;
254        Ok(file)
255    }
256
257    pub(crate) async fn write_player_permissions_file_locked(
258        &self,
259        path: &Path,
260        file: &PlayerPermissionsFile,
261    ) -> io::Result<()> {
262        let contents = serialize_player_permissions_file(file).map_err(|error| {
263            io::Error::new(
264                io::ErrorKind::InvalidData,
265                format!("failed to serialize player permissions TOML: {error}"),
266            )
267        })?;
268        Self::write_atomic_path_locked(path, contents.as_bytes()).await
269    }
270
271    pub(crate) fn global_dir(&self) -> PathBuf {
272        self.save_root.join("global")
273    }
274
275    pub(crate) fn global_players_dir(&self) -> PathBuf {
276        self.global_dir().join("players")
277    }
278
279    pub(crate) fn player_permissions_file(&self) -> PathBuf {
280        self.global_dir().join("player_permissions.toml")
281    }
282
283    pub(crate) fn known_players_file(&self) -> PathBuf {
284        self.global_dir().join("known_players.dat")
285    }
286
287    pub(crate) fn domain_players_dir(&self, domain: &str) -> PathBuf {
288        self.save_root.join(domain).join("players")
289    }
290
291    pub(crate) fn player_data_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
292        players_dir.join(format!("data/{uuid}"))
293    }
294
295    pub(crate) fn player_stats_file(players_dir: &Path, uuid: Uuid) -> PathBuf {
296        players_dir.join(format!("stats/{uuid}.toml"))
297    }
298
299    async fn file_lock(&self, path: &Path) -> OwnedAsyncMutexGuard<()> {
300        let mutex = self
301            .file_locks
302            .lock()
303            .entry(path.to_path_buf())
304            .or_insert_with(|| Arc::new(AsyncMutex::new(())))
305            .clone();
306
307        mutex.lock_owned().await
308    }
309
310    async fn write_atomic_player_data(
311        &self,
312        players_dir: &Path,
313        uuid: Uuid,
314        bytes: &[u8],
315    ) -> io::Result<()> {
316        let final_path = Self::player_data_file(players_dir, uuid);
317        let _guard = self.file_lock(&final_path).await;
318        Self::write_atomic_path_locked(&final_path, bytes).await
319    }
320
321    pub(crate) async fn write_atomic_path_locked(
322        final_path: &Path,
323        bytes: &[u8],
324    ) -> io::Result<()> {
325        let Some(parent) = final_path.parent() else {
326            return Err(io::Error::new(
327                io::ErrorKind::InvalidInput,
328                "atomic write path has no parent",
329            ));
330        };
331        fs::create_dir_all(parent).await?;
332        let temp_path = Self::atomic_temp_path(final_path);
333        let backup_path = Self::atomic_backup_path(final_path);
334        let backup_temp_path = Self::atomic_temp_path(&backup_path);
335
336        Self::write_synced_file(&temp_path, bytes).await?;
337        if fs::try_exists(final_path).await? {
338            Self::copy_synced_file(final_path, &backup_temp_path).await?;
339            fs::rename(&backup_temp_path, &backup_path).await?;
340        }
341        fs::rename(&temp_path, final_path).await?;
342        if let Err(error) = Self::sync_parent(parent).await {
343            tracing::error!(
344                %error,
345                path = %final_path.display(),
346                "Atomic data-file replacement committed, but directory sync failed; crash durability is uncertain"
347            );
348        }
349        Ok(())
350    }
351
352    pub(crate) fn atomic_temp_path(path: &Path) -> PathBuf {
353        let extension = path.extension().and_then(|value| value.to_str());
354        path.with_extension(match extension {
355            Some(extension) => format!("{extension}.tmp"),
356            None => "tmp".to_owned(),
357        })
358    }
359
360    pub(crate) fn atomic_backup_path(path: &Path) -> PathBuf {
361        let extension = path.extension().and_then(|value| value.to_str());
362        path.with_extension(match extension {
363            Some(extension) => format!("{extension}_old"),
364            None => "old".to_owned(),
365        })
366    }
367
368    async fn recover_missing_atomic_path_locked(final_path: &Path) -> io::Result<bool> {
369        if fs::try_exists(final_path).await? {
370            return Ok(true);
371        }
372
373        let backup_path = Self::atomic_backup_path(final_path);
374        if fs::try_exists(&backup_path).await? {
375            fs::rename(&backup_path, final_path).await?;
376            let Some(parent) = final_path.parent() else {
377                return Err(io::Error::new(
378                    io::ErrorKind::InvalidInput,
379                    "atomic recovery path has no parent",
380                ));
381            };
382            Self::sync_parent(parent).await?;
383            let temp_path = Self::atomic_temp_path(final_path);
384            if fs::try_exists(&temp_path).await?
385                && let Err(error) = fs::remove_file(&temp_path).await
386            {
387                tracing::warn!(
388                    %error,
389                    path = %temp_path.display(),
390                    "Failed to remove an uncommitted atomic-write temporary file"
391                );
392            }
393            tracing::warn!(
394                path = %final_path.display(),
395                backup = %backup_path.display(),
396                "Recovered a missing data file from its last committed backup"
397            );
398            return Ok(true);
399        }
400
401        let temp_path = Self::atomic_temp_path(final_path);
402        if fs::try_exists(&temp_path).await? {
403            if let Err(error) = fs::remove_file(&temp_path).await {
404                tracing::warn!(
405                    %error,
406                    path = %temp_path.display(),
407                    "Failed to remove an uncommitted atomic-write temporary file"
408                );
409            }
410            tracing::warn!(
411                path = %final_path.display(),
412                temporary = %temp_path.display(),
413                "Discarded an interrupted data-file publication with no committed generation"
414            );
415        }
416
417        Ok(false)
418    }
419
420    async fn write_synced_file(path: &Path, bytes: &[u8]) -> io::Result<()> {
421        let mut file = fs::File::create(path).await?;
422        file.write_all(bytes).await?;
423        file.sync_all().await
424    }
425
426    async fn copy_synced_file(source: &Path, destination: &Path) -> io::Result<()> {
427        let mut source = fs::File::open(source).await?;
428        let mut destination = fs::File::create(destination).await?;
429        io::copy(&mut source, &mut destination).await?;
430        destination.sync_all().await
431    }
432
433    async fn sync_parent(parent: &Path) -> io::Result<()> {
434        // Runtime check so the `.await`s stay present on all platforms for clippy;
435        // on Windows the branch never runs (directory fsync is unix-only).
436        if cfg!(unix) {
437            fs::File::open(parent).await?.sync_all().await?;
438        }
439        Ok(())
440    }
441}