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