Skip to main content

steel_utils/
saved_data.rs

1//! Per-world saved data storage.
2//!
3//! Vanilla stores world-level saved data under each dimension's `data/`
4//! directory. Steel uses the same per-world saved-data boundary for both its
5//! human-readable TOML data and versioned binary data.
6
7use std::{
8    fmt::Display,
9    fs as sync_fs, io,
10    path::{Path, PathBuf},
11};
12
13use serde::{Serialize, de::DeserializeOwned};
14use tokio::fs;
15use wincode::{SchemaRead, SchemaWrite, config::DefaultConfig};
16
17/// Built-in saved data entry names.
18pub mod names {
19    use super::{SavedDataName, WincodeSavedDataName};
20
21    /// Vanilla `TicketStorage.TYPE`, persisted as `data/chunk_tickets.toml`.
22    pub const CHUNK_TICKETS: SavedDataName = SavedDataName::trusted("chunk_tickets");
23    /// Cached concentric-ring positions, persisted as `data/structure_rings.bin`.
24    pub const STRUCTURE_RINGS: WincodeSavedDataName =
25        WincodeSavedDataName::trusted("structure_rings", *b"STLR", 2);
26    /// Domain command scoreboard, persisted through the domain default world.
27    pub const SCOREBOARD: SavedDataName = SavedDataName::trusted("scoreboard");
28    /// Domain command storage, persisted through the domain default world.
29    pub const COMMAND_STORAGE: SavedDataName = SavedDataName::trusted("command_storage");
30}
31
32/// Name of a per-world saved data entry.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct SavedDataName(&'static str);
35
36impl SavedDataName {
37    /// Creates a saved-data name from a static trusted identifier.
38    #[must_use]
39    pub(crate) const fn trusted(name: &'static str) -> Self {
40        Self(name)
41    }
42
43    /// Creates a saved-data name after validating that it cannot escape `data/`.
44    pub fn try_new(name: &'static str) -> Result<Self, String> {
45        if is_valid_saved_data_name(name) {
46            Ok(Self(name))
47        } else {
48            Err(format!("invalid saved data name {name}"))
49        }
50    }
51
52    fn file_name(self) -> String {
53        format!("{}.toml", self.0)
54    }
55}
56
57/// Name and format header of a wincode-encoded per-world saved data entry.
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct WincodeSavedDataName {
60    name: &'static str,
61    magic: [u8; 4],
62    version: u16,
63}
64
65impl WincodeSavedDataName {
66    /// Creates a binary saved-data name from trusted format metadata.
67    #[must_use]
68    pub(crate) const fn trusted(name: &'static str, magic: [u8; 4], version: u16) -> Self {
69        Self {
70            name,
71            magic,
72            version,
73        }
74    }
75
76    /// Creates a binary saved-data name after validating that it cannot escape `data/`.
77    pub fn try_new(name: &'static str, magic: [u8; 4], version: u16) -> Result<Self, String> {
78        if is_valid_saved_data_name(name) {
79            Ok(Self {
80                name,
81                magic,
82                version,
83            })
84        } else {
85            Err(format!("invalid saved data name {name}"))
86        }
87    }
88
89    fn file_name(self) -> String {
90        format!("{}.bin", self.name)
91    }
92}
93
94fn is_valid_saved_data_name(name: &str) -> bool {
95    !name.is_empty()
96        && !name.contains('/')
97        && !name.contains('\\')
98        && crate::Identifier::validate_path(name)
99}
100
101/// Typed saved-data storage for a loaded world.
102#[derive(Debug, Clone)]
103pub struct SavedDataManager {
104    data_dir: Option<PathBuf>,
105}
106
107impl SavedDataManager {
108    /// Creates saved-data storage rooted at `world_dir/data`.
109    ///
110    /// `None` means the world is ephemeral, matching Steel's RAM-only storage.
111    #[must_use]
112    pub fn new(world_dir: Option<&Path>) -> Self {
113        Self {
114            data_dir: world_dir.map(|path| path.join("data")),
115        }
116    }
117
118    /// Loads a versioned wincode value, or returns `None` when it is absent or
119    /// this world has no persistent storage.
120    pub fn sync_load_wincode<T>(&self, name: WincodeSavedDataName) -> io::Result<Option<T>>
121    where
122        for<'de> T: SchemaRead<'de, DefaultConfig, Dst = T>,
123    {
124        let Some(path) = self.wincode_path_for(name) else {
125            return Ok(None);
126        };
127        if !path.exists() {
128            return Ok(None);
129        }
130
131        let bytes = sync_fs::read(&path)?;
132        let Some((magic, remainder)) = bytes.split_first_chunk::<4>() else {
133            return Err(invalid_binary_data(&path, "missing magic header"));
134        };
135        if magic != &name.magic {
136            return Err(invalid_binary_data(&path, "unexpected magic header"));
137        }
138        let Some((version, payload)) = remainder.split_first_chunk::<2>() else {
139            return Err(invalid_binary_data(&path, "missing format version"));
140        };
141        if u16::from_le_bytes(*version) != name.version {
142            return Err(invalid_binary_data(
143                &path,
144                format!(
145                    "unsupported format version {}",
146                    u16::from_le_bytes(*version)
147                ),
148            ));
149        }
150
151        wincode::deserialize_exact(payload)
152            .map(Some)
153            .map_err(|error| {
154                io::Error::new(
155                    io::ErrorKind::InvalidData,
156                    format!("Invalid binary saved data {}: {error}", path.display()),
157                )
158            })
159    }
160
161    /// Loads saved data, or returns `T::default()` when the data file is absent
162    /// or this world has no persistent storage.
163    pub async fn load_or_default<T>(&self, name: SavedDataName) -> io::Result<T>
164    where
165        T: DeserializeOwned + Default,
166    {
167        let Some(path) = self.path_for(name) else {
168            return Ok(T::default());
169        };
170        if !path.exists() {
171            return Ok(T::default());
172        }
173
174        let content = fs::read_to_string(&path).await?;
175        toml::from_str(&content).map_err(|error| {
176            io::Error::new(
177                io::ErrorKind::InvalidData,
178                format!("Invalid saved data {}: {error}", path.display()),
179            )
180        })
181    }
182
183    /// Saves a versioned wincode value.
184    pub fn sync_save_wincode<T>(&self, name: WincodeSavedDataName, data: &T) -> io::Result<()>
185    where
186        T: SchemaWrite<DefaultConfig, Src = T>,
187    {
188        let Some(path) = self.wincode_path_for(name) else {
189            return Ok(());
190        };
191        if let Some(parent) = path.parent() {
192            sync_fs::create_dir_all(parent)?;
193        }
194
195        let payload = wincode::serialize(data)
196            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error.to_string()))?;
197        let mut bytes = Vec::with_capacity(6 + payload.len());
198        bytes.extend_from_slice(&name.magic);
199        bytes.extend_from_slice(&name.version.to_le_bytes());
200        bytes.extend_from_slice(&payload);
201        sync_fs::write(path, bytes)
202    }
203
204    /// Saves a typed saved-data value.
205    pub async fn save<T>(&self, name: SavedDataName, data: &T) -> io::Result<()>
206    where
207        T: Serialize,
208    {
209        let Some(path) = self.path_for(name) else {
210            return Ok(());
211        };
212        if let Some(parent) = path.parent() {
213            fs::create_dir_all(parent).await?;
214        }
215
216        let content = toml::to_string_pretty(data)
217            .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?;
218        fs::write(path, content).await
219    }
220
221    fn path_for(&self, name: SavedDataName) -> Option<PathBuf> {
222        self.data_dir
223            .as_ref()
224            .map(|data_dir| data_dir.join(name.file_name()))
225    }
226
227    fn wincode_path_for(&self, name: WincodeSavedDataName) -> Option<PathBuf> {
228        self.data_dir
229            .as_ref()
230            .map(|data_dir| data_dir.join(name.file_name()))
231    }
232}
233
234fn invalid_binary_data(path: &Path, message: impl Display) -> io::Error {
235    io::Error::new(
236        io::ErrorKind::InvalidData,
237        format!("Invalid binary saved data {}: {message}", path.display()),
238    )
239}
240
241#[cfg(test)]
242mod tests {
243    use std::{
244        env::temp_dir,
245        io::ErrorKind,
246        path::PathBuf,
247        time::{SystemTime, UNIX_EPOCH},
248    };
249
250    use serde::{Deserialize, Serialize};
251
252    use wincode::{SchemaRead, SchemaWrite};
253
254    use super::{SavedDataManager, SavedDataName, WincodeSavedDataName, sync_fs};
255
256    const TEST_DATA: SavedDataName = SavedDataName::trusted("test_data");
257    const TEST_BINARY_DATA: WincodeSavedDataName =
258        WincodeSavedDataName::trusted("test_binary_data", *b"TEST", 3);
259
260    #[derive(Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
261    struct TestData {
262        value: i32,
263    }
264
265    #[derive(Debug, PartialEq, Eq, SchemaWrite, SchemaRead)]
266    struct TestBinaryData {
267        value: i32,
268    }
269
270    #[test]
271    fn saved_data_name_rejects_paths() {
272        assert!(SavedDataName::try_new("valid_name").is_ok());
273        assert!(SavedDataName::try_new("../outside").is_err());
274        assert!(SavedDataName::try_new("nested/name").is_err());
275        assert!(SavedDataName::try_new("nested\\name").is_err());
276        assert!(SavedDataName::try_new("").is_err());
277        assert!(WincodeSavedDataName::try_new("valid_name", *b"TEST", 1).is_ok());
278        assert!(WincodeSavedDataName::try_new("../outside", *b"TEST", 1).is_err());
279    }
280
281    fn temp_world_dir(test_name: &str) -> PathBuf {
282        let unique = SystemTime::now()
283            .duration_since(UNIX_EPOCH)
284            .expect("system time should be after Unix epoch")
285            .as_nanos();
286        temp_dir().join(format!("steel-saved-data-{test_name}-{unique}"))
287    }
288
289    #[tokio::test]
290    async fn missing_saved_data_loads_default() {
291        let dir = temp_world_dir("missing");
292        let manager = SavedDataManager::new(Some(dir.as_path()));
293
294        let loaded: TestData = manager
295            .load_or_default(TEST_DATA)
296            .await
297            .expect("missing saved data should load default");
298
299        assert_eq!(loaded, TestData::default());
300    }
301
302    #[tokio::test]
303    async fn saved_data_round_trips_through_world_data_dir() {
304        let dir = temp_world_dir("round-trip");
305        let manager = SavedDataManager::new(Some(dir.as_path()));
306
307        manager
308            .save(TEST_DATA, &TestData { value: 42 })
309            .await
310            .expect("saved data should write");
311        let loaded: TestData = manager
312            .load_or_default(TEST_DATA)
313            .await
314            .expect("saved data should load");
315
316        assert_eq!(loaded, TestData { value: 42 });
317        assert!(dir.join("data").join("test_data.toml").exists());
318    }
319
320    #[tokio::test]
321    async fn ephemeral_saved_data_does_not_write() {
322        let manager = SavedDataManager::new(None);
323
324        manager
325            .save(TEST_DATA, &TestData { value: 42 })
326            .await
327            .expect("ephemeral save should be a no-op");
328        let loaded: TestData = manager
329            .load_or_default(TEST_DATA)
330            .await
331            .expect("ephemeral load should return default");
332
333        assert_eq!(loaded, TestData::default());
334    }
335
336    #[test]
337    fn wincode_saved_data_round_trips_with_header() {
338        let dir = temp_world_dir("binary-round-trip");
339        let manager = SavedDataManager::new(Some(dir.as_path()));
340
341        manager
342            .sync_save_wincode(TEST_BINARY_DATA, &TestBinaryData { value: 42 })
343            .expect("binary saved data should write");
344        let loaded: TestBinaryData = manager
345            .sync_load_wincode(TEST_BINARY_DATA)
346            .expect("binary saved data should load")
347            .expect("binary saved data should exist");
348
349        assert_eq!(loaded, TestBinaryData { value: 42 });
350        let bytes = sync_fs::read(dir.join("data").join("test_binary_data.bin"))
351            .expect("binary saved data file should exist");
352        assert_eq!(&bytes[..6], b"TEST\x03\x00");
353
354        let newer_format = WincodeSavedDataName::trusted("test_binary_data", *b"TEST", 4);
355        let error = manager
356            .sync_load_wincode::<TestBinaryData>(newer_format)
357            .expect_err("mismatched binary format version should fail");
358        assert_eq!(error.kind(), ErrorKind::InvalidData);
359    }
360
361    #[test]
362    fn missing_and_ephemeral_wincode_data_return_none() {
363        let dir = temp_world_dir("binary-missing");
364        let persistent = SavedDataManager::new(Some(dir.as_path()));
365        let ephemeral = SavedDataManager::new(None);
366
367        assert!(
368            persistent
369                .sync_load_wincode::<TestBinaryData>(TEST_BINARY_DATA)
370                .expect("missing binary data should load")
371                .is_none()
372        );
373        assert!(
374            ephemeral
375                .sync_load_wincode::<TestBinaryData>(TEST_BINARY_DATA)
376                .expect("ephemeral binary data should load")
377                .is_none()
378        );
379        ephemeral
380            .sync_save_wincode(TEST_BINARY_DATA, &TestBinaryData { value: 42 })
381            .expect("ephemeral binary save should be a no-op");
382    }
383}