//! Reading and writing the RON both projects' config files are in. //! //! Two things are house rules rather than plain RON, and they are here //! together because they are inverses of each other -- change one and the //! other stops round-tripping. Both projects had this, identically, to the //! byte; that is what made it the first thing worth sharing. //! //! **No outer parentheses.** A file *is* the body of the struct, so //! nothing in it is indented for the sake of a wrapper. RON has no //! implicit top-level struct (`de/mod.rs` requires the `(`), so [`parse`] //! adds it and [`render`] takes it back off. The opening paren is not //! followed by a newline, so a parse error's line number still points at //! the real line. //! //! **`Some` is implicit.** Enabled on the deserializer rather than by a //! `#![enable(implicit_some)]` header every file would have to remember, //! and matched on the writing side by `skip_serializing_if` so nothing //! writes back a `Some(...)` a person didn't type. The two halves only //! round-trip together, which is why a caller's own tests should assert //! the written shape rather than only that it loads. /// Reading and writing the RON these files are in. /// /// Two things are house rules rather than plain RON, and they are here /// together because they are inverses of each other -- change one and the /// other stops round-tripping. /// /// **No outer parentheses.** A file *is* the body of the struct, so nothing /// in it is indented for the sake of a wrapper. RON has no implicit /// top-level struct (`de/mod.rs` requires the `(`), so [`format::parse`] /// adds it and [`format::render`] takes it back off. The opening paren is not followed by a /// newline, so a parse error's line number still points at the real line. /// /// **`Some` is implicit.** Enabled on the deserializer rather than by a /// `#![enable(implicit_some)]` header every project file would have to /// remember, and matched on the writing side by `skip_serializing_if` so /// nothing writes back a `Some(...)` a person didn't type. use std::path::Path; use anyhow::{Context, Result}; use serde::{Serialize, de::DeserializeOwned}; use crate::private; fn options() -> ron::Options { ron::Options::default().with_default_extension(ron::extensions::Extensions::IMPLICIT_SOME) } pub fn parse(text: &str) -> Result { options().from_str(&format!("({text})")) } pub fn render(value: &T) -> Result { let pretty = ron::ser::PrettyConfig::new(); let text = options().to_string_pretty(value, pretty)?; Ok(unwrap_outer(&text)) } /// Renders `value` and replaces `path` with it, atomically and owner-only. /// /// Whole-file-and-rename rather than an in-place edit, because both /// projects' config files are small, are read at startup, and hold the /// enrolled token hashes -- a half-written one would take the server down /// on its next start with no way to fix it from a phone. The rename is /// what makes a reader see either the old file or the new one and never /// part of both. /// /// The temp file goes through [`private::write_file`] rather than /// `std::fs::write`, and that is the subtle half: **the temp file is not /// always new.** A save killed partway leaves one behind, and opening that /// again keeps whatever mode it already had -- which is then renamed over /// the file holding the token hashes. Setting the mode as it is opened /// covers both the fresh and the leftover case. pub fn write(path: &Path, value: &T) -> Result<()> { if let Some(parent) = path.parent() { private::create_dir(parent)?; } let text = render(value).context("serialize config")?; // Appended rather than substituted, so `config.ron` yields // `config.ron.tmp` and not `config.tmp` -- a name that cannot collide // with a real file and that says what it is a temporary copy of. let tmp = path.with_file_name(format!( "{}.tmp", path.file_name() .unwrap_or_else(|| std::ffi::OsStr::new("config")) .to_string_lossy() )); private::write_file(&tmp, text.as_bytes())?; std::fs::rename(&tmp, path) .with_context(|| format!("replace {} with {}", path.display(), tmp.display())) } /// Strips the outer `(`/`)` the writer always emits and removes the /// indent level they cost. Deliberately narrow: it accepts only the /// exact shape `PrettyConfig` produces, and leaves anything else alone /// rather than guessing -- a file with stray parentheses is better than /// one silently mangled. `parse` round-trips either way, since a /// wrapped body parses the same as an unwrapped one re-wrapped. fn unwrap_outer(text: &str) -> String { let Some(body) = text .strip_prefix("(\n") .and_then(|rest| rest.strip_suffix("\n)")) else { return text.to_string(); }; let mut out: String = body .lines() .map(|line| line.strip_prefix(" ").unwrap_or(line)) .collect::>() .join("\n"); out.push('\n'); out } #[cfg(test)] mod tests { use serde::Deserialize; use super::*; #[derive(Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] struct Demo { name: String, #[serde(skip_serializing_if = "Option::is_none")] note: Option, } /// The house rule both halves depend on: what is written is the *body* /// of the struct, with no outer parentheses and nothing indented for /// them. Asserted rather than trusted, because `render` strips what /// `parse` adds -- if only one ever changed, every file on disk would /// still load and only look wrong. #[test] fn a_file_is_the_body_of_the_struct() { let written = render(&Demo { name: "thing".to_string(), note: Some("why".to_string()), }) .expect("render"); assert!( !written.trim_start().starts_with('('), "outer parens: {written}" ); assert!( written.starts_with("name: "), "top level sits at column 0: {written}" ); assert_eq!( parse::(&written).expect("re-read").name, "thing", "what is written must read back", ); } /// An optional value is written as itself, never wrapped -- and a /// value nobody set is not written at all, so a file stays readable as /// what was actually chosen. #[test] fn an_optional_value_is_written_as_itself_or_not_at_all() { let with = render(&Demo { name: "a".to_string(), note: Some("b".to_string()), }) .expect("render"); assert!( with.contains(r#"note: "b""#), "no Some(...) wrapper: {with}" ); let without = render(&Demo::default()).expect("render"); assert!( !without.contains("note"), "an unset value writes nothing: {without}" ); // And the bare form reads back, which is the other half. assert_eq!( parse::("name: \"a\",\nnote: \"b\",\n") .expect("parse") .note .as_deref(), Some("b") ); } #[test] fn what_is_written_reads_back_and_is_owner_only() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("nested").join("config.ron"); let value = Demo { name: "thing".to_string(), note: None, }; write(&path, &value).expect("write"); assert_eq!( parse::(&std::fs::read_to_string(&path).expect("read")).expect("re-read"), value, ); let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); assert_eq!(mode & 0o777, 0o600, "config holds token hashes: {mode:o}"); assert!( !path.with_extension("ron.tmp").exists(), "the temp file is renamed away, not left behind", ); } /// The case the whole thing turns on, and the one that cannot happen on /// a machine where nothing has ever crashed mid-save: a leftover temp /// file from an interrupted write is reopened, and if its mode came /// along it would be renamed straight over the file holding the enrolled /// token hashes. #[test] fn a_leftover_temp_file_cannot_widen_the_config() { use std::os::unix::fs::PermissionsExt; let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("config.ron"); let tmp = dir.path().join("config.ron.tmp"); std::fs::write(&tmp, b"leftover from a save that died").expect("stale temp"); std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o644)).expect("widen"); write(&path, &Demo::default()).expect("write"); let mode = std::fs::metadata(&path).expect("stat").permissions().mode(); assert_eq!( mode & 0o777, 0o600, "a world-readable leftover must not become the config: {mode:o}", ); } /// A parse error's line number has to point at the real line, which is /// why the opening paren is not followed by a newline. #[test] fn a_parse_error_points_at_the_line_it_is_on() { let err = parse::("name: \"a\",\nnote: ,\n").expect_err("malformed"); assert_eq!(err.span.start.line, 2, "{err}"); } }