Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
331 changes: 325 additions & 6 deletions src/commands/rotate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,28 @@ pub(super) struct RotateContext {
pub(super) state_file: PathBuf,
/// The `docker` executable every spawn in this rotation runs.
///
/// Set once in [`run_rotate`] and only read afterwards, so a test
/// that builds a context can point the whole tree at a fake without
/// touching `PATH`.
/// Set once in [`run_rotate_with_exec`] and only read afterwards, so
/// a test that builds a context — or drives that entry point — can
/// point the whole tree at a fake without touching `PATH`.
pub(super) docker: PathBuf,
}

#[allow(clippy::too_many_lines)]
pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result<RotateOutcome> {
run_rotate_with_exec(args, Path::new(DOCKER_BIN), messages).await
}

/// [`run_rotate`] with the `docker` executable supplied by the caller.
///
/// Mirrors the `_with_exec` pairs in [`crate::commands::infra`]: the
/// wrapper above is this function with [`DOCKER_BIN`], and a test
/// drives the whole entry point — state-file handling and strategy
/// normalisation included — against a fake it names by path.
#[allow(clippy::too_many_lines)]
async fn run_rotate_with_exec(
args: &RotateArgs,
docker: &Path,
messages: &Messages,
) -> Result<RotateOutcome> {
let state_path = args
.state_file
.clone()
Expand Down Expand Up @@ -173,7 +187,7 @@ pub(crate) async fn run_rotate(args: &RotateArgs, messages: &Messages) -> Result
paths,
state_dir,
state_file: state_path,
docker: PathBuf::from(DOCKER_BIN),
docker: docker.to_path_buf(),
};

// InfraCert operates on local files and Docker only — it must not
Expand Down Expand Up @@ -282,7 +296,6 @@ pub(super) mod test_support {

static ENV_LOCK: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
pub(in crate::commands) const TEST_DOCKER_ARGS_ENV: &str = "BOOTROOT_TEST_DOCKER_ARGS";
pub(super) const TEST_DOCKER_EXIT_ENV: &str = "BOOTROOT_TEST_DOCKER_EXIT";

pub(in crate::commands) struct ScopedEnvVar {
key: &'static str,
Expand Down Expand Up @@ -347,4 +360,310 @@ exit 0
}
env::join_paths(paths).expect("PATH components should be valid")
}

/// Writes a fake `docker` at `path` that appends one record per
/// invocation to `args_log` and reads nothing from its environment.
///
/// The log path is baked into the script text as it is written, so a
/// test handing this executable to production through the `docker`
/// seam gets its argv back without setting a single variable on this
/// process — which is the point, since the test does not construct
/// the `Command` that runs the fake.
pub(super) fn write_self_contained_fake_docker(path: &Path, args_log: &Path) {
write_self_contained_fake_docker_exiting(path, args_log, 0);
}

/// [`write_self_contained_fake_docker`] whose fake exits `exit_code`
/// after logging, so a test can steer the failure path of a docker
/// call it does not spawn itself.
///
/// Each invocation appends its argument count and then exactly that
/// many arguments, every field NUL-terminated: `docker restart c`
/// appends `2\0restart\0c\0` and `docker a '' b` appends
/// `3\0a\0\0b\0`. The record is framed by its count rather than
/// delimited by a byte, so an empty argument stays an empty field
/// and two invocations can never merge into one.
/// [`decode_fake_docker_log`] reads it back.
pub(super) fn write_self_contained_fake_docker_exiting(
path: &Path,
args_log: &Path,
exit_code: u8,
) {
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::PermissionsExt;

// The script is assembled as bytes, not as a `String`: a Unix
// path is an arbitrary NUL-free byte sequence, and rendering
// `args_log` through `Display` would replace any byte that is
// not valid UTF-8, pointing the fake at a different path that
// nothing would ever create.
let mut script = b"#!/bin/sh\nset -eu\nprintf '%s\\0' \"$#\" \"$@\" >> ".to_vec();
script.extend_from_slice(&shell_single_quote(args_log.as_os_str().as_bytes()));
script.extend_from_slice(format!("\nexit {exit_code}\n").as_bytes());
fs::write(path, script).expect("fake docker script should be written");
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.expect("fake docker script should be executable");
}

