json.rs (20695B)
1 use serde::{Serialize, de::DeserializeOwned}; 2 use std::{ 3 fs, 4 io::{self, Write}, 5 path::{Path, PathBuf}, 6 }; 7 use tempfile::NamedTempFile; 8 use thiserror::Error; 9 10 #[cfg(unix)] 11 use std::os::unix::fs::PermissionsExt; 12 13 #[derive(Debug, Error)] 14 pub enum RuntimeJsonError { 15 #[error("JSON file does not exist at {0}")] 16 NotFound(PathBuf), 17 18 #[error("Failed to open JSON file at {0}: {1}")] 19 FileOpen(PathBuf, #[source] io::Error), 20 21 #[error("Failed to parse JSON at {0}: {1}")] 22 FileParse(PathBuf, #[source] serde_json::Error), 23 24 #[error("Failed to serialize JSON: {0}")] 25 Serialization(#[from] serde_json::Error), 26 27 #[error("I/O error during JSON write: {0}")] 28 Io(#[from] io::Error), 29 30 #[error("Failed to persist JSON file to disk: {0}")] 31 Persist(#[from] tempfile::PersistError), 32 } 33 34 #[derive(Debug, Clone)] 35 pub struct JsonWriteOptions { 36 pub pretty: bool, 37 pub mode_unix: Option<u32>, 38 } 39 40 impl Default for JsonWriteOptions { 41 fn default() -> Self { 42 Self { 43 pretty: false, 44 mode_unix: Some(0o600), 45 } 46 } 47 } 48 49 #[derive(Debug, Clone)] 50 pub struct JsonFile<T> { 51 pub value: T, 52 path: PathBuf, 53 options: JsonWriteOptions, 54 } 55 56 impl<T> JsonFile<T> { 57 pub fn path(&self) -> &Path { 58 &self.path 59 } 60 61 pub fn set_options(&mut self, options: JsonWriteOptions) { 62 self.options = options; 63 } 64 } 65 66 impl<T> JsonFile<T> 67 where 68 T: Serialize + DeserializeOwned, 69 { 70 pub fn load(path: impl AsRef<Path>) -> Result<Self, RuntimeJsonError> { 71 let p = path.as_ref().to_path_buf(); 72 if !p.exists() { 73 return Err(RuntimeJsonError::NotFound(p)); 74 } 75 let file = std::fs::File::open(&p).map_err(|e| RuntimeJsonError::FileOpen(p.clone(), e))?; 76 let reader = std::io::BufReader::new(file); 77 let value = serde_json::from_reader(reader) 78 .map_err(|e| RuntimeJsonError::FileParse(p.clone(), e))?; 79 Ok(Self { 80 value, 81 path: p, 82 options: JsonWriteOptions::default(), 83 }) 84 } 85 86 pub fn load_or_create_with<F>(path: impl AsRef<Path>, init: F) -> Result<Self, RuntimeJsonError> 87 where 88 F: FnOnce() -> T, 89 { 90 let p = path.as_ref().to_path_buf(); 91 if p.exists() { 92 return Self::load(p); 93 } 94 let s = Self { 95 value: init(), 96 path: p, 97 options: JsonWriteOptions::default(), 98 }; 99 s.save()?; 100 Ok(s) 101 } 102 103 pub fn save(&self) -> Result<(), RuntimeJsonError> { 104 self.save_as(self.path.clone()) 105 } 106 107 pub fn save_as(&self, new_path: impl AsRef<Path>) -> Result<(), RuntimeJsonError> { 108 let json = if self.options.pretty { 109 serde_json::to_string_pretty(&self.value)? 110 } else { 111 serde_json::to_string(&self.value)? 112 }; 113 atomic_write_json(new_path.as_ref(), json.as_bytes(), self.options.mode_unix)?; 114 Ok(()) 115 } 116 117 pub fn modify<F>(&mut self, f: F) -> Result<(), RuntimeJsonError> 118 where 119 F: FnOnce(&mut T), 120 { 121 f(&mut self.value); 122 self.save() 123 } 124 } 125 126 #[cfg(test)] 127 mod test_hooks { 128 use std::collections::HashMap; 129 use std::sync::{Mutex, OnceLock}; 130 use std::thread::{self, ThreadId}; 131 132 const FAIL_WRITE: u8 = 1; 133 const FAIL_SYNC: u8 = 2; 134 const FAIL_PERMS: u8 = 3; 135 136 static FAIL_POINTS: OnceLock<Mutex<HashMap<ThreadId, u8>>> = OnceLock::new(); 137 138 pub struct FailGuard { 139 thread_id: ThreadId, 140 } 141 142 impl Drop for FailGuard { 143 fn drop(&mut self) { 144 clear(self.thread_id); 145 } 146 } 147 148 pub fn fail_write() -> FailGuard { 149 set(FAIL_WRITE) 150 } 151 152 pub fn fail_sync() -> FailGuard { 153 set(FAIL_SYNC) 154 } 155 156 pub fn fail_perms() -> FailGuard { 157 set(FAIL_PERMS) 158 } 159 160 pub fn take_write() -> bool { 161 take(FAIL_WRITE) 162 } 163 164 pub fn take_sync() -> bool { 165 take(FAIL_SYNC) 166 } 167 168 pub fn take_perms() -> bool { 169 take(FAIL_PERMS) 170 } 171 172 fn set(point: u8) -> FailGuard { 173 let thread_id = thread::current().id(); 174 fail_map() 175 .lock() 176 .expect("lock fail hooks") 177 .insert(thread_id, point); 178 FailGuard { thread_id } 179 } 180 181 fn clear(thread_id: ThreadId) { 182 fail_map() 183 .lock() 184 .expect("lock clear hooks") 185 .remove(&thread_id); 186 } 187 188 fn take(point: u8) -> bool { 189 let thread_id = thread::current().id(); 190 let mut map = fail_map().lock().expect("lock take hooks"); 191 match map.get(&thread_id).copied() { 192 Some(current_point) if current_point == point => { 193 map.remove(&thread_id); 194 true 195 } 196 _ => false, 197 } 198 } 199 200 fn fail_map() -> &'static Mutex<HashMap<ThreadId, u8>> { 201 FAIL_POINTS.get_or_init(|| Mutex::new(HashMap::new())) 202 } 203 } 204 205 fn write_temp_file(tmp: &mut NamedTempFile, bytes: &[u8]) -> io::Result<()> { 206 #[cfg(test)] 207 if test_hooks::take_write() { 208 return Err(io::Error::new(io::ErrorKind::Other, "forced write failure")); 209 } 210 tmp.write_all(bytes) 211 } 212 213 fn sync_temp_file(tmp: &mut NamedTempFile) -> io::Result<()> { 214 #[cfg(test)] 215 if test_hooks::take_sync() { 216 return Err(io::Error::new(io::ErrorKind::Other, "forced sync failure")); 217 } 218 tmp.as_file_mut().sync_all() 219 } 220 221 #[cfg(unix)] 222 fn set_temp_permissions(path: &Path, mode: u32) -> io::Result<()> { 223 #[cfg(test)] 224 if test_hooks::take_perms() { 225 return Err(io::Error::new( 226 io::ErrorKind::Other, 227 "forced permissions failure", 228 )); 229 } 230 fs::set_permissions(path, fs::Permissions::from_mode(mode)) 231 } 232 233 fn atomic_write_json( 234 path: &Path, 235 bytes: &[u8], 236 mode_unix: Option<u32>, 237 ) -> Result<(), RuntimeJsonError> { 238 let dir = path.parent().unwrap_or_else(|| Path::new(".")); 239 fs::create_dir_all(dir).ok(); 240 241 let mut tmp = NamedTempFile::new_in(dir)?; 242 write_temp_file(&mut tmp, bytes)?; 243 sync_temp_file(&mut tmp)?; 244 245 #[cfg(unix)] 246 if let Some(mode) = mode_unix { 247 set_temp_permissions(tmp.path(), mode)?; 248 } 249 250 tmp.persist(path)?; 251 Ok(()) 252 } 253 254 #[cfg(test)] 255 mod tests { 256 use super::{JsonFile, JsonWriteOptions, atomic_write_json, test_hooks}; 257 use serde::{Deserialize, Serialize, Serializer}; 258 use std::path::{Path, PathBuf}; 259 use tempfile::tempdir; 260 261 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 262 struct Payload { 263 id: String, 264 count: u32, 265 } 266 267 #[derive(Debug, Clone, Deserialize, PartialEq)] 268 struct SerializeToggle { 269 fail: bool, 270 label: String, 271 } 272 273 #[derive(Serialize)] 274 struct SerializeToggleData<'a> { 275 fail: bool, 276 label: &'a str, 277 } 278 279 impl Serialize for SerializeToggle { 280 fn serialize<S>(&self, _serializer: S) -> Result<S::Ok, S::Error> 281 where 282 S: Serializer, 283 { 284 if self.fail { 285 Err(serde::ser::Error::custom(format!( 286 "serialize error: {}", 287 core::any::type_name::<S>() 288 ))) 289 } else { 290 SerializeToggleData { 291 fail: self.fail, 292 label: &self.label, 293 } 294 .serialize(_serializer) 295 } 296 } 297 } 298 299 fn payload_path(name: &str) -> (tempfile::TempDir, PathBuf) { 300 let dir = tempdir().expect("tempdir"); 301 let path = dir.path().join(name); 302 (dir, path) 303 } 304 305 fn toggle_default() -> SerializeToggle { 306 SerializeToggle { 307 fail: false, 308 label: "item-1".to_string(), 309 } 310 } 311 312 fn toggle_should_not_create() -> SerializeToggle { 313 SerializeToggle { 314 fail: false, 315 label: "should-not-create".to_string(), 316 } 317 } 318 319 #[test] 320 fn toggle_should_not_create_builds_expected_value() { 321 let value = toggle_should_not_create(); 322 assert_eq!(value.label, "should-not-create"); 323 assert!(!value.fail); 324 } 325 326 #[test] 327 fn load_reports_not_found_for_missing_path() { 328 let (_dir, path) = payload_path("missing.json"); 329 let err = JsonFile::<Payload>::load(path.clone()).expect_err("missing path should fail"); 330 assert!(err.to_string().contains(path.to_string_lossy().as_ref())); 331 } 332 333 #[test] 334 fn serialize_toggle_load_reports_not_found_for_missing_path() { 335 let (_dir, path) = payload_path("missing-toggle.json"); 336 let err = 337 JsonFile::<SerializeToggle>::load(path.clone()).expect_err("missing path should fail"); 338 assert!(err.to_string().contains(path.to_string_lossy().as_ref())); 339 } 340 341 #[test] 342 fn load_reports_file_open_error_for_directory() { 343 let dir = tempdir().expect("tempdir"); 344 let err = JsonFile::<Payload>::load(dir.path().to_path_buf()) 345 .expect_err("directory path should fail"); 346 assert!(err.to_string().contains("Failed to parse JSON")); 347 assert!( 348 err.to_string() 349 .contains(dir.path().to_string_lossy().as_ref()) 350 ); 351 } 352 353 #[cfg(unix)] 354 #[test] 355 fn load_reports_file_open_error_for_unreadable_file_path() { 356 use std::os::unix::fs::PermissionsExt; 357 358 let dir = tempdir().expect("tempdir"); 359 let path = dir.path().join("unreadable.json"); 360 std::fs::write(&path, "{}").expect("write json"); 361 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) 362 .expect("set unreadable permission"); 363 364 let err = JsonFile::<Payload>::load(path.clone()).expect_err("owned path should fail"); 365 assert!(err.to_string().contains("Failed to open JSON file")); 366 367 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) 368 .expect("restore permission"); 369 } 370 371 #[test] 372 fn load_reports_file_parse_error_for_invalid_json() { 373 let (_dir, path) = payload_path("invalid.json"); 374 std::fs::write(&path, "{invalid json").expect("write invalid json"); 375 let err = JsonFile::<Payload>::load(path.clone()).expect_err("invalid json should fail"); 376 assert!(err.to_string().contains("Failed to parse JSON")); 377 } 378 379 #[test] 380 fn serialize_toggle_load_reports_file_open_error_for_directory() { 381 let dir = tempdir().expect("tempdir"); 382 let err = JsonFile::<SerializeToggle>::load(dir.path().to_path_buf()) 383 .expect_err("directory path should fail"); 384 assert!(err.to_string().contains("Failed to parse JSON")); 385 assert!( 386 err.to_string() 387 .contains(dir.path().to_string_lossy().as_ref()) 388 ); 389 } 390 391 #[test] 392 fn serialize_toggle_load_reports_file_parse_error_for_invalid_json() { 393 let (_dir, path) = payload_path("invalid-toggle.json"); 394 std::fs::write(&path, "{invalid json").expect("write invalid json"); 395 let err = 396 JsonFile::<SerializeToggle>::load(path.clone()).expect_err("invalid json should fail"); 397 assert!(err.to_string().contains("Failed to parse JSON")); 398 } 399 400 #[cfg(unix)] 401 #[test] 402 fn serialize_toggle_load_reports_file_open_error_for_unreadable_file_path() { 403 use std::os::unix::fs::PermissionsExt; 404 405 let dir = tempdir().expect("tempdir"); 406 let path = dir.path().join("unreadable-toggle.json"); 407 std::fs::write(&path, r#"{"fail":false,"label":"ok"}"#).expect("write json"); 408 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000)) 409 .expect("set unreadable permission"); 410 411 let err = 412 JsonFile::<SerializeToggle>::load(path.clone()).expect_err("owned path should fail"); 413 assert!(err.to_string().contains("Failed to open JSON file")); 414 415 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)) 416 .expect("restore permission"); 417 } 418 419 #[test] 420 fn load_reads_valid_json_payload() { 421 let (_dir, path) = payload_path("valid.json"); 422 let payload = Payload { 423 id: "item-1".to_string(), 424 count: 2, 425 }; 426 let encoded = serde_json::to_string(&payload).expect("serialize payload"); 427 std::fs::write(&path, encoded).expect("write json"); 428 let loaded = JsonFile::<Payload>::load(path.clone()).expect("load json"); 429 assert_eq!(loaded.value, payload); 430 } 431 432 #[test] 433 fn load_or_create_save_modify_and_load_round_trip() { 434 let (_dir, path) = payload_path("payload.json"); 435 let builder: fn() -> SerializeToggle = toggle_default; 436 let mut json = JsonFile::load_or_create_with(path.clone(), builder).expect("create json"); 437 438 assert_eq!(json.path(), path); 439 assert_eq!(json.value, toggle_default()); 440 441 json.set_options(JsonWriteOptions { 442 pretty: true, 443 mode_unix: None, 444 }); 445 json.modify(|value| { 446 value.label = "item-2".to_string(); 447 }) 448 .expect("modify json"); 449 450 let raw = std::fs::read_to_string(&path).expect("read json"); 451 assert!(raw.contains('\n')); 452 453 let skip_builder: fn() -> SerializeToggle = toggle_should_not_create; 454 let loaded = JsonFile::<SerializeToggle>::load_or_create_with(path.clone(), skip_builder) 455 .expect("load existing json"); 456 assert_eq!( 457 loaded.value, 458 SerializeToggle { 459 fail: false, 460 label: "item-2".to_string(), 461 } 462 ); 463 } 464 465 #[test] 466 fn load_or_create_reports_save_error() { 467 let (_dir, path) = payload_path("create-error.json"); 468 let builder: fn() -> SerializeToggle = toggle_default; 469 let _guard = test_hooks::fail_write(); 470 let err = JsonFile::load_or_create_with(path.clone(), builder) 471 .expect_err("save failure should surface"); 472 assert!(err.to_string().contains("I/O error during JSON write")); 473 } 474 475 #[test] 476 fn save_as_writes_to_new_path() { 477 let (_src_dir, source) = payload_path("source.json"); 478 let (_dst_dir, destination) = payload_path("dest.json"); 479 let builder: fn() -> SerializeToggle = toggle_default; 480 let json = 481 JsonFile::load_or_create_with(source.clone(), builder).expect("create source json"); 482 483 json.save_as(destination.clone()).expect("save as"); 484 let loaded = 485 JsonFile::<SerializeToggle>::load(destination.clone()).expect("load destination json"); 486 assert_eq!(loaded.value, toggle_default()); 487 } 488 489 #[test] 490 fn save_reports_io_error_when_parent_is_not_directory() { 491 let dir = tempdir().expect("tempdir"); 492 let parent_file = dir.path().join("not-a-dir"); 493 std::fs::write(&parent_file, "file").expect("write parent file"); 494 let target = parent_file.join("payload.json"); 495 496 let builder: fn() -> SerializeToggle = toggle_default; 497 let json = JsonFile::load_or_create_with(dir.path().join("valid.json"), builder) 498 .expect("create json"); 499 500 let err = json 501 .save_as(target.clone()) 502 .expect_err("io error should surface"); 503 assert!(err.to_string().contains("I/O error during JSON write")); 504 } 505 506 #[test] 507 fn save_reports_persist_error_when_target_is_directory() { 508 let dir = tempdir().expect("tempdir"); 509 let target_dir = dir.path().join("target"); 510 std::fs::create_dir_all(&target_dir).expect("create target dir"); 511 512 let builder: fn() -> SerializeToggle = toggle_default; 513 let json = JsonFile::load_or_create_with(dir.path().join("value.json"), builder) 514 .expect("create json"); 515 516 let err = json 517 .save_as(target_dir.clone()) 518 .expect_err("persist error should surface"); 519 assert!( 520 err.to_string() 521 .contains("Failed to persist JSON file to disk") 522 ); 523 } 524 525 #[test] 526 fn save_reports_serialization_error() { 527 let (_dir, path) = payload_path("serialize-error.json"); 528 let mut json = JsonFile { 529 value: SerializeToggle { 530 fail: true, 531 label: "error".to_string(), 532 }, 533 path, 534 options: JsonWriteOptions::default(), 535 }; 536 json.set_options(JsonWriteOptions { 537 pretty: true, 538 mode_unix: Some(0o600), 539 }); 540 541 let err = json.save().expect_err("serialization error should surface"); 542 assert!(err.to_string().contains("Failed to serialize JSON")); 543 } 544 545 #[test] 546 fn save_reports_serialization_error_non_pretty() { 547 let (_dir, path) = payload_path("serialize-error-plain.json"); 548 let json = JsonFile { 549 value: SerializeToggle { 550 fail: true, 551 label: "error".to_string(), 552 }, 553 path, 554 options: JsonWriteOptions::default(), 555 }; 556 let err = json.save().expect_err("serialization error should surface"); 557 assert!(err.to_string().contains("Failed to serialize JSON")); 558 } 559 560 #[test] 561 fn save_writes_when_serialize_toggle_allows() { 562 let (_dir, path) = payload_path("serialize-ok.json"); 563 let json = JsonFile { 564 value: SerializeToggle { 565 fail: false, 566 label: "ok".to_string(), 567 }, 568 path, 569 options: JsonWriteOptions::default(), 570 }; 571 json.save().expect("save should succeed"); 572 } 573 574 #[test] 575 fn atomic_write_json_honors_mode_none_and_some() { 576 let (_none_dir, path_none) = payload_path("mode-none.json"); 577 atomic_write_json(&path_none, br#"{"id":"x","count":1}"#, None) 578 .expect("write without mode"); 579 let (_some_dir, path_some) = payload_path("mode-some.json"); 580 atomic_write_json(&path_some, br#"{"id":"y","count":2}"#, Some(0o600)) 581 .expect("write with mode"); 582 583 let err = 584 atomic_write_json(Path::new("/"), br#"{}"#, None).expect_err("root write should fail"); 585 let message = err.to_string(); 586 let is_persist = message.contains("Failed to persist JSON file to disk"); 587 let is_io = message.contains("I/O error during JSON write"); 588 assert!(is_persist | is_io); 589 } 590 591 #[test] 592 fn atomic_write_json_reports_write_error() { 593 let (_dir, path) = payload_path("write-error.json"); 594 let _guard = test_hooks::fail_write(); 595 let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) 596 .expect_err("write error should surface"); 597 assert!(err.to_string().contains("I/O error during JSON write")); 598 } 599 600 #[test] 601 fn atomic_write_json_reports_sync_error() { 602 let (_dir, path) = payload_path("sync-error.json"); 603 let _guard = test_hooks::fail_sync(); 604 let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) 605 .expect_err("sync error should surface"); 606 assert!(err.to_string().contains("I/O error during JSON write")); 607 } 608 609 #[cfg(unix)] 610 #[test] 611 fn atomic_write_json_reports_permissions_error() { 612 let (_dir, path) = payload_path("perms-error.json"); 613 let _guard = test_hooks::fail_perms(); 614 let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, Some(0o600)) 615 .expect_err("permissions error should surface"); 616 assert!(err.to_string().contains("I/O error during JSON write")); 617 } 618 619 #[test] 620 fn fail_hook_ignores_other_points() { 621 let (_dir, path) = payload_path("ignore-other.json"); 622 let _guard = test_hooks::fail_write(); 623 assert!(!test_hooks::take_sync()); 624 let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) 625 .expect_err("write error should surface"); 626 assert!(err.to_string().contains("I/O error during JSON write")); 627 } 628 629 #[test] 630 fn fail_hook_is_thread_local() { 631 let dir = tempdir().expect("tempdir"); 632 let path = dir.path().join("thread-local.json"); 633 let other_path = dir.path().join("thread-ok.json"); 634 let _guard = test_hooks::fail_write(); 635 let handle = std::thread::spawn(move || { 636 atomic_write_json(&other_path, br#"{"id":"x","count":1}"#, None) 637 .expect("other thread write"); 638 }); 639 handle.join().expect("join thread"); 640 let err = atomic_write_json(&path, br#"{"id":"x","count":1}"#, None) 641 .expect_err("write error should surface"); 642 assert!(err.to_string().contains("I/O error during JSON write")); 643 } 644 }