1use 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
19pub mod names {
21 use super::{SavedDataName, WincodeSavedDataName};
22
23 pub const CHUNK_TICKETS: SavedDataName = SavedDataName::trusted("chunk_tickets");
25 pub const STRUCTURE_RINGS: WincodeSavedDataName =
27 WincodeSavedDataName::trusted("structure_rings", *b"STLR", 2);
28 pub const SCOREBOARD: SavedDataName = SavedDataName::trusted("scoreboard");
30 pub const COMMAND_STORAGE: SavedDataName = SavedDataName::trusted("command_storage");
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub struct SavedDataName(&'static str);
37
38impl SavedDataName {
39 #[must_use]
41 pub(crate) const fn trusted(name: &'static str) -> Self {
42 Self(name)
43 }
44
45 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#[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 #[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 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#[derive(Debug, Clone)]
106pub struct SavedDataManager {
107 data_dir: Option<PathBuf>,
108}
109
110impl SavedDataManager {
111 #[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 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 #[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 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 #[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}