Load proxy settings from a file, and import them from a pendrive

Everything came from CONTINUUM_* environment variables, which suits neither
shape the product ships in. A Proxy Box is an appliance whose settings
arrive on a USB stick; a proxy on someone's PC is a config file they edit.

Precedence is stated once, in config.rs: the file is the base, env overrides
it, defaults fill the rest. `Config::from_env` keeps its behaviour and the
struct is unchanged, so existing callers and tests are untouched. Layering
is `Config::layered(file, env_lookup)` with the environment injected as a
closure, so tests do not have to mutate process env to cover it.

The bootstrap is read from $CONTINUUM_BOOTSTRAP, then
/etc/continuum/bootstrap.toml, then nowhere - and nowhere means PC mode with
./continuum.toml, so an env-only deployment behaves exactly as before. The
working directory is deliberately not searched, and the bootstrap itself
must never come from a stick: it is what says whether sticks are trusted at
all.

Bundle format is continuum-config.zip holding config.toml and
config.toml.sha256. The hash is checked before the bytes are parsed - the
TOML parser should not be the first thing to read possibly-corrupt input -
and **the comments say plainly what that buys: corruption and change, not
forgery.** Anyone who can write the stick writes both files. It is an
integrity check under a recorded trust assumption, that physical access to
the box is trusted; a signature with the key in the box image is named as
the follow-up that would give authenticity. sha256 via openssl, already a
direct dependency, so no new crate and `cargo deny check licenses` still
passes.

The hazards of ingesting a file off removable media, all tested:

Zip-slip is refused on absolute paths, `..` components, backslash
separators, drive prefixes, NUL and non-UTF-8 names, and one bad entry
rejects the whole bundle rather than being skipped. The stronger guarantee
is structural though: entries are only ever read **by index, matched against
two literal names**, and no write path is ever derived from an archive name,
so a hole in the name check still writes nothing. A test confirms a bundle
carrying `../evil.toml` alongside a valid config writes nothing outside the
target and leaves the installed config alone.

Zip bombs are capped at 16 entries, 256 KiB per entry and a 1 MiB file on
the stick. Declared sizes are refused in a first pass that decompresses
nothing, and a bounded reader then caps actual output so a lying header
gains nothing.

Installing is atomic: temp file in the destination directory - not /tmp,
since rename is only atomic within a filesystem - created 0600 from the
first byte because the config holds the gateway token, fsync, rename, then a
best-effort directory fsync. A write that fails halfway leaves the previous
config intact and no temp file behind.

A bad stick never costs the box its config: a bad hash, bad TOML, an unsafe
entry or missing settings all return Rejected and are logged as keeping the
existing config. A correctly-hashed config is also validated **with no
environment at all**, so a stick cannot install something that parses now
and fails to start on the next boot - which would be a bricked box caused by
exactly the stick we were being careful about.

PC mode ignores pendrives twice over, by mode and by an emptied directory
list, because on a general-purpose machine importing settings from a USB
stick would be a way to change them behind the user's back.

A [network] block is parsed, validated and exposed, and stops there. main.rs
logs a warning that this build does not apply it, so a box given network
settings cannot look like it acted on them. Applying them needs root, is
specific to the appliance image, and a wrong write strands the box - it wants
its own decision, not a side effect of reading a file.

continuum.toml is gitignored, since it holds the gateway token.