/// Quotes `value` as a single POSIX shell word, byte for byte.
///
/// A tempdir path may legally contain `'`, which ends the quoted
/// word; the usual `'\''` dance closes, escapes and reopens it.
/// Every other byte is copied through unchanged, so a path that is
/// not UTF-8 reaches the script intact.
fn shell_single_quote(value: &[u8]) -> Vec<u8> {
let mut quoted = vec![b'\''];
for byte in value {
if *byte == b'\'' {
quoted.extend_from_slice(br"'\''");
} else {
quoted.push(*byte);
}
}
quoted.push(b'\'');
quoted
}

/// Decodes a log written by [`write_self_contained_fake_docker`],
/// returning one argument vector per invocation in call order.
///
/// # Panics
///
/// Panics if the log is unreadable or is not the framing the fake
/// writes — a missing count, or a record the file ends inside.
pub(super) fn decode_fake_docker_log(args_log: &Path) -> Vec<Vec<String>> {
let bytes = fs::read(args_log).expect("fake docker log should be readable");
if bytes.is_empty() {
return Vec::new();
}
// Every field is NUL-terminated, so dropping the final
// terminator leaves the fields themselves — including an empty
// argument, which a terminator-less split would swallow.
let body = bytes
.strip_suffix(&[0])
.expect("the fake terminates every field it writes");
let mut fields = body.split(|byte| *byte == 0);
let mut invocations = Vec::new();
while let Some(count_field) = fields.next() {
let count: usize = std::str::from_utf8(count_field)
.expect("argument count must be UTF-8")
.parse()
.expect("argument count must be a decimal number");
let argv: Vec<String> = fields
.by_ref()
.take(count)
.map(|field| String::from_utf8_lossy(field).into_owned())
.collect();
assert_eq!(argv.len(), count, "the log ends inside a record");
invocations.push(argv);
}
invocations
}
}

