lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

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 }