Not done, and not claimed: no signature, so no authenticity. There is no real
pendrive here, so the removable-media path was tested with ordinary
directories standing in for mount points - real udisks/udev mounts, FAT
behaviour, a genuinely yanked stick and how promptly the 5s poll notices a
mount are all unproven. The watcher's install-then-exit flow, which relies
on a supervisor restarting the process, has no test and no supervisor exists
in this environment; a stick left permanently inserted reports Unchanged
rather than looping, because the hash matches. Env still overrides on a box,
so a box is only pendrive-only to the extent its environment is trusted -
bootstrap.rs says so.
This commit is contained in:
2026-10-19 07:49:46 +00:00
parent da28597eeb
commit 64d46dbc49
8 changed files with 1469 additions and 15 deletions
+831
View File
@@ -0,0 +1,831 @@
//! Installing a Proxy Box's config from a pendrive.
//!
//! A bundle is a ZIP (`continuum-config.zip`) holding `config.toml` and
//! `config.toml.sha256`. On boot, and while running when a stick appears,
//! a Proxy Box looks for one, checks it, and replaces its installed config
//! only if the new one differs from what it already has. This module is
//! everything between "there is a file on the stick" and "the installed
//! config is now different"; *which* installation this is lives in
//! `bootstrap.rs`, and what the config means in `config.rs`.
//!
//! # What the hash does and does not buy - read this before relying on it
//!
//! The SHA-256 in the bundle detects **corruption and change**: a stick that
//! was half-copied, a file edited by hand without regenerating the hash, a
//! bit flipped in flash. It does **not** detect **forgery**. The hash sits in
//! the same archive as the file it hashes, so anyone able to write the
//! pendrive can write a matching pair. It is a checksum, not authentication,
//! and nothing here calls it that.
//!
//! What would give authenticity is a signature (e.g. Ed25519 over the config
//! bytes, public key baked into the box image) - the follow-up, not done
//! here. Until then the **trust assumption is: whoever can physically put a
//! stick in the box is trusted to configure it.** That is the same trust a
//! person with physical access already has, and it is why PC mode refuses
//! removable media entirely: there the assumption does not hold.
//!
//! SHA-256 over MD5/CRC32: not for strength (forgery is out of scope as
//! above) but because it is the hash every operator's tooling already has
//! (`sha256sum`), it costs nothing here (`openssl` is already a direct
//! dependency; no `sha2` crate added), and it will not need changing when a
//! signature is layered on.
//!
//! # Hostile archives
//!
//! The stick is untrusted input even though its author is trusted: it can be
//! mistyped, mis-made by a script, or simply the wrong stick.
//!
//! - **Zip-slip.** Entry names with a leading `/`, a `..` component, a drive
//! prefix or a backslash separator reject the whole bundle. This code never
//! writes to a path derived from an entry name - only the two expected
//! entries are read, into memory, and the only file written is the
//! configured destination - so the check is defence in depth, kept because
//! a later change that starts extracting would otherwise be one line from a
//! path-traversal bug, and because a bundle with such a name is wrong
//! enough to refuse on its own.
//! - **Zip bomb.** At most [`MAX_ENTRIES`] entries and [`MAX_ENTRY_BYTES`]
//! per entry, checked against the *declared* size before decompressing and
//! again against the *actual* output while reading, because a header can
//! lie. Only the two expected entries are ever decompressed, so output is
//! bounded by twice the per-entry cap whatever the archive claims. The
//! file read off the stick is itself capped at [`MAX_BUNDLE_BYTES`]
//! so a mis-named multi-gigabyte file cannot exhaust memory first.
//! - **Non-regular bundle files** (symlinks, devices) are skipped: a link
//! from a stick to a device node would otherwise be read as a bundle.
//!
//! # A bad stick never bricks the box
//!
//! Every failure here is an [`Outcome::Rejected`], not an error that
//! propagates: the installed config is untouched, the reason is logged at
//! `error` level, and the box carries on running with what it has. Install
//! is atomic - temp file in the destination directory, `fsync`, `rename` -
//! so a stick yanked mid-copy, or power lost mid-write, leaves either the old
//! config or the new one and never a truncated mix. The temp file lives in
//! the destination's directory (not `/tmp`) because `rename` is only atomic
//! within one filesystem.
use std::collections::HashSet;
use std::fs;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::time::Duration;
use crate::bootstrap::{Bootstrap, Mode};
use crate::config::{Config, ProxyFile};
/// The file an operator puts on the stick.
pub const BUNDLE_NAME: &str = "continuum-config.zip";
const CONFIG_ENTRY: &str = "config.toml";
const HASH_ENTRY: &str = "config.toml.sha256";
/// A real config is a few hundred bytes; 256 KiB is three orders of
/// magnitude of headroom and still small enough to hold in memory twice.
pub const MAX_ENTRY_BYTES: u64 = 256 * 1024;
/// Two expected entries plus a little for whatever a ZIP tool adds
/// (`__MACOSX/` resource forks, a README). A bundle with dozens of entries
/// is not a config bundle.
pub const MAX_ENTRIES: usize = 16;
/// The compressed file. Generous against `MAX_ENTRY_BYTES * MAX_ENTRIES`
/// worth of incompressible junk; its job is only to bound the read.
pub const MAX_BUNDLE_BYTES: u64 = 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
/// PC mode, or no directories configured: nothing was looked at.
Disabled,
/// No bundle on any scanned medium.
NoBundle,
/// A valid bundle whose config is byte-identical to the installed one.
Unchanged { bundle: PathBuf },
/// The installed config was replaced. `sha256` is the new config's hash.
Installed { bundle: PathBuf, sha256: String },
/// The bundle was refused; the installed config is untouched.
Rejected { bundle: PathBuf, reason: String },
}
#[derive(Debug, thiserror::Error)]
enum BundleError {
#[error("not a readable ZIP archive: {0}")]
NotAnArchive(String),
#[error("unsafe entry name {0:?} (absolute path, `..`, drive prefix or backslash)")]
UnsafeEntry(String),
#[error("{0} entries; at most {MAX_ENTRIES} are allowed")]
TooManyEntries(usize),
#[error("entry {name:?} is {size} bytes; at most {MAX_ENTRY_BYTES} are allowed")]
EntryTooLarge { name: String, size: u64 },
#[error("missing entry {0:?}")]
MissingEntry(&'static str),
#[error("entry {0:?} appears more than once")]
DuplicateEntry(&'static str),
#[error("entry {0:?} is not a regular file")]
NotARegularFile(&'static str),
#[error("unreadable entry {name:?}: {detail}")]
Unreadable { name: &'static str, detail: String },
#[error("{HASH_ENTRY} is malformed: {0}")]
MalformedHash(String),
#[error("hash mismatch: {HASH_ENTRY} says {claimed}, {CONFIG_ENTRY} is {actual} - the stick is corrupt or the config was edited without regenerating the hash")]
HashMismatch { claimed: String, actual: String },
#[error("{CONFIG_ENTRY} is not valid: {0}")]
InvalidConfig(String),
}
/// A config that passed every check and is safe to install.
struct Verified {
bytes: Vec<u8>,
sha256: String,
}
/// Rejects an entry name that could escape a directory it were joined onto.
///
/// Checked on the raw string rather than via `Path::components`, because the
/// platform's idea of a separator is the wrong test for an archive: a ZIP
/// made on Windows uses `\`, and on Linux `..\evil` is a legal single file
/// name that becomes a traversal the moment anything extracts it on the
/// other platform. Treating both as separators is stricter than needed on
/// Linux and costs nothing.
fn entry_name_is_safe(name: &str) -> bool {
if name.is_empty() || name.contains('\0') || name.starts_with('/') || name.starts_with('\\') {
return false;
}
// `C:foo` and `C:\foo`.
let mut chars = name.chars();
if let (Some(first), Some(':')) = (chars.next(), chars.next())
&& first.is_ascii_alphabetic()
{
return false;
}
!name.split(['/', '\\']).any(|component| component == "..")
}
/// Reads at most `limit` bytes, failing if the source has more. Separate from
/// the archive so the cap on *actual* output - the one that survives a lying
/// header - can be tested with a source that never ends.
fn read_bounded(source: impl Read, limit: u64) -> io::Result<Option<Vec<u8>>> {
let mut out = Vec::new();
source.take(limit + 1).read_to_end(&mut out)?;
Ok((out.len() as u64 <= limit).then_some(out))
}
pub fn sha256_hex(bytes: &[u8]) -> String {
openssl::sha::sha256(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
/// Accepts `sha256sum`'s own output (`<hex> config.toml`, with or without
/// the `*` binary marker) as well as a bare hex digest, because the first is
/// what an operator will actually produce and the second is what a script
/// will. A filename, if present, must name our config: a hash file copied
/// from some other project is a mistake worth catching.
fn parse_hash_file(text: &str) -> Result<String, BundleError> {
let mut tokens = text.split_whitespace();
let digest = tokens.next().ok_or_else(|| BundleError::MalformedHash("empty".into()))?;
if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(BundleError::MalformedHash(format!("expected 64 hex characters, got {digest:?}")));
}
if let Some(name) = tokens.next()
&& name.trim_start_matches('*') != CONFIG_ENTRY
{
return Err(BundleError::MalformedHash(format!("it hashes {name:?}, not {CONFIG_ENTRY:?}")));
}
if tokens.next().is_some() {
return Err(BundleError::MalformedHash("unexpected trailing text".into()));
}
Ok(digest.to_ascii_lowercase())
}
/// Everything that decides whether bytes from a stick are acceptable, with no
/// filesystem effects - so the whole policy is testable on in-memory ZIPs.
fn verify_bundle(archive: &[u8]) -> Result<Verified, BundleError> {
let mut zip = zip::ZipArchive::new(io::Cursor::new(archive)).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
if zip.len() > MAX_ENTRIES {
return Err(BundleError::TooManyEntries(zip.len()));
}
// First pass: names and declared sizes of every entry, expected or not.
// Nothing is decompressed yet, so a bomb is refused before it costs
// anything.
let mut config_at = None;
let mut hash_at = None;
for index in 0..zip.len() {
let entry = zip.by_index_raw(index).map_err(|e| BundleError::NotAnArchive(e.to_string()))?;
// A name that is not valid UTF-8 cannot be vetted component by
// component, so it is refused like any other unsafe name.
let Ok(name) = entry.name().map(|n| n.to_string()) else {
return Err(BundleError::UnsafeEntry(String::from_utf8_lossy(entry.name_raw()).into_owned()));
};
if !entry_name_is_safe(&name) {
return Err(BundleError::UnsafeEntry(name));
}
if entry.size() > MAX_ENTRY_BYTES {
return Err(BundleError::EntryTooLarge { size: entry.size(), name });
}
for (wanted, slot) in [(CONFIG_ENTRY, &mut config_at), (HASH_ENTRY, &mut hash_at)] {
if name == wanted && slot.replace(index).is_some() {
return Err(BundleError::DuplicateEntry(wanted));
}
}
}
let mut read = |index: Option<usize>, name: &'static str| -> Result<Vec<u8>, BundleError> {
let index = index.ok_or(BundleError::MissingEntry(name))?;
let entry = zip.by_index(index).map_err(|e| BundleError::Unreadable { name, detail: e.to_string() })?;
if entry.is_dir() || entry.is_symlink() {
return Err(BundleError::NotARegularFile(name));
}
read_bounded(entry, MAX_ENTRY_BYTES)
.map_err(|e| BundleError::Unreadable { name, detail: e.to_string() })?
.ok_or_else(|| BundleError::EntryTooLarge { name: name.into(), size: MAX_ENTRY_BYTES + 1 })
};
let config = read(config_at, CONFIG_ENTRY)?;
let hash_text = read(hash_at, HASH_ENTRY)?;
// The hash is checked before the bytes are parsed or trusted for
// anything: the TOML parser should not be the first thing to look at
// possibly-corrupt input. (Corruption only - see the module docs.)
let claimed = parse_hash_file(&String::from_utf8_lossy(&hash_text))?;
let actual = sha256_hex(&config);
if claimed != actual {
return Err(BundleError::HashMismatch { claimed, actual });
}
// Validated as the box will actually run it: with no environment. On a
// box the file has to be complete by itself, so a config that parses but
// lacks `gateway_id` is refused now rather than installed and then
// failing to start on the next boot - which would be a bricked box
// caused by exactly the stick we were being careful about.
let text = std::str::from_utf8(&config).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
let parsed = ProxyFile::parse(text).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
Config::layered(Some(&parsed), &|_| None).map_err(|e| BundleError::InvalidConfig(e.to_string()))?;
Ok(Verified { bytes: config, sha256: actual })
}
/// Replaces `dest` with `bytes` so that no observer, and no crash, ever sees
/// anything but the old contents or the new ones.
pub fn install_atomically(dest: &Path, bytes: &[u8]) -> io::Result<()> {
install_atomically_with(dest, bytes, |file, bytes| file.write_all(bytes))
}
/// `install_atomically` with the write step injectable, so a test can make it
/// fail halfway the way a pulled stick or a full disk would - the situation
/// the atomicity exists for, and not one a happy-path test ever reaches.
fn install_atomically_with(
dest: &Path,
bytes: &[u8],
write: impl FnOnce(&mut fs::File, &[u8]) -> io::Result<()>,
) -> io::Result<()> {
let dir = match dest.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => Path::new("."),
};
let name = dest
.file_name()
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "destination has no file name"))?;
fs::create_dir_all(dir)?;
let mut temp_name = std::ffi::OsString::from(".");
temp_name.push(name);
temp_name.push(format!(".tmp-{}", std::process::id()));
let temp = dir.join(temp_name);
// A leftover from a previous crash with a recycled pid would make
// `create_new` fail forever; it is ours by name, so clear it.
let _ = fs::remove_file(&temp);
let result = (|| {
let mut options = fs::OpenOptions::new();
options.write(true).create_new(true);
// The config holds the gateway token (and maybe a wifi password), so
// it is owner-only from the first byte rather than chmod'd after.
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
let mut file = options.open(&temp)?;
write(&mut file, bytes)?;
// Data to disk before the rename makes it visible: without this a
// power cut can leave a renamed-but-empty file on some filesystems.
file.sync_all()?;
drop(file);
fs::rename(&temp, dest)
})();
if result.is_err() {
let _ = fs::remove_file(&temp);
} else if let Ok(dir_handle) = fs::File::open(dir) {
// Best effort: persists the rename itself. Failure here does not
// undo it, so it is not reported as an install failure.
let _ = dir_handle.sync_all();
}
result
}
/// Verifies one bundle and installs it if it differs from `installed`.
/// Never panics and never returns an error: every outcome, including every
/// refusal, is a value the caller reports, and none of them touch the
/// installed config except `Installed`.
pub fn import_bundle(bundle: &Path, installed: &Path) -> Outcome {
let reject = |reason: String| Outcome::Rejected { bundle: bundle.to_path_buf(), reason };
let archive = match read_bundle_file(bundle) {
Ok(bytes) => bytes,
Err(err) => return reject(format!("could not read the bundle: {err}")),
};
let verified = match verify_bundle(&archive) {
Ok(v) => v,
Err(err) => return reject(err.to_string()),
};
// Compared against the bytes actually on disk, not a remembered hash:
// if someone did edit the installed file, the next stick restores it,
// which is the "changed only by a new pendrive" rule enforced and not
// just asserted. An unreadable or missing installed file counts as
// different.
if fs::read(installed).is_ok_and(|current| sha256_hex(&current) == verified.sha256) {
return Outcome::Unchanged { bundle: bundle.to_path_buf() };
}
match install_atomically(installed, &verified.bytes) {
Ok(()) => Outcome::Installed { bundle: bundle.to_path_buf(), sha256: verified.sha256 },
Err(err) => reject(format!("could not install into {installed:?}: {err}")),
}
}
fn read_bundle_file(path: &Path) -> io::Result<Vec<u8>> {
// `symlink_metadata`, not `metadata`: do not follow a link off the stick.
let meta = fs::symlink_metadata(path)?;
if !meta.is_file() {
return Err(io::Error::other("not a regular file"));
}
read_bounded(fs::File::open(path)?, MAX_BUNDLE_BYTES)?
.ok_or_else(|| io::Error::other(format!("larger than {MAX_BUNDLE_BYTES} bytes")))
}
/// Bundles under `roots`, looking at `root`, `root/*` and `root/*/*` - the
/// shapes of `/mnt/stick`, `/media/LABEL` and `/media/<user>/LABEL`, which
/// between them are where udisks and a hand `mount` put a stick. Sorted, so
/// two sticks are always tried in the same order.
fn find_bundles(roots: &[PathBuf]) -> Vec<PathBuf> {
fn subdirs(dir: &Path) -> Vec<PathBuf> {
let Ok(entries) = fs::read_dir(dir) else { return Vec::new() };
// `file_type` does not follow links, so a symlinked directory is not
// descended into.
entries
.flatten()
.filter(|e| e.file_type().is_ok_and(|t| t.is_dir()))
.map(|e| e.path())
.collect()
}
let mut found = Vec::new();
for root in roots {
let mut dirs = vec![root.clone()];
let level1 = subdirs(root);
dirs.extend(level1.iter().flat_map(|d| subdirs(d)));
dirs.extend(level1);
for dir in dirs {
let candidate = dir.join(BUNDLE_NAME);
if fs::symlink_metadata(&candidate).is_ok() {
found.push(candidate);
}
}
}
found.sort();
found.dedup();
found
}
/// One pass over every configured medium. Returns what happened to each
/// bundle; stops after the first install, since a second stick would
/// otherwise immediately overwrite the first with whichever sorts last.
///
/// This is the gate for PC mode: anything but a Proxy Box returns
/// `Disabled` without opening a directory.
pub fn import_from_pendrives(bootstrap: &Bootstrap) -> Vec<Outcome> {
if bootstrap.mode != Mode::ProxyBox || bootstrap.pendrive_dirs.is_empty() {
return vec![Outcome::Disabled];
}
let bundles = find_bundles(&bootstrap.pendrive_dirs);
if bundles.is_empty() {
return vec![Outcome::NoBundle];
}
let mut outcomes = Vec::new();
for bundle in bundles {
let outcome = import_bundle(&bundle, &bootstrap.config_path);
let installed = matches!(outcome, Outcome::Installed { .. });
outcomes.push(outcome);
if installed {
break;
}
}
outcomes
}
pub fn log_outcome(outcome: &Outcome) {
match outcome {
Outcome::Disabled | Outcome::NoBundle => {}
Outcome::Unchanged { bundle } => {
tracing::info!(bundle = %bundle.display(), "config bundle matches the installed config; nothing to do");
}
Outcome::Installed { bundle, sha256 } => {
tracing::warn!(bundle = %bundle.display(), %sha256, "installed a new config from the pendrive; a restart is needed to use it");
}
// `error`, not `warn`: someone inserted a stick expecting something
// to happen, and "nothing happened" must not be silent.
Outcome::Rejected { bundle, reason } => {
tracing::error!(
bundle = %bundle.display(),
%reason,
"REJECTED the config bundle; the box keeps running on its existing config"
);
}
}
}
/// Polls for a stick while the proxy runs. Returns only after installing a
/// new config, because settings are read once at startup: the caller is
/// expected to let the process exit so its supervisor (systemd
/// `Restart=always` on a box) starts it on the new config. Never returns in
/// PC mode.
///
/// Polling, not inotify/udev: a mount appearing under `/media` is not a
/// reliable inotify event across desktop and headless setups, and a 5s scan
/// of a handful of directories is cheap. A rejected stick is reported once
/// per distinct reason, not every five seconds for as long as it stays in.
pub async fn watch(bootstrap: Bootstrap) {
if bootstrap.mode != Mode::ProxyBox {
std::future::pending::<()>().await;
}
let mut reported: HashSet<(PathBuf, String)> = HashSet::new();
loop {
tokio::time::sleep(Duration::from_secs(5)).await;
let scan = bootstrap.clone();
let Ok(outcomes) = tokio::task::spawn_blocking(move || import_from_pendrives(&scan)).await else {
continue;
};
for outcome in &outcomes {
if let Outcome::Rejected { bundle, reason } = outcome
&& !reported.insert((bundle.clone(), reason.clone()))
{
continue;
}
log_outcome(outcome);
}
if outcomes.iter().any(|o| matches!(o, Outcome::Installed { .. })) {
return;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const GOOD: &str = "gateway_id = \"g\"\nfarm_id = \"f\"\ngateway_token = \"t\"\n";
/// A self-deleting scratch directory, as in tests/discovery_to_fleet.rs -
/// a dependency for a dozen lines would be the wrong trade here.
struct Scratch(PathBuf);
impl Scratch {
fn new() -> Self {
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT: AtomicUsize = AtomicUsize::new(0);
let path = std::env::temp_dir()
.join(format!("continuum-import-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
fs::create_dir_all(&path).unwrap();
Self(path)
}
fn path(&self, rel: &str) -> PathBuf {
self.0.join(rel)
}
}
impl Drop for Scratch {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn zip_of(entries: &[(&str, &[u8])], method: zip::CompressionMethod) -> Vec<u8> {
let mut w = zip::ZipWriter::new(io::Cursor::new(Vec::new()));
let opts = zip::write::SimpleFileOptions::default().compression_method(method);
for (name, body) in entries {
w.start_file(*name, opts).unwrap();
w.write_all(body).unwrap();
}
w.finish().unwrap().into_inner()
}
fn stored(entries: &[(&str, &[u8])]) -> Vec<u8> {
zip_of(entries, zip::CompressionMethod::Stored)
}
fn good_bundle(config: &str) -> Vec<u8> {
let hash = format!("{} config.toml\n", sha256_hex(config.as_bytes()));
stored(&[(CONFIG_ENTRY, config.as_bytes()), (HASH_ENTRY, hash.as_bytes())])
}
fn rejection(archive: &[u8]) -> String {
verify_bundle(archive).err().expect("bundle should be rejected").to_string()
}
#[test]
fn a_bundle_whose_hash_matches_is_accepted() {
let verified = verify_bundle(&good_bundle(GOOD)).unwrap();
assert_eq!(verified.bytes, GOOD.as_bytes());
assert_eq!(verified.sha256, sha256_hex(GOOD.as_bytes()));
}
#[test]
fn a_bare_hex_digest_with_no_filename_is_also_accepted() {
let hash = sha256_hex(GOOD.as_bytes());
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
assert!(verify_bundle(&archive).is_ok());
}
#[test]
fn a_config_edited_after_its_hash_was_made_is_rejected_as_a_mismatch() {
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
let edited = GOOD.replace("\"g\"", "\"other\"");
let archive = stored(&[(CONFIG_ENTRY, edited.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
assert!(rejection(&archive).contains("hash mismatch"));
}
#[test]
fn a_hash_file_that_names_some_other_file_is_rejected() {
let hash = format!("{} other.toml", sha256_hex(GOOD.as_bytes()));
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, hash.as_bytes())]);
assert!(rejection(&archive).contains("other.toml"));
}
#[test]
fn a_hash_that_is_not_sixty_four_hex_characters_is_rejected() {
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes()), (HASH_ENTRY, b"deadbeef")]);
assert!(rejection(&archive).contains("malformed"));
}
#[test]
fn a_bundle_missing_its_hash_is_rejected_rather_than_trusted() {
let archive = stored(&[(CONFIG_ENTRY, GOOD.as_bytes())]);
assert!(rejection(&archive).contains("missing entry"));
}
#[test]
fn a_correctly_hashed_config_that_is_not_valid_toml_is_rejected() {
assert!(rejection(&good_bundle("this is = = not toml")).contains("not valid"));
}
#[test]
fn a_correctly_hashed_config_missing_required_settings_is_rejected_so_it_cannot_brick_the_next_boot() {
assert!(rejection(&good_bundle("uplink_url = \"wss://x\"\n")).contains("gateway_id"));
}
#[test]
fn a_correctly_hashed_config_with_a_misspelled_key_is_rejected() {
let config = format!("{GOOD}uplink_ulr = \"x\"\n");
assert!(rejection(&good_bundle(&config)).contains("uplink_ulr"));
}
#[test]
fn a_file_that_is_not_a_zip_is_rejected() {
assert!(rejection(b"definitely not a zip").contains("not a readable ZIP"));
}
#[test]
fn entry_names_that_could_escape_the_target_directory_are_all_unsafe() {
for name in ["/etc/passwd", "../evil", "a/../../evil", "a/..", "..\\evil", "\\abs", "C:\\evil", "C:evil", ""] {
assert!(!entry_name_is_safe(name), "{name:?} should be unsafe");
}
for name in ["config.toml", "dir/config.toml", "..config", "a..b/c", "__MACOSX/._x"] {
assert!(entry_name_is_safe(name), "{name:?} should be safe");
}
}
#[test]
fn a_zip_slip_entry_rejects_the_whole_bundle_even_when_the_rest_is_valid() {
let hash = format!("{} config.toml", sha256_hex(GOOD.as_bytes()));
let archive = stored(&[
(CONFIG_ENTRY, GOOD.as_bytes()),
(HASH_ENTRY, hash.as_bytes()),
("../../../../tmp/evil.toml", b"pwned"),
]);
assert!(rejection(&archive).contains("unsafe entry"));
}
#[test]
fn an_absolute_path_entry_rejects_the_bundle() {
let archive = stored(&[("/etc/cron.d/evil", b"x")]);
assert!(rejection(&archive).contains("unsafe entry"));
}
#[test]
fn importing_a_zip_slip_bundle_writes_nothing_outside_the_target_and_keeps_the_old_config() {
let scratch = Scratch::new();
let installed = scratch.path("box/config.toml");
fs::create_dir_all(installed.parent().unwrap()).unwrap();
fs::write(&installed, GOOD).unwrap();
let hash = format!("{} config.toml", sha256_hex(b"new"));
let bundle = scratch.path("stick.zip");
fs::write(&bundle, stored(&[(CONFIG_ENTRY, b"new"), (HASH_ENTRY, hash.as_bytes()), ("../evil.toml", b"pwned")])).unwrap();
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Rejected { .. }));
assert!(!scratch.path("evil.toml").exists(), "a file escaped the target directory");
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
}
#[test]
fn a_bundle_with_too_many_entries_is_rejected() {
let names: Vec<String> = (0..=MAX_ENTRIES).map(|i| format!("junk{i}")).collect();
let entries: Vec<(&str, &[u8])> = names.iter().map(|n| (n.as_str(), &b"x"[..])).collect();
assert!(rejection(&stored(&entries)).contains("entries"));
}
#[test]
fn a_highly_compressed_entry_that_would_inflate_past_the_cap_is_rejected_before_it_is_inflated() {
let bomb = vec![0u8; (MAX_ENTRY_BYTES as usize) * 40];
let archive = zip_of(&[(CONFIG_ENTRY, &bomb), (HASH_ENTRY, b"x")], zip::CompressionMethod::Deflated);
assert!(archive.len() < 64 * 1024, "the fixture should be small on disk: {}", archive.len());
assert!(rejection(&archive).contains("at most"));
}
#[test]
fn the_bounded_reader_stops_an_endless_source_instead_of_filling_memory() {
// The cap that survives a header which lies about the size.
assert_eq!(read_bounded(io::repeat(0), 1000).unwrap(), None);
assert_eq!(read_bounded(&b"abc"[..], 1000).unwrap(), Some(b"abc".to_vec()));
}
#[test]
fn an_oversized_file_on_the_stick_is_refused_without_being_read_whole() {
let scratch = Scratch::new();
let bundle = scratch.path("big.zip");
fs::write(&bundle, vec![0u8; MAX_BUNDLE_BYTES as usize + 1]).unwrap();
assert!(matches!(import_bundle(&bundle, &scratch.path("config.toml")), Outcome::Rejected { .. }));
}
#[test]
fn a_new_config_is_installed_when_none_exists_yet() {
let scratch = Scratch::new();
let bundle = scratch.path("stick.zip");
fs::write(&bundle, good_bundle(GOOD)).unwrap();
let installed = scratch.path("var/lib/continuum/config.toml");
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. }));
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
}
#[cfg(unix)]
#[test]
fn the_installed_config_is_readable_only_by_its_owner() {
use std::os::unix::fs::PermissionsExt;
let scratch = Scratch::new();
let installed = scratch.path("config.toml");
install_atomically(&installed, b"secret").unwrap();
assert_eq!(fs::metadata(&installed).unwrap().permissions().mode() & 0o777, 0o600);
}
#[test]
fn a_bundle_identical_to_the_installed_config_changes_nothing() {
let scratch = Scratch::new();
let bundle = scratch.path("stick.zip");
fs::write(&bundle, good_bundle(GOOD)).unwrap();
let installed = scratch.path("config.toml");
fs::write(&installed, GOOD).unwrap();
let before = fs::metadata(&installed).unwrap().modified().unwrap();
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Unchanged { .. }));
assert_eq!(fs::metadata(&installed).unwrap().modified().unwrap(), before, "an unchanged config was rewritten");
}
#[test]
fn a_config_hand_edited_on_the_box_is_restored_by_the_next_stick() {
let scratch = Scratch::new();
let bundle = scratch.path("stick.zip");
fs::write(&bundle, good_bundle(GOOD)).unwrap();
let installed = scratch.path("config.toml");
fs::write(&installed, format!("{GOOD}uplink_url = \"wss://tampered\"\n")).unwrap();
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Installed { .. }));
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
}
#[test]
fn a_pendrive_yanked_mid_copy_leaves_the_old_config_intact() {
let scratch = Scratch::new();
let installed = scratch.path("config.toml");
fs::write(&installed, "old config").unwrap();
let result = install_atomically_with(&installed, b"the whole new config", |file, bytes| {
file.write_all(&bytes[..5])?;
Err(io::Error::other("stick removed"))
});
assert!(result.is_err());
assert_eq!(fs::read_to_string(&installed).unwrap(), "old config");
}
#[test]
fn a_failed_install_leaves_no_temp_file_behind() {
let scratch = Scratch::new();
let installed = scratch.path("config.toml");
let _ = install_atomically_with(&installed, b"new", |_, _| Err(io::Error::other("disk full")));
let leftovers: Vec<_> = fs::read_dir(&scratch.0).unwrap().flatten().collect();
assert!(leftovers.is_empty(), "left behind: {leftovers:?}");
}
#[test]
fn a_rejected_bundle_never_touches_the_installed_config() {
let scratch = Scratch::new();
let installed = scratch.path("config.toml");
fs::write(&installed, GOOD).unwrap();
let bundle = scratch.path("stick.zip");
fs::write(&bundle, good_bundle("not = = toml")).unwrap();
assert!(matches!(import_bundle(&bundle, &installed), Outcome::Rejected { .. }));
assert_eq!(fs::read_to_string(&installed).unwrap(), GOOD);
}
fn box_with_stick(scratch: &Scratch, mode: Mode) -> Bootstrap {
let stick = scratch.path("media/USB");
fs::create_dir_all(&stick).unwrap();
fs::write(stick.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
Bootstrap {
mode,
config_path: scratch.path("installed.toml"),
config_path_is_explicit: true,
pendrive_dirs: vec![scratch.path("media")],
}
}
#[test]
fn a_proxy_box_finds_a_stick_mounted_one_level_down_and_installs_from_it() {
let scratch = Scratch::new();
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
let outcomes = import_from_pendrives(&bootstrap);
assert!(matches!(outcomes.as_slice(), [Outcome::Installed { .. }]), "{outcomes:?}");
assert_eq!(fs::read_to_string(scratch.path("installed.toml")).unwrap(), GOOD);
}
#[test]
fn a_proxy_box_finds_a_stick_at_the_media_user_label_depth() {
let scratch = Scratch::new();
let deep = scratch.path("media/pi/LABEL");
fs::create_dir_all(&deep).unwrap();
fs::write(deep.join(BUNDLE_NAME), good_bundle(GOOD)).unwrap();
let bootstrap = Bootstrap {
mode: Mode::ProxyBox,
config_path: scratch.path("installed.toml"),
config_path_is_explicit: true,
pendrive_dirs: vec![scratch.path("media")],
};
assert!(matches!(import_from_pendrives(&bootstrap).as_slice(), [Outcome::Installed { .. }]));
}
#[test]
fn pc_mode_ignores_a_pendrive_even_when_one_with_a_valid_bundle_is_mounted() {
let scratch = Scratch::new();
let mut bootstrap = box_with_stick(&scratch, Mode::Pc);
// Force the dirs back in, as if some future code path forgot that
// `Bootstrap` already empties them: the mode check must hold alone.
bootstrap.pendrive_dirs = vec![scratch.path("media")];
assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::Disabled]);
assert!(!scratch.path("installed.toml").exists(), "PC mode imported from removable media");
}
#[test]
fn an_empty_pendrive_directory_is_not_an_error() {
let scratch = Scratch::new();
fs::create_dir_all(scratch.path("media")).unwrap();
let bootstrap = Bootstrap {
mode: Mode::ProxyBox,
config_path: scratch.path("installed.toml"),
config_path_is_explicit: true,
pendrive_dirs: vec![scratch.path("media")],
};
assert_eq!(import_from_pendrives(&bootstrap), vec![Outcome::NoBundle]);
}
#[cfg(unix)]
#[test]
fn a_bundle_that_is_a_symlink_is_not_followed() {
let scratch = Scratch::new();
let real = scratch.path("real.zip");
fs::write(&real, good_bundle(GOOD)).unwrap();
let link = scratch.path("link.zip");
std::os::unix::fs::symlink(&real, &link).unwrap();
assert!(matches!(import_bundle(&link, &scratch.path("c.toml")), Outcome::Rejected { .. }));
}
#[test]
fn a_bad_stick_is_reported_and_the_box_keeps_the_config_it_had() {
let scratch = Scratch::new();
let bootstrap = box_with_stick(&scratch, Mode::ProxyBox);
fs::write(&bootstrap.config_path, "previous").unwrap();
fs::write(scratch.path("media/USB").join(BUNDLE_NAME), b"garbage").unwrap();
let outcomes = import_from_pendrives(&bootstrap);
assert!(matches!(outcomes.as_slice(), [Outcome::Rejected { .. }]));
assert_eq!(fs::read_to_string(&bootstrap.config_path).unwrap(), "previous");
}
}