#[cfg(test)]
mod tests {
use std::fs;
use std::path::Path;
use std::process::Command;

use tempfile::tempdir;

use super::test_support::{
decode_fake_docker_log, write_self_contained_fake_docker,
write_self_contained_fake_docker_exiting,
};

/// Runs the fake once with `args`, the way production would.
fn run_fake(fake: &Path, args: &[&str]) {
let status = Command::new(fake)
.args(args)
.status()
.expect("the fake docker must be spawnable");
assert!(status.success(), "the fake docker must exit 0");
}

/// A space-joined encoding cannot tell one argument holding a space
/// from two arguments, which is exactly what the `--user <uid>:<gid>`
/// assertion in `stepca_password` rests on.
#[test]
fn the_fake_docker_log_keeps_argument_boundaries() {
let dir = tempdir().expect("tempdir");
let split_log = dir.path().join("split.log");
let joined_log = dir.path().join("joined.log");
let split = dir.path().join("split-docker");
let joined = dir.path().join("joined-docker");
write_self_contained_fake_docker(&split, &split_log);
write_self_contained_fake_docker(&joined, &joined_log);

run_fake(&split, &["--user", "1000:1000"]);
run_fake(&joined, &["--user 1000:1000"]);

assert_eq!(
decode_fake_docker_log(&split_log),
[["--user", "1000:1000"]]
);
assert_eq!(decode_fake_docker_log(&joined_log), [["--user 1000:1000"]]);
assert_ne!(
decode_fake_docker_log(&split_log),
decode_fake_docker_log(&joined_log)
);
}

/// A multi-call flow must decode as its own invocations, in order,
/// rather than as the last call or one merged record.
#[test]
fn the_fake_docker_log_keeps_every_invocation_in_order() {
let dir = tempdir().expect("tempdir");
let args_log = dir.path().join("docker_args.log");
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker(&fake, &args_log);

run_fake(&fake, &["run", "first"]);
run_fake(&fake, &["kill", "-s", "SIGHUP", "c"]);
run_fake(&fake, &["restart", "c"]);

assert_eq!(
decode_fake_docker_log(&args_log),
vec![
vec!["run", "first"],
vec!["kill", "-s", "SIGHUP", "c"],
vec!["restart", "c"],
]
);
}

/// The pair a doubled-NUL record terminator collapses: `["a", "",
/// "b"]` once and `["a"]` then `["b"]` encode to the same bytes
/// under that scheme, and must not under this one.
#[test]
fn the_fake_docker_log_frames_empty_arguments() {
let dir = tempdir().expect("tempdir");
let single_log = dir.path().join("single.log");
let pair_log = dir.path().join("pair.log");
let single = dir.path().join("single-docker");
let pair = dir.path().join("pair-docker");
write_self_contained_fake_docker(&single, &single_log);
write_self_contained_fake_docker(&pair, &pair_log);

run_fake(&single, &["a", "", "b"]);
run_fake(&pair, &["a"]);
run_fake(&pair, &["b"]);

assert_eq!(decode_fake_docker_log(&single_log), [["a", "", "b"]]);
assert_eq!(decode_fake_docker_log(&pair_log), [["a"], ["b"]]);
assert_ne!(
decode_fake_docker_log(&single_log),
decode_fake_docker_log(&pair_log)
);
}

/// An argument-less invocation is the degenerate record, `0\0`, and
/// the count is the only thing that separates it from the next one:
/// it contributes no fields of its own, so a decoder that scanned
/// for a boundary instead of counting would swallow the record after
/// it.
#[test]
fn the_fake_docker_log_frames_an_argument_less_invocation() {
let dir = tempdir().expect("tempdir");
let args_log = dir.path().join("docker_args.log");
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker(&fake, &args_log);

run_fake(&fake, &[]);
run_fake(&fake, &["restart", "c"]);
run_fake(&fake, &[]);

assert_eq!(
decode_fake_docker_log(&args_log),
vec![vec![], vec!["restart".to_string(), "c".to_string()], vec![]]
);
}

/// `tempfile::tempdir()` can legitimately hand back a path holding
/// an apostrophe, so the helper quotes rather than rejects one.
#[test]
fn the_fake_docker_handles_a_quoted_log_path() {
let dir = tempdir().expect("tempdir");
let awkward = dir.path().join("it's a dir");
fs::create_dir(&awkward).expect("create awkward dir");
let args_log = awkward.join("docker args.log");
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker(&fake, &args_log);

run_fake(&fake, &["restart", "c"]);

assert_eq!(decode_fake_docker_log(&args_log), [["restart", "c"]]);
}

/// The redirect the fake is written with must hold the log path's
/// own bytes. Rendering the path through `Display` instead replaces
/// every byte that is not valid UTF-8 with `U+FFFD`, which silently
/// aims the fake at a path nothing creates.
///
/// This asserts on the script text rather than on running it, so it
/// holds on filesystems that would refuse to create the name.
#[test]
fn the_fake_docker_script_embeds_the_log_path_verbatim() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;

let dir = tempdir().expect("tempdir");
let args_log = dir.path().join(OsStr::from_bytes(b"non\xffutf8.log"));
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker(&fake, &args_log);

let script = fs::read(&fake).expect("the fake docker script should be readable");
let expected = args_log.as_os_str().as_bytes();
assert!(
script
.windows(expected.len())
.any(|window| window == expected),
"the script must redirect to the log path's own bytes"
);
}

/// A Unix path is an arbitrary NUL-free byte sequence, so a tempdir
/// rooted below one that is not UTF-8 — which `TMPDIR` can be — must
/// still yield a fake that logs where the test reads.
///
/// The name is only creatable where the filesystem takes it: APFS
/// and other UTF-8-enforcing filesystems reject it with `EILSEQ`,
/// and on those the property is unobservable rather than broken.
#[test]
fn the_fake_docker_handles_a_non_utf8_log_path() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;

let dir = tempdir().expect("tempdir");
let awkward = dir.path().join(OsStr::from_bytes(b"non\xffutf8"));
if fs::create_dir(&awkward).is_err() {
return;
}
let args_log = awkward.join("docker_args.log");
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker(&fake, &args_log);

run_fake(&fake, &["restart", "c"]);

assert_eq!(decode_fake_docker_log(&args_log), [["restart", "c"]]);
}

/// The baked-in exit code is what replaces the environment variable
/// the shared `PATH` fake reads.
#[test]
fn the_fake_docker_reports_the_baked_in_exit_code() {
let dir = tempdir().expect("tempdir");
let args_log = dir.path().join("docker_args.log");
let fake = dir.path().join("fake-docker");
write_self_contained_fake_docker_exiting(&fake, &args_log, 7);

let status = Command::new(&fake)
.arg("run")
.status()
.expect("the fake docker must be spawnable");

assert_eq!(status.code(), Some(7));
assert_eq!(decode_fake_docker_log(&args_log), [["run"]]);
}
}
Loading
Loading