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