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.
This commit is contained in:
commit
f95bc77f7b
17 files changed
+2552
No files matched your search
@@ -0,0 +1,239 @@
|
||||
//! The TLS certificates a server presents, generated in process on first
|
||||
//! start, from a CA the app pins.
|
||||
//!
|
||||
//! Shared because it existed twice and differed by an organisation name.
|
||||
//! It is also the piece where being written twice is worst: a trust
|
||||
//! anchor built two ways can be built differently two ways, and the
|
||||
//! difference would surface as an opaque handshake failure on a phone.
|
||||
//!
|
||||
//! `product` names the certificate's organisation and common name, and is
|
||||
//! the whole of what is per-project.
|
||||
//!
|
||||
//! There used to be a `gen-dev-cert.sh` calling openssl, which meant a
|
||||
//! setup step to remember, a second place for the "which SANs?" answer to
|
||||
//! live, and a dependency on whatever openssl was installed. Doing it here
|
||||
//! means the server can simply ensure its own certificates exist, with the
|
||||
//! file modes and extensions it wants, and with the address it is actually
|
||||
//! about to bind already in the leaf.
|
||||
//!
|
||||
//! The split that matters is between the two:
|
||||
//!
|
||||
//! - The **CA** is generated once and then left alone. The updater app
|
||||
//! pins it, so replacing it strands every installed copy -- recovery is
|
||||
//! a reinstall over the plain-HTTP bootstrap port. It is the one thing
|
||||
//! here that is a one-way door.
|
||||
//! - The **leaf** is cheap and reissued on every start, signed by that
|
||||
//! same unchanged CA. Nothing pins it, so covering a new address is just
|
||||
//! a restart rather than anything the phone has to be told about.
|
||||
//!
|
||||
//! Everything is written owner-only into a directory outside the repo (see
|
||||
//! `config_home`): a CA private key readable by another machine is one it
|
||||
//! can sign with, and a certificate signed by a pinned CA is accepted
|
||||
//! without question.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DnType, IsCa, Issuer, KeyPair, KeyUsagePurpose, SanType,
|
||||
};
|
||||
|
||||
/// Where the leaf lives, for handing to the TLS listener.
|
||||
pub struct Certificates {
|
||||
pub leaf_cert: PathBuf,
|
||||
pub leaf_key: PathBuf,
|
||||
/// True when the CA was created just now, i.e. anything already
|
||||
/// installed pins the wrong one and has to be reinstalled.
|
||||
pub ca_is_new: bool,
|
||||
}
|
||||
|
||||
/// Ensures `dir` holds a CA and a leaf covering `addresses`, creating what
|
||||
/// is missing. Safe to call on every start.
|
||||
pub fn ensure(product: &str, dir: &Path, addresses: &[IpAddr]) -> Result<Certificates> {
|
||||
crate::private::create_dir(dir)?;
|
||||
|
||||
let ca_cert_path = dir.join("ca.pem");
|
||||
let ca_key_path = dir.join("ca-key.pem");
|
||||
let ca_is_new = !ca_cert_path.is_file() || !ca_key_path.is_file();
|
||||
|
||||
let (ca_pem, ca_key_pem) = if ca_is_new {
|
||||
let (pem, key) = generate_ca(product)?;
|
||||
crate::private::write_file(&ca_key_path, key.as_bytes())?;
|
||||
crate::private::write_file(&ca_cert_path, pem.as_bytes())?;
|
||||
tracing::info!("generated a new CA in {}", dir.display());
|
||||
(pem, key)
|
||||
} else {
|
||||
(
|
||||
std::fs::read_to_string(&ca_cert_path)
|
||||
.with_context(|| format!("read {}", ca_cert_path.display()))?,
|
||||
std::fs::read_to_string(&ca_key_path)
|
||||
.with_context(|| format!("read {}", ca_key_path.display()))?,
|
||||
)
|
||||
};
|
||||
|
||||
let (leaf_pem, leaf_key_pem) = generate_leaf(product, &ca_pem, &ca_key_pem, addresses)?;
|
||||
let leaf_cert = dir.join("leaf.pem");
|
||||
let leaf_key = dir.join("leaf-key.pem");
|
||||
crate::private::write_file(&leaf_key, leaf_key_pem.as_bytes())?;
|
||||
crate::private::write_file(&leaf_cert, leaf_pem.as_bytes())?;
|
||||
|
||||
Ok(Certificates {
|
||||
leaf_cert,
|
||||
leaf_key,
|
||||
ca_is_new,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_ca(product: &str) -> Result<(String, String)> {
|
||||
let key = KeyPair::generate().context("generate CA key")?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, format!("{product} dev"));
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::CommonName, format!("{product} dev CA"));
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Unconstrained);
|
||||
// Explicit, because strict verifiers reject a CA without them -- and
|
||||
// that rejection surfaces as an opaque handshake failure on a phone.
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
let certificate = params.self_signed(&key).context("self-sign CA")?;
|
||||
Ok((certificate.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
fn generate_leaf(
|
||||
product: &str,
|
||||
ca_pem: &str,
|
||||
ca_key_pem: &str,
|
||||
addresses: &[IpAddr],
|
||||
) -> Result<(String, String)> {
|
||||
let ca_key = KeyPair::from_pem(ca_key_pem).context("read CA key")?;
|
||||
let issuer = Issuer::from_ca_cert_pem(ca_pem, ca_key).context("read CA certificate")?;
|
||||
|
||||
let key = KeyPair::generate().context("generate leaf key")?;
|
||||
let mut params = CertificateParams::default();
|
||||
params
|
||||
.distinguished_name
|
||||
.push(DnType::OrganizationName, format!("{product} dev"));
|
||||
params.distinguished_name.push(
|
||||
DnType::CommonName,
|
||||
addresses
|
||||
.first()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| product.to_string()),
|
||||
);
|
||||
params.subject_alt_names = addresses.iter().map(|a| SanType::IpAddress(*a)).collect();
|
||||
params.is_ca = IsCa::ExplicitNoCa;
|
||||
params.key_usages = vec![KeyUsagePurpose::DigitalSignature];
|
||||
params.use_authority_key_identifier_extension = true;
|
||||
let certificate = params.signed_by(&key, &issuer).context("sign leaf")?;
|
||||
Ok((certificate.pem(), key.serialize_pem()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn decode_pem(pem: &str) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
let body: String = pem
|
||||
.lines()
|
||||
.filter(|line| !line.starts_with("-----"))
|
||||
.collect();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(body)
|
||||
.expect("base64")
|
||||
}
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack
|
||||
.windows(needle.len())
|
||||
.any(|window| window == needle)
|
||||
}
|
||||
|
||||
fn addresses() -> Vec<IpAddr> {
|
||||
vec![
|
||||
"192.168.1.5".parse().unwrap(),
|
||||
"127.0.0.1".parse().unwrap(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_once_then_keeps_the_ca_and_reissues_the_leaf() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let first = ensure("demo", dir.path(), &addresses()).expect("generate");
|
||||
assert!(first.ca_is_new);
|
||||
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
|
||||
let leaf = std::fs::read_to_string(&first.leaf_cert).expect("leaf");
|
||||
assert!(ca.starts_with("-----BEGIN CERTIFICATE-----"));
|
||||
|
||||
let second = ensure("demo", dir.path(), &addresses()).expect("regenerate");
|
||||
// The CA is the pinned one: replacing it strands every installed
|
||||
// app, so it must survive a restart untouched.
|
||||
assert!(!second.ca_is_new);
|
||||
assert_eq!(
|
||||
ca,
|
||||
std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca")
|
||||
);
|
||||
// The leaf is not pinned, and is reissued so a new address is a
|
||||
// restart away rather than a reinstall.
|
||||
assert_ne!(
|
||||
leaf,
|
||||
std::fs::read_to_string(&second.leaf_cert).expect("leaf")
|
||||
);
|
||||
}
|
||||
|
||||
/// Private keys are rewritten on every start, which is the case that
|
||||
/// makes the mode matter: `private` narrows a file that already
|
||||
/// exists, and this is the caller that depends on it.
|
||||
#[test]
|
||||
fn everything_is_owner_only_even_on_the_second_start() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
ensure("demo", dir.path(), &addresses()).expect("generate");
|
||||
|
||||
// What an older version could have left behind.
|
||||
for file in ["leaf.pem", "leaf-key.pem"] {
|
||||
std::fs::set_permissions(
|
||||
dir.path().join(file),
|
||||
std::fs::Permissions::from_mode(0o644),
|
||||
)
|
||||
.expect("chmod");
|
||||
}
|
||||
ensure("demo", dir.path(), &addresses()).expect("reissue");
|
||||
|
||||
let mode = |path: std::path::PathBuf| {
|
||||
std::fs::metadata(&path).expect("stat").permissions().mode() & 0o777
|
||||
};
|
||||
assert_eq!(mode(dir.path().to_path_buf()), 0o700);
|
||||
for file in ["ca.pem", "ca-key.pem", "leaf.pem", "leaf-key.pem"] {
|
||||
assert_eq!(
|
||||
mode(dir.path().join(file)),
|
||||
0o600,
|
||||
"{file} is not owner-only"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The product name is the whole of what is per-project, so it has to
|
||||
/// reach the certificate rather than being decoration.
|
||||
#[test]
|
||||
fn the_product_names_the_certificate() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
ensure("dev-updater", dir.path(), &addresses()).expect("generate");
|
||||
let ca = std::fs::read_to_string(dir.path().join("ca.pem")).expect("ca");
|
||||
|
||||
// Decoded rather than searched as base64: a distinguished name is
|
||||
// stored in the DER as literal bytes, so this checks the value
|
||||
// that actually reaches a phone rather than the value we passed in.
|
||||
let der = decode_pem(&ca);
|
||||
assert!(
|
||||
contains(&der, b"dev-updater dev CA"),
|
||||
"the CA's common name"
|
||||
);
|
||||
assert!(contains(&der, b"dev-updater dev"), "the organisation");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The bearer token a phone carries, and the QR code that gets it there.
|
||||
//!
|
||||
//! Pinning authenticates the server to the phone but never the phone to
|
||||
//! the server, so the token supplies the other direction. Binding the
|
||||
//! WireGuard interface (see [`crate::netif`]) narrows who can try at all;
|
||||
//! this narrows it to who was enrolled.
|
||||
//!
|
||||
//! The token is 256 bits from the OS CSPRNG and is never typed by a
|
||||
//! human -- it travels once, in a QR code printed to the terminal -- so
|
||||
//! being unguessable costs nothing and there is no manual-entry path to
|
||||
//! design around.
|
||||
//!
|
||||
//! Only the hash is ever stored. That is what makes the plaintext a
|
||||
//! once-only artifact: it exists in the QR at generation time and nowhere
|
||||
//! afterwards, and a lost phone is answered by rotating rather than by
|
||||
//! looking the old one up.
|
||||
//!
|
||||
//! # Never log the token
|
||||
//!
|
||||
//! Nothing here, and nothing that calls it, may log the Authorization
|
||||
//! header or the token itself. Both existing projects hold a test that
|
||||
//! drives the rejection path under a capturing subscriber and asserts the
|
||||
//! token does not appear in the output; that tripwire belongs with the
|
||||
//! middleware, which stays in each project because it is generic over
|
||||
//! that project's state.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use base64::Engine;
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
|
||||
/// 256 bits from the OS CSPRNG, base64url.
|
||||
pub fn generate_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut bytes = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
/// What the config stores instead of the token: hex SHA-256.
|
||||
///
|
||||
/// A plain hash, not a password KDF, and deliberately: the input is 256
|
||||
/// random bits, so there is nothing to dictionary-attack and stretching
|
||||
/// would buy only latency on every request.
|
||||
pub fn token_hash_hex(token: &str) -> String {
|
||||
Sha256::digest(token.as_bytes())
|
||||
.iter()
|
||||
.map(|byte| format!("{byte:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Whether `presented` matches any enrolled hash.
|
||||
///
|
||||
/// The fold visits every entry regardless of an earlier match, so the
|
||||
/// time taken does not say which entry matched, or whether the first one
|
||||
/// did.
|
||||
pub fn token_matches(presented: &str, stored_hashes: &[String]) -> bool {
|
||||
let presented = token_hash_hex(presented);
|
||||
stored_hashes.iter().fold(false, |matched, stored| {
|
||||
matched | bool::from(presented.as_bytes().ct_eq(stored.as_bytes()))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `<scheme>://enroll?...` URI a QR code carries.
|
||||
///
|
||||
/// The scheme is the caller's because it is what routes the scan back to
|
||||
/// the right app -- `devupdater`, `aiapp` -- and it is the only part of
|
||||
/// enrollment that is per-project.
|
||||
pub fn enrollment_uri(scheme: &str, host: IpAddr, port: u16, token: &str) -> String {
|
||||
format!("{scheme}://enroll?host={host}&port={port}&token={token}")
|
||||
}
|
||||
|
||||
/// Prints the one-time enrollment QR, and the URI under it for a person
|
||||
/// who would rather paste than scan.
|
||||
///
|
||||
/// Printed to stdout rather than through `tracing`: it is for the human
|
||||
/// at the terminal, once, and a log line is the wrong shape for something
|
||||
/// that has to be photographed.
|
||||
///
|
||||
/// The QR carries no trust material. The CA is embedded in the app at
|
||||
/// build time, so photographing the terminal leaks only the token, which
|
||||
/// is rotatable.
|
||||
pub fn print_enrollment(scheme: &str, host: IpAddr, port: u16, token: &str) -> Result<()> {
|
||||
let uri = enrollment_uri(scheme, host, port, token);
|
||||
let code = qrcode::QrCode::new(uri.as_bytes()).context("render enrollment QR")?;
|
||||
let rendered = code
|
||||
.render::<qrcode::render::unicode::Dense1x2>()
|
||||
.quiet_zone(true)
|
||||
.build();
|
||||
println!("\n{rendered}\n");
|
||||
println!("Scan with the phone's camera to enroll (or paste into the app's settings):");
|
||||
println!(" {uri}");
|
||||
println!("The token is not stored in the clear and won't be shown again;");
|
||||
println!("a lost phone means re-running with --rotate-token.\n");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hashing_is_stable_and_tokens_verify() {
|
||||
let token = generate_token();
|
||||
assert_eq!(token_hash_hex(&token), token_hash_hex(&token));
|
||||
assert_ne!(token, generate_token(), "tokens must not repeat");
|
||||
|
||||
let hashes = vec![token_hash_hex(&token), token_hash_hex("other")];
|
||||
assert!(token_matches(&token, &hashes));
|
||||
assert!(token_matches("other", &hashes));
|
||||
assert!(!token_matches("wrong", &hashes));
|
||||
assert!(
|
||||
!token_matches(&token, &[]),
|
||||
"no enrolled token matches nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The hash is what gets stored, so it must not be the token, and it
|
||||
/// must be the shape the config files already hold.
|
||||
#[test]
|
||||
fn the_stored_form_reveals_nothing_and_is_hex() {
|
||||
let token = generate_token();
|
||||
let hash = token_hash_hex(&token);
|
||||
assert_ne!(hash, token);
|
||||
assert_eq!(hash.len(), 64);
|
||||
assert!(
|
||||
hash.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
}
|
||||
|
||||
/// The scheme is the only per-project part, and the app parses this
|
||||
/// back -- so the shape is a contract, not a formatting choice.
|
||||
#[test]
|
||||
fn the_enrollment_uri_carries_scheme_host_port_and_token() {
|
||||
let uri = enrollment_uri("devupdater", "10.66.0.1".parse().unwrap(), 8090, "tok");
|
||||
assert_eq!(
|
||||
uri,
|
||||
"devupdater://enroll?host=10.66.0.1&port=8090&token=tok"
|
||||
);
|
||||
let other = enrollment_uri("aiapp", "10.66.0.1".parse().unwrap(), 8443, "tok");
|
||||
assert!(other.starts_with("aiapp://enroll?"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
//! 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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! What dev-updater and ai-app both need in order to be reached from a
|
||||
//! phone, and nothing either of them does afterwards.
|
||||
//!
|
||||
//! Both projects are the same shape underneath: a server on a machine
|
||||
//! somebody owns, bound to a WireGuard interface so it is not on the LAN,
|
||||
//! presenting a certificate from a CA the app pins, and answering only
|
||||
//! requests carrying a bearer token that was enrolled by scanning a QR
|
||||
//! code off the terminal. None of that is about serving APKs or running
|
||||
//! model sessions -- it is the link, and it was written twice.
|
||||
//!
|
||||
//! # What belongs here
|
||||
//!
|
||||
//! Anything that would be *identical* in a third such project. The test
|
||||
//! applied to each module below was to diff the two existing copies: if
|
||||
//! the only differences were a product name and which state type the code
|
||||
//! was generic over, it came here.
|
||||
//!
|
||||
//! # What deliberately does not
|
||||
//!
|
||||
//! The API surfaces. dev-updater's routes are about projects and builds,
|
||||
//! ai-app's about sessions and providers, and their HTTP clients have
|
||||
//! diverged to 14% similarity because they are genuinely different
|
||||
//! programs. Sharing a transport is worth doing; sharing an API would mean
|
||||
//! inventing a common vocabulary neither project wants.
|
||||
//!
|
||||
//! Config *schemas*, for the same reason -- though the RON house rules
|
||||
//! that both files are written in are shared, since those were identical
|
||||
//! to the byte.
|
||||
|
||||
pub mod certs;
|
||||
pub mod enroll;
|
||||
pub mod format;
|
||||
pub mod netif;
|
||||
pub mod private;
|
||||
pub mod xdg;
|
||||
@@ -0,0 +1,180 @@
|
||||
//! Which address to bind, and which addresses the certificate must cover.
|
||||
//!
|
||||
//! Both projects bind the WireGuard interface and nothing else, so that
|
||||
//! neither is reachable from the LAN. That is the outer of two gates --
|
||||
//! the tunnel decides who can try, the token (see [`crate::enroll`])
|
||||
//! decides who is answered -- and it is worth having on its own account:
|
||||
//! an unenrolled scanner never reaches the token check, and a plain-HTTP
|
||||
//! bootstrap port travels inside the tunnel's encryption.
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// The interface both projects bind. A constant rather than a parameter
|
||||
/// because a second answer would mean two ideas of what "the tunnel" is.
|
||||
pub const WG_INTERFACE: &str = "wg0";
|
||||
|
||||
/// The alias an Android emulator reaches its host by. Not a real
|
||||
/// interface anywhere, which is why it has to be added by hand.
|
||||
const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2];
|
||||
|
||||
/// Every address this machine answers on, for the leaf certificate's SANs
|
||||
/// -- so it covers whatever the phone actually dials without anyone
|
||||
/// maintaining a hardcoded IP.
|
||||
///
|
||||
/// Loopback is included for curl and tests, and the emulator's host alias
|
||||
/// so a debug build can reach a server running beside it.
|
||||
///
|
||||
/// Failing to enumerate is not fatal: the certificate still covers
|
||||
/// loopback, which is enough to start and to diagnose from the machine
|
||||
/// itself.
|
||||
pub fn local_addresses() -> Vec<IpAddr> {
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())),
|
||||
Err(err) => {
|
||||
tracing::warn!("couldn't enumerate interfaces for the certificate: {err}");
|
||||
addresses_among(std::iter::empty())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The SAN list built from `found`, which is the part worth testing.
|
||||
///
|
||||
/// Split from the lookup so the outcome does not depend on what this
|
||||
/// machine happens to have; see [`wg_address_among`] for the same
|
||||
/// reasoning stated at length.
|
||||
pub fn addresses_among(found: impl IntoIterator<Item = IpAddr>) -> Vec<IpAddr> {
|
||||
let mut addresses = vec![
|
||||
IpAddr::from([127, 0, 0, 1]),
|
||||
IpAddr::from(EMULATOR_HOST_ALIAS),
|
||||
];
|
||||
for ip in found {
|
||||
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||
addresses.push(ip);
|
||||
}
|
||||
}
|
||||
addresses
|
||||
}
|
||||
|
||||
/// The IPv4 address on the WireGuard interface, or a refusal to start.
|
||||
///
|
||||
/// Failing closed rather than falling back to 0.0.0.0 is the point. The
|
||||
/// escape hatch belongs to the caller as an explicit `--bind`, because
|
||||
/// each of these servers is also how something stranded gets recovered,
|
||||
/// and that recovery should not depend on the tunnel being healthy.
|
||||
///
|
||||
/// `product` names the binary in the failure, so the message reads as
|
||||
/// advice rather than as a library complaining.
|
||||
pub fn wg_address(product: &str) -> Result<IpAddr> {
|
||||
let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?;
|
||||
wg_address_among(
|
||||
product,
|
||||
interfaces
|
||||
.iter()
|
||||
.map(|iface| (iface.name.as_str(), iface.ip())),
|
||||
)
|
||||
}
|
||||
|
||||
/// The same answer, from interfaces the caller supplies.
|
||||
///
|
||||
/// This exists because the failure is the interesting half -- it is the
|
||||
/// message somebody reads when nothing works -- and it cannot be reached
|
||||
/// on any machine this runs on, all of which have the tunnel up. A test
|
||||
/// that calls [`wg_address`] and skips when it succeeds passes everywhere
|
||||
/// and checks nothing, which is the shape of test this project has been
|
||||
/// bitten by more than once.
|
||||
pub fn wg_address_among<'a>(
|
||||
product: &str,
|
||||
interfaces: impl IntoIterator<Item = (&'a str, IpAddr)>,
|
||||
) -> Result<IpAddr> {
|
||||
interfaces
|
||||
.into_iter()
|
||||
.find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4())
|
||||
.map(|(_, ip)| ip)
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \
|
||||
WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \
|
||||
tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering."
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ip(s: &str) -> IpAddr {
|
||||
s.parse().expect("an address")
|
||||
}
|
||||
|
||||
/// Whatever the machine has, the two that are not interfaces must be
|
||||
/// there -- loopback for tests and curl, the alias for an emulator --
|
||||
/// and nothing may appear twice, since these become certificate SANs.
|
||||
#[test]
|
||||
fn the_certificate_always_covers_loopback_and_the_emulator_alias() {
|
||||
let found = [ip("192.168.1.5"), ip("10.66.0.1"), ip("192.168.1.5")];
|
||||
let addresses = addresses_among(found);
|
||||
|
||||
assert!(addresses.contains(&ip("127.0.0.1")));
|
||||
assert!(addresses.contains(&ip("10.0.2.2")));
|
||||
assert!(
|
||||
addresses.contains(&ip("10.66.0.1")),
|
||||
"the tunnel's own address"
|
||||
);
|
||||
|
||||
let mut seen = addresses.clone();
|
||||
seen.sort();
|
||||
seen.dedup();
|
||||
assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}");
|
||||
assert!(addresses.iter().all(|ip| ip.is_ipv4()));
|
||||
}
|
||||
|
||||
/// A machine with nothing at all still gets a usable certificate,
|
||||
/// because failing to enumerate must not leave the server unable to
|
||||
/// answer on loopback and diagnose itself.
|
||||
#[test]
|
||||
fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() {
|
||||
assert_eq!(
|
||||
addresses_among(std::iter::empty()),
|
||||
[ip("127.0.0.1"), ip("10.0.2.2")]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_tunnel_is_found_by_name_and_only_over_ipv4() {
|
||||
let found = wg_address_among(
|
||||
"demo-server",
|
||||
[
|
||||
("lo", ip("127.0.0.1")),
|
||||
("eth0", ip("192.168.1.5")),
|
||||
(WG_INTERFACE, ip("10.66.0.1")),
|
||||
],
|
||||
);
|
||||
assert_eq!(found.expect("the tunnel"), ip("10.66.0.1"));
|
||||
|
||||
// An interface of the right name carrying only IPv6 is not an
|
||||
// answer: both listeners bind an IPv4 socket.
|
||||
let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]);
|
||||
assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind");
|
||||
}
|
||||
|
||||
/// The failure is the thing somebody reads at 2am, so it has to name
|
||||
/// the binary, the interface, and the way out.
|
||||
///
|
||||
/// Reached by handing in an empty interface list rather than by hoping
|
||||
/// the machine has no tunnel. Every machine this runs on has one up,
|
||||
/// so a test that called the real lookup and skipped on success would
|
||||
/// pass everywhere and assert nothing -- which is exactly what it did
|
||||
/// before this was rewritten.
|
||||
#[test]
|
||||
fn a_missing_tunnel_explains_itself() {
|
||||
let err = wg_address_among("demo-server", [])
|
||||
.expect_err("no tunnel")
|
||||
.to_string();
|
||||
assert!(err.contains("demo-server"), "names the binary: {err}");
|
||||
assert!(err.contains(WG_INTERFACE), "names the interface: {err}");
|
||||
assert!(err.contains("--bind"), "names the way out: {err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
//! Creating files and directories this server alone can read.
|
||||
//!
|
||||
//! Everything a server writes outside its repo goes through here: the
|
||||
//! config holding token hashes, the TLS private keys, and whatever state
|
||||
//! it keeps. One module owns the modes, so "owner-only" is a property
|
||||
//! that can be checked in one place rather than re-argued at every
|
||||
//! `create`.
|
||||
//!
|
||||
//! Taken from ai-app, which had factored this out; dev-updater still has
|
||||
//! the same logic inline in two places, which is the duplication this
|
||||
//! crate exists to end.
|
||||
|
||||
use std::fs::File;
|
||||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt, PermissionsExt};
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Creates `dir` and its parents, owner-accessible only.
|
||||
///
|
||||
/// The mode is set again after creation, deliberately: `DirBuilder::mode`
|
||||
/// applies only when the directory is actually created, so one that
|
||||
/// already existed -- made by hand, or by an older version -- would
|
||||
/// otherwise keep whatever permissions it had while holding a private key.
|
||||
pub fn create_dir(dir: &Path) -> Result<()> {
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(dir)
|
||||
.with_context(|| format!("create {}", dir.display()))?;
|
||||
std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("restrict {}", dir.display()))
|
||||
}
|
||||
|
||||
/// Writes `contents` to `path`, owner-readable only.
|
||||
///
|
||||
/// The mode is set as the file is opened rather than chmod-ed afterwards,
|
||||
/// so it is never briefly world-readable at its real path.
|
||||
pub fn write_file(path: &Path, contents: &[u8]) -> Result<()> {
|
||||
use std::io::Write;
|
||||
let mut file = create_file(path)?;
|
||||
file.write_all(contents)
|
||||
.with_context(|| format!("write {}", path.display()))
|
||||
}
|
||||
|
||||
/// Opens `path` for writing, owner-readable only, truncating what is
|
||||
/// there. For a caller that streams rather than holding the whole body.
|
||||
pub fn create_file(path: &Path) -> Result<File> {
|
||||
restrict(
|
||||
path,
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("write {}", path.display()))?,
|
||||
)
|
||||
}
|
||||
|
||||
/// Opens `path` for appending, owner-readable only, creating it if needed.
|
||||
///
|
||||
/// The append case is separate because a transcript must never be
|
||||
/// truncated by being opened, and the two differ by one flag that is easy
|
||||
/// to get wrong in a hurry.
|
||||
pub fn append_file(path: &Path) -> Result<File> {
|
||||
restrict(
|
||||
path,
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.create(true)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.with_context(|| format!("append to {}", path.display()))?,
|
||||
)
|
||||
}
|
||||
|
||||
/// Narrows an already-open file to owner-only.
|
||||
///
|
||||
/// `OpenOptions::mode` applies **only when the file is created**, so
|
||||
/// opening one that already exists silently keeps whatever mode it had --
|
||||
/// which for an append helper is the normal case rather than the odd one,
|
||||
/// and for a truncating one happens on every rewrite after the first.
|
||||
/// Setting it through the handle rather than the path closes the window
|
||||
/// where something could swap the path between the two.
|
||||
///
|
||||
/// The same reasoning as [`create_dir`]'s second call, and it was missed
|
||||
/// here first: a file made wrong by an older version, or by hand, would
|
||||
/// otherwise stay wrong for as long as it is only ever appended to.
|
||||
fn restrict(path: &Path, file: File) -> Result<File> {
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o600))
|
||||
.with_context(|| format!("restrict {}", path.display()))?;
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn mode_of(path: &Path) -> u32 {
|
||||
std::fs::metadata(path).expect("stat").permissions().mode() & 0o777
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_directory_is_owner_only_even_if_it_already_existed() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let target = dir.path().join("state");
|
||||
|
||||
// Made by hand, wide open -- what an older version or a person
|
||||
// might leave behind.
|
||||
std::fs::create_dir(&target).expect("mkdir");
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
|
||||
create_dir(&target).expect("create_dir");
|
||||
assert_eq!(
|
||||
mode_of(&target),
|
||||
0o700,
|
||||
"an existing directory must be restricted too"
|
||||
);
|
||||
}
|
||||
|
||||
/// The case `OpenOptions::mode` cannot cover, because it applies only
|
||||
/// at creation: a file that already exists, made wrong earlier, and
|
||||
/// opened again. An append-only transcript hits this on every write
|
||||
/// after the first.
|
||||
#[test]
|
||||
fn an_existing_file_made_wrong_is_narrowed_on_open() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
create_dir(dir.path()).expect("create_dir");
|
||||
|
||||
for (name, open) in [
|
||||
("appended", append_file as fn(&Path) -> Result<File>),
|
||||
("rewritten", create_file as fn(&Path) -> Result<File>),
|
||||
] {
|
||||
let path = dir.path().join(name);
|
||||
std::fs::write(&path, b"made by an older version").expect("write");
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).expect("chmod");
|
||||
assert_eq!(mode_of(&path), 0o644, "precondition");
|
||||
|
||||
open(&path).expect("open");
|
||||
assert_eq!(
|
||||
mode_of(&path),
|
||||
0o600,
|
||||
"{name} kept a mode somebody else could read"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_are_owner_only_from_the_moment_they_exist() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
create_dir(dir.path()).expect("create_dir");
|
||||
|
||||
let written = dir.path().join("key.pem");
|
||||
write_file(&written, b"secret").expect("write");
|
||||
assert_eq!(mode_of(&written), 0o600);
|
||||
assert_eq!(std::fs::read(&written).expect("read"), b"secret");
|
||||
|
||||
let appended = dir.path().join("transcript.jsonl");
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = append_file(&appended).expect("append");
|
||||
file.write_all(b"one\n").expect("write");
|
||||
}
|
||||
{
|
||||
use std::io::Write;
|
||||
let mut file = append_file(&appended).expect("append");
|
||||
file.write_all(b"two\n").expect("write");
|
||||
}
|
||||
assert_eq!(mode_of(&appended), 0o600);
|
||||
// The whole point of the separate opener: opening again must not
|
||||
// have truncated what was there.
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(&appended).expect("read"),
|
||||
"one\ntwo\n"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Where each project keeps the state that must not live in its repo.
|
||||
//!
|
||||
//! Both servers hold the same three things outside their checkout -- the
|
||||
//! config with its token hashes, the CA's private key, and whatever state
|
||||
//! the product itself keeps -- and both resolve the location the same way.
|
||||
//! That is not a coincidence of style: it is forced by the arrangement the
|
||||
//! two projects share.
|
||||
//!
|
||||
//! The repo is a virtiofs mount shared between a machine and a VM at
|
||||
//! *different* absolute paths, so a config inside it would record paths
|
||||
//! that resolve on only one side -- which is exactly how dev-updater came
|
||||
//! to show "not built" for every project on one of them. And the mount is
|
||||
//! writable by the untrusted side, so a CA private key inside it would let
|
||||
//! that side mint a leaf the pinned app trusts, which voids the pinning
|
||||
//! the rest of this crate exists to provide.
|
||||
//!
|
||||
//! Per machine, therefore, and under the XDG directories rather than a
|
||||
//! path of our own choosing, so that a person's existing backup and
|
||||
//! sync rules already cover it.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// `$XDG_CONFIG_HOME/<product>`, or `~/.config/<product>`.
|
||||
///
|
||||
/// Holds `config.ron` and `certs/`. `product` is a parameter rather than
|
||||
/// a constant because the whole point is that two products keep their
|
||||
/// state apart while resolving it identically.
|
||||
pub fn config_home(product: &str) -> PathBuf {
|
||||
xdg_dir(std::env::var_os("XDG_CONFIG_HOME"), ".config", product)
|
||||
}
|
||||
|
||||
/// `$XDG_DATA_HOME/<product>`, or `~/.local/share/<product>`.
|
||||
///
|
||||
/// Holds whatever the product accumulates rather than is configured with
|
||||
/// -- ai-app's session transcripts and attachments, dev-updater's builds.
|
||||
pub fn data_home(product: &str) -> PathBuf {
|
||||
xdg_dir(std::env::var_os("XDG_DATA_HOME"), ".local/share", product)
|
||||
}
|
||||
|
||||
/// The resolution both of the above use, with the environment handed in.
|
||||
///
|
||||
/// Split out so the rules can be tested without setting process-wide
|
||||
/// environment variables, which two tests running in parallel cannot do
|
||||
/// without racing each other.
|
||||
///
|
||||
/// A relative value is ignored rather than resolved, per the XDG spec: a
|
||||
/// server started from a different working directory would otherwise look
|
||||
/// for its token hashes somewhere new and generate a fresh enrollment,
|
||||
/// silently locking out the phone that was already enrolled.
|
||||
fn xdg_dir(base: Option<OsString>, fallback: &str, product: &str) -> PathBuf {
|
||||
base.map(PathBuf::from)
|
||||
.filter(|path| path.is_absolute())
|
||||
.unwrap_or_else(|| {
|
||||
std::env::home_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join(fallback)
|
||||
})
|
||||
.join(product)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_absolute_setting_is_used_and_namespaced_by_product() {
|
||||
assert_eq!(
|
||||
xdg_dir(Some("/somewhere".into()), ".config", "ai-app"),
|
||||
PathBuf::from("/somewhere/ai-app"),
|
||||
);
|
||||
assert_eq!(
|
||||
xdg_dir(Some("/somewhere".into()), ".config", "dev-updater"),
|
||||
PathBuf::from("/somewhere/dev-updater"),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unset_value_falls_back_under_home() {
|
||||
let dir = xdg_dir(None, ".local/share", "ai-app");
|
||||
assert!(dir.ends_with("ai-app"));
|
||||
assert!(dir.parent().expect("parent").ends_with("share"));
|
||||
}
|
||||
|
||||
/// A relative setting lands on the same path as no setting at all,
|
||||
/// rather than on something that moves with the working directory.
|
||||
#[test]
|
||||
fn a_relative_setting_is_ignored_rather_than_resolved() {
|
||||
assert_eq!(
|
||||
xdg_dir(Some("relative/path".into()), ".config", "ai-app"),
|
||||
xdg_dir(None, ".config", "ai-app"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user