Files
wg-app-link/server/src/format.rs
T
iris f95bc77f7b wg-app-link: the WireGuard-and-pinned-TLS half both apps needed
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.
2026-08-31 20:27:27 -04:00

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}");
}
}