The Rust crate and the Android half of one arrangement: a server that binds the tunnel interface and nothing else, certificates it generates and keeps outside any shared checkout, enrolment that carries a token and the CA to a phone, and a client that trusts exactly that certificate and no other. Extracted because ai-app and dev-updater had written all of it twice and the two copies had already drifted -- one of them carried a bug the other did not. History before this point was squashed away; it was a running record of that extraction and of a personal machine's addresses, and neither is worth keeping in a public repository.
247 lines
9.4 KiB
Rust
247 lines
9.4 KiB
Rust
//! 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<T: DeserializeOwned>(text: &str) -> Result<T, ron::error::SpannedError> {
|
|
options().from_str(&format!("({text})"))
|
|
}
|
|
|
|
pub fn render<T: Serialize>(value: &T) -> Result<String, ron::Error> {
|
|
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<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
|
|
/// 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::<Vec<_>>()
|
|
.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<String>,
|
|
}
|
|
|
|
/// 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::<Demo>(&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::<Demo>("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::<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]
|
|
fn a_parse_error_points_at_the_line_it_is_on() {
|
|
let err = parse::<Demo>("name: \"a\",\nnote: ,\n").expect_err("malformed");
|
|
assert_eq!(err.span.start.line, 2, "{err}");
|
|
}
|
|
}
|