Write the config file in one place too, since both did it identically
Both projects saved by rendering, writing a temp file through `private`,
and renaming over the target. `format::write` is that, and it belongs with
the house rules rather than beside either schema: it is the two existing
modules used together, and the thing worth stating once is why they are
used together at all.
That thing is the temp file, and it is not obvious. It is not always new
-- a save killed partway leaves one behind, and reopening it keeps
whatever mode it already had, which is then renamed straight over the file
holding the enrolled token hashes. Opening through `private` sets the mode
on the way in, so the fresh and the leftover case are the same case.
There is a test that creates a 0644 leftover and asserts the config comes
out 0600, because that state cannot arise on a machine where nothing has
ever crashed mid-save -- which is every machine either project has been
developed on.
The temp name is now appended rather than substituted, so `config.ron`
yields `config.ron.tmp`. `with_extension("tmp")` would have produced
`config.tmp`, which is a name that could plausibly belong to something
else.
This commit is contained in:
1 parent
4de8bff5f2
commit
db4552f4e4
2 files changed
+92
-2
No files matched your search
@@ -35,8 +35,13 @@
|
||||
/// `#![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)
|
||||
}
|
||||
@@ -51,6 +56,40 @@ pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
||||
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<T: Serialize>(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
|
||||
@@ -146,6 +185,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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::<Demo>(&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]
|
||||
|
||||
Reference in new issue
Block a user