2431 lines
94 KiB
Rust
2431 lines
94 KiB
Rust
use std::{
|
|
io::{Read, Write},
|
|
process::{Command, Stdio},
|
|
thread,
|
|
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
|
};
|
|
|
|
use testcontainers::core::error::{ClientError, WaitContainerError};
|
|
use testcontainers::{GenericImage, ImageExt, TestcontainersError, runners::SyncRunner};
|
|
|
|
use super::{
|
|
container_process::{
|
|
docker_text, docker_text_with_timeout, docker_text_with_timeout_and_limits,
|
|
},
|
|
container_selection::Selection,
|
|
};
|
|
|
|
const IMAGE: &str = "wireguard-admin-alpine:3.22.7";
|
|
const IMAGE_ID: &str = "sha256:f41cd6b848d8280d4d86bcaceea962e1b3e5a73e9496c6f9d7929470d0e874d5";
|
|
const NETWORK_IPV4: &str = "192.168.253.0/24";
|
|
const CONTAINER_STARTUP_TIMEOUT: Duration = Duration::from_secs(12 * 60);
|
|
const FIXTURE_COMMAND_TIMEOUT_SECONDS: &str = "15";
|
|
const FIXTURE_TRANSPORT_TIMEOUT: Duration = Duration::from_secs(20);
|
|
const DRIVER_TRANSPORT_TIMEOUT: Duration = Duration::from_secs(180);
|
|
const COMMAND_STDOUT_LIMIT: usize = 1024;
|
|
const NAMESPACE_STDOUT_LIMIT: usize = 128;
|
|
const COMMAND_STDERR_LIMIT: usize = 1024;
|
|
const MAX_PHASE_REQUEST_BYTES: usize = 4096;
|
|
|
|
#[derive(Clone, Debug)]
|
|
pub struct ProbeNames {
|
|
pub network: String,
|
|
pub server: String,
|
|
pub baseline: String,
|
|
pub client: String,
|
|
}
|
|
|
|
impl ProbeNames {
|
|
pub fn unique() -> Self {
|
|
let nonce = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_nanos();
|
|
let suffix = format!("{}-{nonce:x}", std::process::id());
|
|
Self {
|
|
network: format!("wga-m83-net-{suffix}"),
|
|
server: format!("wga-m83-server-{suffix}"),
|
|
baseline: format!("wga-m85-baseline-{suffix}"),
|
|
client: format!("wga-m83-client-{suffix}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn run_probe(names: &ProbeNames) -> Result<(), &'static str> {
|
|
let selection = Selection::from_process_environment()?;
|
|
verify_daemon(&selection)?;
|
|
verify_cached_image(&selection)?;
|
|
|
|
let network = OwnedNetwork::create(&selection, names.network.clone())?;
|
|
let server = start_fixture_container(&selection, names.server.as_str(), &network.name)?;
|
|
let baseline = start_fixture_container(&selection, names.baseline.as_str(), &network.name)?;
|
|
let client = start_fixture_container(&selection, names.client.as_str(), &network.name)?;
|
|
|
|
verify_distinct_namespaces(&server, &baseline)?;
|
|
verify_distinct_namespaces(&server, &client)?;
|
|
verify_distinct_namespaces(&baseline, &client)?;
|
|
verify_wireguard_and_ipv4(&server)?;
|
|
verify_wireguard_and_ipv4(&client)?;
|
|
|
|
run_application_smoke(&server, &baseline, &client)?;
|
|
|
|
client.remove()?;
|
|
baseline.remove()?;
|
|
server.remove()?;
|
|
network.remove()
|
|
}
|
|
|
|
fn run_application_smoke(
|
|
server: &OwnedContainer,
|
|
baseline_client: &OwnedContainer,
|
|
client: &OwnedContainer,
|
|
) -> Result<(), &'static str> {
|
|
exec_smoke_command(
|
|
server,
|
|
&["ip", "link", "set", "dev", "wg-m83-probe", "up"],
|
|
"Fixture WireGuard link setup failed",
|
|
)?;
|
|
exec_smoke_command(
|
|
server,
|
|
&[
|
|
"ip",
|
|
"-4",
|
|
"address",
|
|
"add",
|
|
"10.20.0.1/24",
|
|
"dev",
|
|
"wg-m83-probe",
|
|
],
|
|
"Fixture IPv4 address setup failed",
|
|
)?;
|
|
|
|
let driver = std::env::var_os("WIREGUARD_ADMIN_TEST_DRIVER")
|
|
.ok_or("musl test driver path was not explicitly selected")?;
|
|
let driver = std::path::PathBuf::from(driver);
|
|
let metadata = std::fs::symlink_metadata(&driver)
|
|
.map_err(|_| "musl test driver could not be inspected")?;
|
|
if metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.len() > 256 * 1024 * 1024
|
|
{
|
|
return Err("musl test driver is not a bounded regular file");
|
|
}
|
|
|
|
let endpoint = format!("--host={}", server.selection.endpoint);
|
|
let remote_driver = "/tmp/wga-m8-driver";
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
driver
|
|
.to_str()
|
|
.ok_or("musl test driver path is not UTF-8")?,
|
|
&format!("{}:{remote_driver}", server.container().id()),
|
|
],
|
|
Duration::from_secs(180),
|
|
)
|
|
.map_err(|category| match category {
|
|
"Docker command timed out" => "Application fixture driver staging timed out",
|
|
_ => "Application fixture driver staging failed",
|
|
})?;
|
|
exec_smoke_command(
|
|
server,
|
|
&["chmod", "0700", remote_driver],
|
|
"Application fixture driver permission setup failed",
|
|
)?;
|
|
|
|
let command = vec![
|
|
endpoint.as_str(),
|
|
"exec",
|
|
"-i",
|
|
"--env=WIREGUARD_ADMIN_FIXTURE_ROOT=/tmp/wireguard-admin-fixture",
|
|
server.container().id(),
|
|
"/bin/busybox",
|
|
"timeout",
|
|
"-s",
|
|
"KILL",
|
|
"150",
|
|
remote_driver,
|
|
"--ignored",
|
|
"--exact",
|
|
"container_driver::alpine_scenario_driver",
|
|
"--test-threads=1",
|
|
"--nocapture",
|
|
];
|
|
let empty = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !empty.contains("phase=observe status=complete interface=wg-m83-probe peers=0") {
|
|
return Err("real runtime reader did not report the initial empty peer set");
|
|
}
|
|
let baseline = invoke_driver(&command, b"BASELINE\nEND\n")?;
|
|
let baseline_key = phase_public_key(&baseline, "baseline_apply")?;
|
|
let unconnected = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let unconnected_peer = unconnected
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")))
|
|
.ok_or("real runtime reader omitted the unconnected baseline peer")?;
|
|
if !peer_has_exact_ipv4_routes(unconnected_peer, "10.20.0.10/32")
|
|
|| !unconnected_peer.contains("handshake=none")
|
|
|| !unconnected_peer.contains("received=0")
|
|
|| !unconnected_peer.contains("transmitted=0")
|
|
{
|
|
return Err("real runtime reader did not report the baseline pre-handshake state");
|
|
}
|
|
let baseline_temp = tempfile::Builder::new()
|
|
.prefix("wga-m85-baseline-")
|
|
.tempdir()
|
|
.map_err(|_| "private baseline staging directory could not be created")?;
|
|
use std::os::unix::fs::PermissionsExt;
|
|
std::fs::set_permissions(baseline_temp.path(), std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| "private baseline staging directory permissions failed")?;
|
|
let baseline_file = baseline_temp.path().join("baseline-client.conf");
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
&format!(
|
|
"{}:/tmp/wireguard-admin-fixture/baseline-client.conf",
|
|
server.container().id()
|
|
),
|
|
baseline_file
|
|
.to_str()
|
|
.ok_or("private baseline path was not UTF-8")?,
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "synthetic baseline client transfer failed")?;
|
|
let baseline_metadata = std::fs::symlink_metadata(&baseline_file)
|
|
.map_err(|_| "synthetic baseline client file could not be inspected")?;
|
|
use std::os::unix::fs::MetadataExt;
|
|
if baseline_metadata.file_type().is_symlink()
|
|
|| !baseline_metadata.is_file()
|
|
|| baseline_metadata.len() > 64 * 1024
|
|
|| baseline_metadata.mode() & 0o777 != 0o600
|
|
{
|
|
return Err("synthetic baseline client was not a bounded mode-0600 regular file");
|
|
}
|
|
let baseline_bytes = std::fs::read(&baseline_file)
|
|
.map_err(|_| "synthetic baseline client file could not be read")?;
|
|
let (baseline_address, baseline_endpoint) = client_connection_material(&baseline_bytes)?;
|
|
const REMOTE_BASELINE_DELIVERY: &str = "/tmp/wgb85.conf";
|
|
const REMOTE_BASELINE_SETCONF: &str = "/tmp/wgb85-set.conf";
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
baseline_file
|
|
.to_str()
|
|
.ok_or("private baseline path was not UTF-8")?,
|
|
&format!(
|
|
"{}:{REMOTE_BASELINE_DELIVERY}",
|
|
baseline_client.container().id()
|
|
),
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "synthetic baseline client transfer failed")?;
|
|
verify_private_container_file(baseline_client, REMOTE_BASELINE_DELIVERY)?;
|
|
strip_client_config(
|
|
baseline_client,
|
|
REMOTE_BASELINE_DELIVERY,
|
|
REMOTE_BASELINE_SETCONF,
|
|
)?;
|
|
verify_private_container_file(baseline_client, REMOTE_BASELINE_SETCONF)?;
|
|
setup_client_interface(
|
|
baseline_client,
|
|
"wg-m85-baseline",
|
|
REMOTE_BASELINE_SETCONF,
|
|
&baseline_address,
|
|
&baseline_endpoint,
|
|
)?;
|
|
exec_success(
|
|
baseline_client,
|
|
["ping", "-4", "-c", "3", "-W", "2", "10.20.0.1"],
|
|
)
|
|
.map_err(|_| "synthetic baseline IPv4 tunnel traffic failed")?;
|
|
let mut baseline_observed = None;
|
|
for _ in 0..10 {
|
|
let observation = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let peer = observation
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")));
|
|
if peer.is_some_and(|line| {
|
|
peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
&& line.contains("handshake=present")
|
|
&& counter_is_positive(line, "received=")
|
|
&& counter_is_positive(line, "transmitted=")
|
|
}) {
|
|
baseline_observed = Some(observation);
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if baseline_observed.is_none() {
|
|
return Err("real runtime reader did not observe the baseline peer and traffic");
|
|
}
|
|
|
|
// Create, Commit and first delivery share this one Application session.
|
|
// Only fixed phase requests cross stdin; key/config bytes remain in private
|
|
// fixture files and are transferred separately by docker cp.
|
|
let report = docker_exchange_report(
|
|
&command,
|
|
b"CREATE\tM8 smoke client\nCOMMIT\nDELIVER\nEND\n",
|
|
DRIVER_TRANSPORT_TIMEOUT,
|
|
2048,
|
|
1024,
|
|
)
|
|
.map_err(|category| match category {
|
|
"Docker command timed out" => {
|
|
"Application fixture session timed out; in-container completion is uncertain"
|
|
}
|
|
"In-container fixture command timed out" => {
|
|
"Application fixture driver exceeded its in-container deadline"
|
|
}
|
|
"Docker command output exceeded its limit" => {
|
|
"Application fixture driver output exceeded its limit"
|
|
}
|
|
_ => "Application fixture driver protocol failed",
|
|
})?;
|
|
let phase_report = format!("{}\n{}", report.stdout, report.stderr);
|
|
if !driver_ran_exactly_one_test(&phase_report) {
|
|
return Err("Application fixture driver selection did not execute exactly one test");
|
|
}
|
|
if report.failure_category.is_some() {
|
|
return Err(driver_phase_failure_category(&phase_report));
|
|
}
|
|
for marker in [
|
|
"phase=fixture_bootstrap status=ok",
|
|
"phase=create status=ok public_key=",
|
|
"phase=commit status=ok",
|
|
"phase=delivery status=ok activation_warning=true",
|
|
"phase=end status=ok",
|
|
] {
|
|
if !phase_report.contains(marker) {
|
|
return Err("Application fixture report omitted a required creation phase");
|
|
}
|
|
}
|
|
let public_key = phase_public_key(&phase_report, "create")?;
|
|
let baseline_archive = phase_archive_name(&phase_report, "commit")?;
|
|
|
|
let temp = tempfile::Builder::new()
|
|
.prefix("wga-m85-delivery-")
|
|
.tempdir()
|
|
.map_err(|_| "private delivery staging directory could not be created")?;
|
|
std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| "private delivery staging directory permissions failed")?;
|
|
let delivered = temp.path().join("delivered.conf");
|
|
let endpoint = format!("--host={}", server.selection.endpoint);
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
&format!(
|
|
"{}:/tmp/wireguard-admin-fixture/first-delivery.conf",
|
|
server.container().id()
|
|
),
|
|
delivered
|
|
.to_str()
|
|
.ok_or("private delivery path was not UTF-8")?,
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "first delivery transfer from server fixture failed")?;
|
|
let metadata = std::fs::symlink_metadata(&delivered)
|
|
.map_err(|_| "first delivery file could not be inspected")?;
|
|
if metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.len() > 64 * 1024
|
|
|| metadata.mode() & 0o777 != 0o600
|
|
{
|
|
return Err("first delivery file was not a bounded mode-0600 regular file");
|
|
}
|
|
let bytes = std::fs::read(&delivered).map_err(|_| "first delivery file could not be read")?;
|
|
let (address, endpoint_addr) = client_connection_material(&bytes)?;
|
|
// wg-quick strip derives an interface name from the config basename and
|
|
// rejects names longer than Linux's 15-character interface limit.
|
|
let remote_delivery = "/tmp/wgm85.conf";
|
|
let remote_setconf = "/tmp/wgm85-set.conf";
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
delivered
|
|
.to_str()
|
|
.ok_or("private client path was not UTF-8")?,
|
|
&format!("{}:{remote_delivery}", client.container().id()),
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "first delivery transfer to client fixture failed")?;
|
|
verify_private_container_file(client, remote_delivery)?;
|
|
verify_container_copy_matches(
|
|
client,
|
|
remote_delivery,
|
|
&temp.path().join("delivered-copy.conf"),
|
|
&bytes,
|
|
&endpoint,
|
|
)?;
|
|
strip_client_config(client, remote_delivery, remote_setconf)?;
|
|
verify_private_container_file(client, remote_setconf)?;
|
|
setup_client_interface(
|
|
client,
|
|
"wg-m85-client",
|
|
remote_setconf,
|
|
&address,
|
|
&endpoint_addr,
|
|
)?;
|
|
|
|
let before = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !before.contains("phase=observe status=complete interface=wg-m83-probe peers=1")
|
|
|| !before.lines().any(|line| {
|
|
line.starts_with(&format!("peer={baseline_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
})
|
|
|| before.contains(&format!("peer={public_key} "))
|
|
{
|
|
return Err("real runtime reader did not report only the baseline peer before Apply");
|
|
}
|
|
for _ in 0..3 {
|
|
expect_fixture_ping_failure(client, "10.20.0.1")?;
|
|
}
|
|
let apply = invoke_driver(
|
|
&command,
|
|
b"APPLY\t/tmp/wireguard-admin-fixture/server.conf\nEND\n",
|
|
)?;
|
|
if !apply.contains("phase=fixture_apply status=verified") {
|
|
return Err("fixture Apply did not pass real runtime peer verification");
|
|
}
|
|
let post_apply = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let baseline_post_apply = post_apply
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")))
|
|
.filter(|line| peer_has_exact_ipv4_routes(line, "10.20.0.10/32"))
|
|
.and_then(peer_counters)
|
|
.ok_or("real runtime reader omitted the baseline peer after new-client Apply")?;
|
|
// Reapply the private client peer configuration after the fixture server
|
|
// setconf. This explicitly reestablishes a handshake from the baseline
|
|
// client instead of trusting counters from the pre-Apply session.
|
|
eprintln!("phase=baseline_reconnect status=started reason=fixture_server_setconf");
|
|
exec_success(
|
|
baseline_client,
|
|
["wg", "setconf", "wg-m85-baseline", REMOTE_BASELINE_SETCONF],
|
|
)
|
|
.map_err(|_| "baseline client fixture reconnect failed after new-client Apply")?;
|
|
eprintln!("phase=baseline_reconnect status=complete");
|
|
let (transmitted, received) = bounded_ping_summary(baseline_client, "10.20.0.1")?;
|
|
if received == 0 {
|
|
report_baseline_failure(
|
|
server,
|
|
&command,
|
|
baseline_key,
|
|
"after baseline fixture reconnect",
|
|
)?;
|
|
eprintln!(
|
|
"baseline ping after fixture reconnect: transmitted={transmitted} received={received}"
|
|
);
|
|
return Err(
|
|
"pre-existing baseline client stopped after new-client Apply and fixture reconnect",
|
|
);
|
|
}
|
|
let mut baseline_after_apply = None;
|
|
for _ in 0..10 {
|
|
let report = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if let Some(line) = report
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
&& line.contains("handshake=present")
|
|
&& peer_counters(line).is_some_and(|after| {
|
|
after.0 > baseline_post_apply.0 && after.1 > baseline_post_apply.1
|
|
})
|
|
{
|
|
baseline_after_apply = Some(report);
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if baseline_after_apply.is_none() {
|
|
return Err(
|
|
"real runtime reader did not observe fresh baseline traffic after new-client Apply",
|
|
);
|
|
}
|
|
let before_traffic = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let pre_traffic_peer = before_traffic
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")))
|
|
.ok_or("real runtime reader omitted the applied peer before traffic")?;
|
|
let pre_traffic_counters = peer_counters(pre_traffic_peer)
|
|
.ok_or("real runtime reader counters were unavailable before traffic")?;
|
|
exec_success(client, ["ping", "-4", "-c", "3", "-W", "2", "10.20.0.1"])
|
|
.map_err(|_| "client IPv4 tunnel traffic failed after fixture Apply")?;
|
|
let mut observed = None;
|
|
for _ in 0..10 {
|
|
let report = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if let Some(line) = report
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")))
|
|
&& peer_has_exact_ipv4_routes(line, &address)
|
|
&& line.contains("handshake=present")
|
|
&& peer_counters(line).is_some_and(|after| {
|
|
after.0 > pre_traffic_counters.0 && after.1 > pre_traffic_counters.1
|
|
})
|
|
{
|
|
observed = Some(report);
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if observed.is_none() {
|
|
let last = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if let Some(peer) = last
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")))
|
|
{
|
|
eprintln!("Rustix reader final new-peer sample: {peer}");
|
|
}
|
|
}
|
|
let observed = observed.ok_or("real runtime reader did not observe the client handshake")?;
|
|
let peer = observed
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")))
|
|
.ok_or("real runtime reader omitted the created client")?;
|
|
if !peer_has_exact_ipv4_routes(peer, &address)
|
|
|| !peer.contains("handshake=present")
|
|
|| !counter_is_positive(peer, "received=")
|
|
|| !counter_is_positive(peer, "transmitted=")
|
|
|| !endpoint_addr.contains(":51820")
|
|
{
|
|
return Err("real runtime reader reported incomplete IPv4 tunnel state");
|
|
}
|
|
|
|
// Commit the deletion through the real Application while the fixture's
|
|
// running interface still has the peer. The request carries only the
|
|
// public identity and address; key/config material stays in private files.
|
|
let pre_delete_counters = peer_counters(peer)
|
|
.ok_or("real runtime reader counters were unavailable before deletion")?;
|
|
let delete_request = format!("DELETE\t{public_key}\t{address}\nEND\n");
|
|
let deletion = invoke_driver(&command, delete_request.as_bytes())?;
|
|
if !deletion.contains(&format!(
|
|
"phase=delete status=saved public_key={public_key} current_record=removed backup_record=retained address_reserved=true"
|
|
)) {
|
|
return Err("Application deletion did not preserve the backup and address reservation");
|
|
}
|
|
let restore_archive = phase_archive_name(&deletion, "delete")?;
|
|
|
|
let saved_but_live = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !saved_but_live.contains("phase=observe status=complete interface=wg-m83-probe peers=2")
|
|
|| !saved_but_live.lines().any(|line| {
|
|
line.starts_with(&format!("peer={public_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, &address)
|
|
})
|
|
|| !saved_but_live.lines().any(|line| {
|
|
line.starts_with(&format!("peer={baseline_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
})
|
|
{
|
|
return Err("saved deletion changed the live peer set before explicit Apply");
|
|
}
|
|
exec_success(client, ["ping", "-4", "-c", "2", "-W", "2", "10.20.0.1"])
|
|
.map_err(|_| "deleted client's live IPv4 access stopped before explicit Apply")?;
|
|
exec_success(
|
|
baseline_client,
|
|
["ping", "-4", "-c", "3", "-W", "2", "10.20.0.1"],
|
|
)
|
|
.map_err(|_| "pre-existing baseline client stopped before explicit Apply")?;
|
|
let mut continued_access = None;
|
|
for _ in 0..10 {
|
|
let report = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let live_peer = report
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")));
|
|
let baseline_peer = report
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")));
|
|
if live_peer.is_some_and(|line| {
|
|
peer_counters(line).is_some_and(|after| {
|
|
after.0 > pre_delete_counters.0 && after.1 > pre_delete_counters.1
|
|
})
|
|
}) && baseline_peer.is_some_and(|line| {
|
|
peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
&& line.contains("handshake=present")
|
|
&& counter_is_positive(line, "received=")
|
|
&& counter_is_positive(line, "transmitted=")
|
|
}) {
|
|
continued_access = Some(report);
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if continued_access.is_none() {
|
|
return Err("fresh tunnel traffic did not prove access persisted after saved deletion");
|
|
}
|
|
|
|
let apply_request =
|
|
format!("APPLY\t/tmp/wireguard-admin-fixture/server.conf\t{address}\nEND\n");
|
|
let apply = invoke_driver(&command, apply_request.as_bytes())?;
|
|
if !apply.contains("phase=fixture_apply status=verified address_released=true") {
|
|
return Err("explicit fixture Apply did not verify deletion or release the address");
|
|
}
|
|
let after_apply = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let baseline_after_apply = after_apply
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")))
|
|
.filter(|line| peer_has_exact_ipv4_routes(line, "10.20.0.10/32"));
|
|
if !after_apply.contains("phase=observe status=complete interface=wg-m83-probe peers=1")
|
|
|| after_apply.contains(&format!("peer={public_key} "))
|
|
|| baseline_after_apply.is_none()
|
|
{
|
|
return Err(
|
|
"real runtime reader did not verify deleted peer removal and baseline retention",
|
|
);
|
|
}
|
|
let baseline_after_apply = baseline_after_apply
|
|
.and_then(peer_counters)
|
|
.ok_or("baseline counters were unavailable after deletion Apply")?;
|
|
// These are new attempts after the successful runtime verification. A
|
|
// separate baseline reconnect and successful probe guard against setup
|
|
// failure after the full server peer-set replacement.
|
|
for _ in 0..3 {
|
|
expect_fixture_ping_failure(client, "10.20.0.1")?;
|
|
}
|
|
eprintln!("phase=baseline_reconnect status=started reason=deletion_apply");
|
|
exec_success(
|
|
baseline_client,
|
|
["wg", "setconf", "wg-m85-baseline", REMOTE_BASELINE_SETCONF],
|
|
)
|
|
.map_err(|_| "baseline client fixture reconnect failed after deletion Apply")?;
|
|
eprintln!("phase=baseline_reconnect status=complete reason=deletion_apply");
|
|
let (transmitted, received) = bounded_ping_summary(baseline_client, "10.20.0.1")?;
|
|
if received == 0 {
|
|
report_baseline_failure(
|
|
server,
|
|
&command,
|
|
baseline_key,
|
|
"after deletion Apply baseline reconnect",
|
|
)?;
|
|
eprintln!(
|
|
"baseline ping after deletion Apply: transmitted={transmitted} received={received}"
|
|
);
|
|
return Err(
|
|
"pre-existing baseline client stopped after explicit Apply and fixture reconnect",
|
|
);
|
|
}
|
|
let mut baseline_verified = false;
|
|
for _ in 0..10 {
|
|
let report = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if report.contains("phase=observe status=complete interface=wg-m83-probe peers=1")
|
|
&& !report.contains(&format!("peer={public_key} "))
|
|
&& report.lines().any(|line| {
|
|
line.starts_with(&format!("peer={baseline_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
&& line.contains("handshake=present")
|
|
&& peer_counters(line).is_some_and(|after| {
|
|
after.0 > baseline_after_apply.0 && after.1 > baseline_after_apply.1
|
|
})
|
|
})
|
|
{
|
|
baseline_verified = true;
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if !baseline_verified {
|
|
return Err(
|
|
"real runtime reader did not verify fresh baseline traffic after deletion Apply",
|
|
);
|
|
}
|
|
|
|
// Restore the exact pre-delete archive emitted by this Application Commit.
|
|
// It contains the complete prior client set and vault while runtime is
|
|
// currently baseline-only. Restore must replace saved files only.
|
|
let restore_request = format!("RESTORE\t{restore_archive}\t{address}\nEND\n");
|
|
let restore = invoke_driver(&command, restore_request.as_bytes())?;
|
|
if !restore.contains(&format!(
|
|
"phase=restore status=confirmed archive={restore_archive} reason=pre-commit precautionary_backup=pre-restore settings=true server=true vault=true target_client_present=true pending_address_reserved=false runtime_unchanged=true"
|
|
)) {
|
|
return Err("password-free restore did not verify the exact archive and precautionary backup");
|
|
}
|
|
let saved_restored_live = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !saved_restored_live.contains("phase=observe status=complete interface=wg-m83-probe peers=1")
|
|
|| saved_restored_live.contains(&format!("peer={public_key} "))
|
|
|| !saved_restored_live.lines().any(|line| {
|
|
line.starts_with(&format!("peer={baseline_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
})
|
|
{
|
|
return Err("restore changed the live peer set before explicit fixture Apply");
|
|
}
|
|
for _ in 0..3 {
|
|
expect_fixture_ping_failure(client, "10.20.0.1")?;
|
|
}
|
|
|
|
let restore_apply = invoke_driver(
|
|
&command,
|
|
format!("APPLY\t/tmp/wireguard-admin-fixture/server.conf\t{address}\nEND\n").as_bytes(),
|
|
)?;
|
|
if !restore_apply.contains("phase=fixture_apply status=verified address_released=true") {
|
|
return Err(
|
|
"explicit fixture Apply after restore did not verify the restored saved peer set",
|
|
);
|
|
}
|
|
let before_restored_traffic = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
let restored_before = before_restored_traffic
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={public_key} ")))
|
|
.filter(|line| peer_has_exact_ipv4_routes(line, &address))
|
|
.and_then(peer_counters)
|
|
.ok_or("real runtime reader omitted the restored peer after explicit Apply")?;
|
|
eprintln!("phase=client_reconnect status=started reason=restore_apply");
|
|
exec_success(client, ["wg", "setconf", "wg-m85-client", remote_setconf])
|
|
.map_err(|_| "restored client fixture reconnect failed after restore Apply")?;
|
|
eprintln!("phase=client_reconnect status=complete reason=restore_apply");
|
|
let (transmitted, received) = bounded_ping_summary(client, "10.20.0.1")?;
|
|
if received == 0 {
|
|
return Err("restored client did not renew IPv4 tunnel traffic after explicit Apply");
|
|
}
|
|
let mut restored_traffic = false;
|
|
for _ in 0..10 {
|
|
let report = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if report.contains("phase=observe status=complete interface=wg-m83-probe peers=2")
|
|
&& report.lines().any(|line| {
|
|
line.starts_with(&format!("peer={public_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, &address)
|
|
&& line.contains("handshake=present")
|
|
&& peer_counters(line).is_some_and(|after| {
|
|
after.0 > restored_before.0 && after.1 > restored_before.1
|
|
})
|
|
})
|
|
{
|
|
restored_traffic = true;
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if !restored_traffic {
|
|
eprintln!("restored client ping summary: transmitted={transmitted} received={received}");
|
|
return Err("real runtime reader did not observe fresh restored-client IPv4 traffic");
|
|
}
|
|
|
|
// Restoring the earlier baseline archive removes the restored client from
|
|
// saved state while it remains active in the kernel. Its address must stay
|
|
// reserved until another explicit, successfully verified fixture Apply.
|
|
let reservation_restore_request = format!("RESTORE\t{baseline_archive}\t{address}\nEND\n");
|
|
let reservation_restore = invoke_driver(&command, reservation_restore_request.as_bytes())?;
|
|
if !reservation_restore.contains(&format!(
|
|
"phase=restore status=confirmed archive={baseline_archive} reason=pre-commit precautionary_backup=pre-restore settings=true server=true vault=true target_client_present=false pending_address_reserved=true runtime_unchanged=true"
|
|
)) {
|
|
return Err("restore released a pending client address before explicit Apply");
|
|
}
|
|
let live_after_second_restore = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !live_after_second_restore
|
|
.contains("phase=observe status=complete interface=wg-m83-probe peers=2")
|
|
|| !live_after_second_restore.contains(&format!("peer={public_key} "))
|
|
{
|
|
return Err("second restore changed the live peer set before explicit fixture Apply");
|
|
}
|
|
exec_success(client, ["wg", "setconf", "wg-m85-client", remote_setconf])
|
|
.map_err(|_| "restored client fixture reconnect failed before reservation Apply")?;
|
|
exec_success(client, ["ping", "-4", "-c", "2", "-W", "2", "10.20.0.1"])
|
|
.map_err(|_| "restored client access did not remain live before reservation Apply")?;
|
|
let release_apply = invoke_driver(
|
|
&command,
|
|
format!("APPLY\t/tmp/wireguard-admin-fixture/server.conf\t{address}\nEND\n").as_bytes(),
|
|
)?;
|
|
if !release_apply.contains("phase=fixture_apply status=verified address_released=true") {
|
|
return Err("explicit fixture Apply did not verify reservation release after restore");
|
|
}
|
|
let released_runtime = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if !released_runtime.contains("phase=observe status=complete interface=wg-m83-probe peers=1")
|
|
|| released_runtime.contains(&format!("peer={public_key} "))
|
|
{
|
|
return Err("explicit Apply did not remove the restored client after reservation restore");
|
|
}
|
|
for _ in 0..3 {
|
|
expect_fixture_ping_failure(client, "10.20.0.1")?;
|
|
}
|
|
|
|
let imported = invoke_driver(&command, b"IMPORT_REISSUE\nEND\n")?;
|
|
for marker in [
|
|
"phase=import_reissue status=verified matched=true",
|
|
"conflict_visible=true invalid_visible=true originals_unchanged=true",
|
|
"wrong_password_blocked=true conflict_reissue_blocked=true reissue_mode=0600",
|
|
] {
|
|
if !imported.contains(marker) {
|
|
return Err("Application import/reissue scenario omitted a required safety assertion");
|
|
}
|
|
}
|
|
let import_temp = tempfile::Builder::new()
|
|
.prefix("wga-m85-import-reissue-")
|
|
.tempdir()
|
|
.map_err(|_| "private import/reissue staging directory could not be created")?;
|
|
std::fs::set_permissions(import_temp.path(), std::fs::Permissions::from_mode(0o700))
|
|
.map_err(|_| "private import/reissue directory permissions failed")?;
|
|
let reissued = import_temp.path().join("reissued-client.conf");
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
&format!(
|
|
"{}:/tmp/wireguard-admin-fixture/import-reissue/reissued-client.conf",
|
|
server.container().id()
|
|
),
|
|
reissued
|
|
.to_str()
|
|
.ok_or("private reissue path was not UTF-8")?,
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "password-authorized imported client transfer failed")?;
|
|
verify_private_host_file(&reissued, 64 * 1024)?;
|
|
let reissued_bytes = std::fs::read(&reissued)
|
|
.map_err(|_| "password-authorized imported client file could not be read")?;
|
|
let (imported_address, imported_endpoint) = client_connection_material(&reissued_bytes)?;
|
|
const REMOTE_REISSUED: &str = "/tmp/wgb85-imported.conf";
|
|
const REMOTE_REISSUED_SETCONF: &str = "/tmp/wgb85-imported-set.conf";
|
|
docker_text_with_timeout(
|
|
&[
|
|
&endpoint,
|
|
"cp",
|
|
reissued
|
|
.to_str()
|
|
.ok_or("private reissue path was not UTF-8")?,
|
|
&format!("{}:{REMOTE_REISSUED}", client.container().id()),
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "password-authorized imported client transfer failed")?;
|
|
verify_private_container_file(client, REMOTE_REISSUED)?;
|
|
verify_container_copy_matches(
|
|
client,
|
|
REMOTE_REISSUED,
|
|
&import_temp.path().join("client-copy.conf"),
|
|
&reissued_bytes,
|
|
&endpoint,
|
|
)?;
|
|
strip_client_config(client, REMOTE_REISSUED, REMOTE_REISSUED_SETCONF)?;
|
|
verify_private_container_file(client, REMOTE_REISSUED_SETCONF)?;
|
|
exec_success(baseline_client, ["ip", "link", "del", "wg-m85-baseline"])
|
|
.map_err(|_| "baseline client fixture did not stop before imported-key reissue")?;
|
|
let _ = exec_success(client, ["ip", "link", "del", "wg-m85-client"]);
|
|
setup_client_interface(
|
|
client,
|
|
"wg-m85-client",
|
|
REMOTE_REISSUED_SETCONF,
|
|
&imported_address,
|
|
&imported_endpoint,
|
|
)?;
|
|
exec_success(client, ["ping", "-4", "-c", "3", "-W", "2", "10.20.0.1"])
|
|
.map_err(|_| "reissued imported client did not establish IPv4 tunnel traffic")?;
|
|
let mut imported_traffic = false;
|
|
for _ in 0..10 {
|
|
let observation = invoke_driver(&command, b"OBSERVE\nEND\n")?;
|
|
if observation.lines().any(|line| {
|
|
line.starts_with(&format!("peer={baseline_key} "))
|
|
&& peer_has_exact_ipv4_routes(line, "10.20.0.10/32")
|
|
&& line.contains("handshake=present")
|
|
&& counter_is_positive(line, "received=")
|
|
&& counter_is_positive(line, "transmitted=")
|
|
}) {
|
|
imported_traffic = true;
|
|
break;
|
|
}
|
|
thread::sleep(Duration::from_millis(250));
|
|
}
|
|
if !imported_traffic {
|
|
return Err("real runtime reader did not observe traffic from the reissued imported file");
|
|
}
|
|
|
|
let failure = invoke_driver(&command, b"FAILURE_PREVENTS_APPLICATION\nEND\n")?;
|
|
for marker in [
|
|
"phase=reader_failures status=verified missing_interface=rejected deadline=timeout empty_or_partial=false",
|
|
"phase=commit_backup_failure status=verified saved_files_unchanged=true draft_preserved=true runtime_unchanged=true archive_path_restored=true",
|
|
"phase=restart_backup_failure status=verified apply_called=false restart_submitted=false address_reserved=true historical_state=unchanged runtime_unchanged=true archive_path_restored=true",
|
|
"phase=failure_prevents_application status=complete",
|
|
] {
|
|
if !failure.contains(marker) {
|
|
return Err("backup-failure scenario omitted a required safety assertion");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn invoke_driver(command: &[&str], request: &[u8]) -> Result<String, &'static str> {
|
|
let report = docker_exchange_report(command, request, DRIVER_TRANSPORT_TIMEOUT, 2048, 1024)
|
|
.map_err(|_| "Application fixture phase exchange failed")?;
|
|
let combined = format!("{}\n{}", report.stdout, report.stderr);
|
|
if report.failure_category.is_some() {
|
|
let progress = combined
|
|
.split_whitespace()
|
|
.filter(|part| {
|
|
part.starts_with("phase=")
|
|
|| part.starts_with("status=")
|
|
|| part.starts_with("stage=")
|
|
|| part.starts_with("step=")
|
|
|| part.starts_with("settings_match=")
|
|
|| part.starts_with("server_match=")
|
|
|| part.starts_with("vault_match=")
|
|
|| part.starts_with("target_presence_match=")
|
|
|| part.starts_with("reservations_match=")
|
|
|| part.starts_with("actual_reservation_count=")
|
|
|| part.starts_with("expected_reservation_count=")
|
|
|| part.starts_with("actual_target_reserved=")
|
|
|| part.starts_with("expected_target_reserved=")
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
eprintln!("Fixture driver reported bounded phase status: {progress}");
|
|
return Err(driver_phase_failure_category(&combined));
|
|
}
|
|
if !driver_ran_exactly_one_test(&combined) {
|
|
return Err("Application fixture phase driver failed");
|
|
}
|
|
Ok(combined)
|
|
}
|
|
|
|
fn phase_public_key<'a>(report: &'a str, phase: &str) -> Result<&'a str, &'static str> {
|
|
let marker = format!("phase={phase} status=verified public_key=");
|
|
let create_marker = format!("phase={phase} status=ok public_key=");
|
|
report
|
|
.split_once(&marker)
|
|
.or_else(|| report.split_once(&create_marker))
|
|
.and_then(|(_, tail)| tail.split_whitespace().next())
|
|
.ok_or("fixture phase report omitted its public identity")
|
|
}
|
|
|
|
fn phase_archive_name<'a>(report: &'a str, phase: &str) -> Result<&'a str, &'static str> {
|
|
let completed_status = match phase {
|
|
"commit" => "ok",
|
|
"delete" => "saved",
|
|
_ => return Err("Application phase archive identity was invalid"),
|
|
};
|
|
let prefix = format!("phase={phase} ");
|
|
let line = report
|
|
.lines()
|
|
.find(|line| {
|
|
if !line.starts_with(&prefix) {
|
|
return false;
|
|
}
|
|
let mut statuses = line
|
|
.split_whitespace()
|
|
.filter_map(|field| field.strip_prefix("status="));
|
|
statuses.next() == Some(completed_status) && statuses.next().is_none()
|
|
})
|
|
.ok_or("Application phase omitted its archive identity")?;
|
|
let mut names = line
|
|
.split_whitespace()
|
|
.filter_map(|field| field.strip_prefix("backup_name="));
|
|
let name = names
|
|
.next()
|
|
.ok_or("Application phase omitted its archive identity")?;
|
|
if names.next().is_some()
|
|
|| name.is_empty()
|
|
|| name.len() > 128
|
|
|| !name
|
|
.bytes()
|
|
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
|
|
|| !name.ends_with(".tar")
|
|
{
|
|
return Err("Application phase archive identity was invalid");
|
|
}
|
|
Ok(name)
|
|
}
|
|
|
|
fn verify_private_container_file(
|
|
container: &OwnedContainer,
|
|
path: &str,
|
|
) -> Result<(), &'static str> {
|
|
let mode = exec_small(container, ["stat", "-c", "%a", path])?;
|
|
if mode == "600" {
|
|
Ok(())
|
|
} else {
|
|
Err("secret-bearing fixture config was not mode 0600 after transfer")
|
|
}
|
|
}
|
|
|
|
fn verify_container_copy_matches(
|
|
container: &OwnedContainer,
|
|
remote_path: &str,
|
|
local_path: &std::path::Path,
|
|
expected_bytes: &[u8],
|
|
endpoint: &str,
|
|
) -> Result<(), &'static str> {
|
|
docker_text_with_timeout(
|
|
&[
|
|
endpoint,
|
|
"cp",
|
|
&format!("{}:{remote_path}", container.container().id()),
|
|
local_path
|
|
.to_str()
|
|
.ok_or("private client verification path was not UTF-8")?,
|
|
],
|
|
Duration::from_secs(20),
|
|
)
|
|
.map_err(|_| "client copy could not be returned for byte verification")?;
|
|
let metadata = std::fs::symlink_metadata(local_path)
|
|
.map_err(|_| "client copy verification file could not be inspected")?;
|
|
use std::os::unix::fs::MetadataExt;
|
|
if metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.len() != expected_bytes.len() as u64
|
|
|| metadata.mode() & 0o777 != 0o600
|
|
{
|
|
return Err("client copy verification file was not a private regular file");
|
|
}
|
|
let copied_bytes =
|
|
std::fs::read(local_path).map_err(|_| "client copy verification file could not be read")?;
|
|
if copied_bytes == expected_bytes {
|
|
Ok(())
|
|
} else {
|
|
Err("client copy did not match the first-delivery bytes")
|
|
}
|
|
}
|
|
|
|
fn verify_private_host_file(
|
|
path: &std::path::Path,
|
|
maximum_bytes: u64,
|
|
) -> Result<(), &'static str> {
|
|
use std::os::unix::fs::MetadataExt;
|
|
let metadata = std::fs::symlink_metadata(path)
|
|
.map_err(|_| "private transferred client file could not be inspected")?;
|
|
if metadata.file_type().is_symlink()
|
|
|| !metadata.is_file()
|
|
|| metadata.len() > maximum_bytes
|
|
|| metadata.mode() & 0o777 != 0o600
|
|
{
|
|
return Err("private transferred client file was not a bounded mode-0600 regular file");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn strip_client_config(
|
|
client: &OwnedContainer,
|
|
source_path: &str,
|
|
destination_path: &str,
|
|
) -> Result<(), &'static str> {
|
|
// wg-quick strip parses the copied file and does not apply its hooks. The
|
|
// fixed wrapper redirects private output to a mode-0600 file; config text is
|
|
// never passed as an argument or emitted to the test logs.
|
|
const STRIP_SCRIPT: &str = "umask 077; wg-quick strip \"$1\" > \"$2\" && chmod 600 \"$2\"";
|
|
exec_success(
|
|
client,
|
|
[
|
|
"/bin/busybox",
|
|
"sh",
|
|
"-c",
|
|
STRIP_SCRIPT,
|
|
"wga-strip",
|
|
source_path,
|
|
destination_path,
|
|
],
|
|
)
|
|
.map_err(|_| "copied client configuration could not be safely stripped")
|
|
}
|
|
|
|
fn setup_client_interface(
|
|
client: &OwnedContainer,
|
|
interface: &str,
|
|
setconf_path: &str,
|
|
address: &str,
|
|
endpoint: &str,
|
|
) -> Result<(), &'static str> {
|
|
if interface != "wg-m85-baseline" && interface != "wg-m85-client" {
|
|
return Err("client fixture interface name was not recognized");
|
|
}
|
|
let endpoint_route = endpoint_host_route(endpoint)?;
|
|
exec_success(
|
|
client,
|
|
["ip", "link", "add", interface, "type", "wireguard"],
|
|
)
|
|
.map_err(|_| "client fixture WireGuard interface setup failed")?;
|
|
// The endpoint has a more-specific route through the underlay before the
|
|
// default route is sent through WireGuard.
|
|
exec_success(
|
|
client,
|
|
[
|
|
"ip",
|
|
"-4",
|
|
"route",
|
|
"replace",
|
|
&endpoint_route,
|
|
"dev",
|
|
"eth0",
|
|
],
|
|
)
|
|
.map_err(|_| "server endpoint underlay route could not be preserved")?;
|
|
exec_success(client, ["wg", "setconf", interface, setconf_path])
|
|
.map_err(|_| "delivered client configuration was rejected by WireGuard")?;
|
|
exec_success(
|
|
client,
|
|
["ip", "-4", "address", "add", address, "dev", interface],
|
|
)
|
|
.map_err(|_| "delivered IPv4 address could not be configured")?;
|
|
exec_success(client, ["ip", "link", "set", "dev", interface, "up"])
|
|
.map_err(|_| "client fixture WireGuard link setup failed")?;
|
|
exec_success(
|
|
client,
|
|
["ip", "-4", "route", "replace", "default", "dev", interface],
|
|
)
|
|
.map_err(|_| "delivered full-tunnel IPv4 route could not be configured")
|
|
}
|
|
|
|
fn endpoint_host_route(endpoint: &str) -> Result<String, &'static str> {
|
|
let (host, port) = endpoint
|
|
.rsplit_once(':')
|
|
.ok_or("delivered client endpoint was malformed")?;
|
|
if port != "51820" {
|
|
return Err("delivered client endpoint did not use the fixture port");
|
|
}
|
|
let address = host
|
|
.parse::<std::net::Ipv4Addr>()
|
|
.map_err(|_| "delivered client endpoint was not IPv4")?;
|
|
Ok(format!("{address}/32"))
|
|
}
|
|
|
|
fn expect_fixture_ping_failure(
|
|
container: &OwnedContainer,
|
|
destination: &str,
|
|
) -> Result<(), &'static str> {
|
|
require_zero_ping_replies(bounded_ping_summary(container, destination))
|
|
}
|
|
|
|
fn require_zero_ping_replies(
|
|
summary: Result<(u32, u32), &'static str>,
|
|
) -> Result<(), &'static str> {
|
|
let (transmitted, received) = summary?;
|
|
if transmitted == 3 && received == 0 {
|
|
Ok(())
|
|
} else if received > 0 {
|
|
Err("client still reached the tunnel during a negative reachability probe")
|
|
} else {
|
|
Err("negative reachability probe did not transmit three packets")
|
|
}
|
|
}
|
|
|
|
fn bounded_ping_summary(
|
|
container: &OwnedContainer,
|
|
destination: &str,
|
|
) -> Result<(u32, u32), &'static str> {
|
|
// Keep ping's packet-loss summary even when it exits nonzero; the final
|
|
// `true` only preserves that bounded output for diagnosis, while the
|
|
// parsed receive count below remains a strict reachability assertion.
|
|
const PING_SCRIPT: &str = "ping -q -4 -c 3 -W 2 \"$1\"; /bin/busybox true";
|
|
let output = exec_bounded(
|
|
container,
|
|
&[
|
|
"/bin/busybox",
|
|
"sh",
|
|
"-c",
|
|
PING_SCRIPT,
|
|
"wga-ping",
|
|
destination,
|
|
],
|
|
COMMAND_STDOUT_LIMIT,
|
|
COMMAND_STDERR_LIMIT,
|
|
"Baseline reachability probe failed",
|
|
)?;
|
|
let output = String::from_utf8(output).map_err(|_| "Baseline ping output was invalid")?;
|
|
let summary = output
|
|
.lines()
|
|
.find(|line| line.contains("packets transmitted") && line.contains("packets received"))
|
|
.ok_or("Baseline ping summary was absent")?;
|
|
parse_ping_summary(summary)
|
|
}
|
|
|
|
fn parse_ping_summary(summary: &str) -> Result<(u32, u32), &'static str> {
|
|
let mut fields = summary.split(',');
|
|
let transmitted = fields
|
|
.next()
|
|
.filter(|field| field.contains("packets transmitted"))
|
|
.and_then(|field| field.split_whitespace().next())
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
.ok_or("Baseline ping summary was malformed")?;
|
|
let received = fields
|
|
.next()
|
|
.filter(|field| field.contains("packets received"))
|
|
.and_then(|field| field.split_whitespace().next())
|
|
.and_then(|value| value.parse::<u32>().ok())
|
|
.ok_or("Baseline ping summary was malformed")?;
|
|
Ok((transmitted, received))
|
|
}
|
|
|
|
fn report_baseline_failure(
|
|
server: &OwnedContainer,
|
|
command: &[&str],
|
|
baseline_key: &str,
|
|
stage: &str,
|
|
) -> Result<(), &'static str> {
|
|
let report = invoke_driver(command, b"OBSERVE\nEND\n")?;
|
|
let peer = report
|
|
.lines()
|
|
.find(|line| line.starts_with(&format!("peer={baseline_key} ")));
|
|
if let Some(peer) = peer {
|
|
let routes = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("routes="))
|
|
.unwrap_or("unavailable");
|
|
let handshake = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("handshake="))
|
|
.unwrap_or("unavailable");
|
|
let received = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("received="))
|
|
.unwrap_or("unavailable");
|
|
let transmitted = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("transmitted="))
|
|
.unwrap_or("unavailable");
|
|
eprintln!(
|
|
"baseline peer {stage}: routes={routes} handshake={handshake} received={received} transmitted={transmitted}"
|
|
);
|
|
} else {
|
|
eprintln!("baseline peer {stage}: absent from complete runtime observation");
|
|
}
|
|
|
|
let endpoints = exec_bounded(
|
|
server,
|
|
&["wg", "show", "wg-m83-probe", "endpoints"],
|
|
COMMAND_STDOUT_LIMIT,
|
|
COMMAND_STDERR_LIMIT,
|
|
"Baseline endpoint observation failed",
|
|
)?;
|
|
let endpoints =
|
|
String::from_utf8(endpoints).map_err(|_| "Baseline endpoint observation was invalid")?;
|
|
let endpoint_learned = endpoints.lines().any(|line| {
|
|
line.split_whitespace()
|
|
.next()
|
|
.is_some_and(|identity| identity == baseline_key)
|
|
&& line
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.is_some_and(|endpoint| endpoint != "(none)")
|
|
});
|
|
eprintln!("baseline server endpoint learned={endpoint_learned}");
|
|
Ok(())
|
|
}
|
|
|
|
fn counter_is_positive(peer: &str, field: &str) -> bool {
|
|
peer.split_whitespace()
|
|
.find_map(|part| part.strip_prefix(field))
|
|
.and_then(|value| value.parse::<u64>().ok())
|
|
.is_some_and(|value| value > 0)
|
|
}
|
|
|
|
fn peer_counters(peer: &str) -> Option<(u64, u64)> {
|
|
let received = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("received="))?
|
|
.parse::<u64>()
|
|
.ok()?;
|
|
let transmitted = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("transmitted="))?
|
|
.parse::<u64>()
|
|
.ok()?;
|
|
Some((received, transmitted))
|
|
}
|
|
|
|
fn client_connection_material(bytes: &[u8]) -> Result<(String, String), &'static str> {
|
|
let text = std::str::from_utf8(bytes).map_err(|_| "delivered client file was not UTF-8")?;
|
|
let mut section = "";
|
|
let mut addresses = Vec::new();
|
|
let mut endpoint = None;
|
|
let mut ipv4_routes = std::collections::BTreeSet::new();
|
|
for line in text.lines() {
|
|
let trimmed = line.trim();
|
|
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
|
section = trimmed;
|
|
}
|
|
if let Some((name, value)) = trimmed.split_once('=') {
|
|
match (section, name.trim()) {
|
|
("[Interface]", "Address") => {
|
|
for item in value.split(',') {
|
|
let address = item
|
|
.trim()
|
|
.parse::<ipnet::IpNet>()
|
|
.map_err(|_| "delivered client Address was malformed")?;
|
|
if let ipnet::IpNet::V4(network) = address {
|
|
addresses.push(network);
|
|
}
|
|
}
|
|
}
|
|
("[Peer]", "Endpoint") => endpoint = Some(value.trim().to_owned()),
|
|
("[Peer]", "AllowedIPs") => {
|
|
for item in value.split(',') {
|
|
let route = item
|
|
.trim()
|
|
.parse::<ipnet::IpNet>()
|
|
.map_err(|_| "delivered client AllowedIPs was malformed")?;
|
|
if let ipnet::IpNet::V4(network) = route {
|
|
ipv4_routes.insert(network);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
let expected_route = "0.0.0.0/0"
|
|
.parse::<ipnet::Ipv4Net>()
|
|
.map_err(|_| "expected IPv4 full-tunnel route was invalid")?;
|
|
if ipv4_routes != [expected_route].into_iter().collect() {
|
|
return Err("delivered client file did not contain the expected IPv4 full-tunnel route");
|
|
}
|
|
let endpoint = endpoint.ok_or("delivered client endpoint was absent")?;
|
|
let _ = endpoint_host_route(&endpoint)?;
|
|
if addresses.len() != 1 || addresses[0].prefix_len() != 32 {
|
|
return Err("delivered client did not contain exactly one IPv4 /32 address");
|
|
}
|
|
Ok((addresses[0].to_string(), endpoint))
|
|
}
|
|
|
|
fn peer_has_exact_ipv4_routes(peer: &str, expected: &str) -> bool {
|
|
let Some(routes) = peer
|
|
.split_whitespace()
|
|
.find_map(|part| part.strip_prefix("routes="))
|
|
else {
|
|
return false;
|
|
};
|
|
let actual = routes
|
|
.split(',')
|
|
.filter_map(|route| route.parse::<ipnet::IpNet>().ok())
|
|
.filter_map(|route| match route {
|
|
ipnet::IpNet::V4(route) => Some(route),
|
|
ipnet::IpNet::V6(_) => None,
|
|
})
|
|
.collect::<std::collections::BTreeSet<_>>();
|
|
let Ok(expected) = expected.parse::<ipnet::Ipv4Net>() else {
|
|
return false;
|
|
};
|
|
actual == [expected].into_iter().collect()
|
|
}
|
|
|
|
#[derive(Debug, Eq, PartialEq)]
|
|
struct DockerExchangeReport {
|
|
stdout: String,
|
|
stderr: String,
|
|
failure_category: Option<&'static str>,
|
|
}
|
|
|
|
fn docker_exchange_report(
|
|
arguments: &[&str],
|
|
request: &[u8],
|
|
command_deadline: Duration,
|
|
stdout_limit: usize,
|
|
stderr_limit: usize,
|
|
) -> Result<DockerExchangeReport, &'static str> {
|
|
if request.len() > MAX_PHASE_REQUEST_BYTES {
|
|
return Err("fixture phase request exceeded its limit");
|
|
}
|
|
let mut child = Command::new("docker")
|
|
.args(arguments)
|
|
.stdin(Stdio::piped())
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.spawn()
|
|
.map_err(|_| "Docker CLI could not be started")?;
|
|
let mut stdin = child.stdin.take().ok_or("Docker stdin unavailable")?;
|
|
let stdout = child.stdout.take().ok_or("Docker stdout unavailable")?;
|
|
let stderr = child.stderr.take().ok_or("Docker stderr unavailable")?;
|
|
let stdout_reader = thread::spawn(move || read_bounded(stdout, stdout_limit));
|
|
let stderr_reader = thread::spawn(move || read_bounded(stderr, stderr_limit));
|
|
let deadline = Instant::now() + command_deadline;
|
|
if stdin.write_all(request).is_err() {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = stdout_reader.join();
|
|
let _ = stderr_reader.join();
|
|
return Err("Docker fixture request write failed");
|
|
}
|
|
drop(stdin);
|
|
let status = loop {
|
|
match child.try_wait() {
|
|
Ok(Some(status)) => break status,
|
|
Ok(None) if Instant::now() >= deadline => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = stdout_reader.join();
|
|
let _ = stderr_reader.join();
|
|
return Err("Docker command timed out");
|
|
}
|
|
Ok(None) => thread::sleep(Duration::from_millis(20)),
|
|
Err(_) => {
|
|
let _ = child.kill();
|
|
let _ = child.wait();
|
|
let _ = stdout_reader.join();
|
|
let _ = stderr_reader.join();
|
|
return Err("Docker command wait failed");
|
|
}
|
|
}
|
|
};
|
|
let (stdout, stdout_exceeded) = stdout_reader
|
|
.join()
|
|
.map_err(|_| "Docker stdout reader failed")?
|
|
.map_err(|_| "Docker stdout read failed")?;
|
|
let (stderr, stderr_exceeded) = stderr_reader
|
|
.join()
|
|
.map_err(|_| "Docker stderr reader failed")?
|
|
.map_err(|_| "Docker stderr read failed")?;
|
|
if stdout_exceeded || stderr_exceeded {
|
|
return Err("Docker command output exceeded its limit");
|
|
}
|
|
let stdout = String::from_utf8(stdout).map_err(|_| "Docker returned non-UTF-8 output")?;
|
|
let stderr = String::from_utf8(stderr).map_err(|_| "Docker returned non-UTF-8 output")?;
|
|
let failure_category = (!status.success()).then(|| match status.code() {
|
|
Some(124 | 137 | 143) => "In-container fixture command timed out",
|
|
_ => "Docker command returned a nonzero status",
|
|
});
|
|
Ok(DockerExchangeReport {
|
|
stdout,
|
|
stderr,
|
|
failure_category,
|
|
})
|
|
}
|
|
|
|
fn read_bounded(mut reader: impl Read, limit: usize) -> std::io::Result<(Vec<u8>, bool)> {
|
|
let mut captured = Vec::new();
|
|
let mut buffer = [0_u8; 1024];
|
|
let mut exceeded = false;
|
|
loop {
|
|
let count = reader.read(&mut buffer)?;
|
|
if count == 0 {
|
|
return Ok((captured, exceeded));
|
|
}
|
|
let remaining = limit.saturating_sub(captured.len());
|
|
captured.extend_from_slice(&buffer[..count.min(remaining)]);
|
|
exceeded |= count > remaining;
|
|
}
|
|
}
|
|
|
|
fn driver_ran_exactly_one_test(output: &str) -> bool {
|
|
output.contains("running 1 test")
|
|
&& output.contains("test container_driver::alpine_scenario_driver")
|
|
}
|
|
|
|
fn driver_phase_failure_category(output: &str) -> &'static str {
|
|
if let Some(stage) = output.lines().find_map(|line| {
|
|
line.split_once("phase=fixture_apply status=failed stage=")
|
|
.map(|(_, tail)| tail.trim())
|
|
}) {
|
|
return match stage {
|
|
"Preparation" => "Application fixture Apply failed during preparation",
|
|
"Backup" => "Application fixture Apply failed during backup",
|
|
"Service" => "Application fixture Apply command failed",
|
|
"RuntimeRead" => "Application fixture Apply runtime observation failed",
|
|
"Verification" => "Application fixture Apply peer verification failed",
|
|
"Persistence" => "Application fixture Apply saved-state publication failed",
|
|
_ => "Application fixture Apply failed at an unknown stage",
|
|
};
|
|
}
|
|
let failed_phase = output
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.windows(2)
|
|
.filter_map(|pair| {
|
|
(pair[1] == "status=started")
|
|
.then(|| pair[0].strip_prefix("phase="))
|
|
.flatten()
|
|
})
|
|
.next_back();
|
|
match failed_phase {
|
|
Some("create") => "Application fixture Create phase failed",
|
|
Some("commit") => "Application fixture Commit phase failed",
|
|
Some("delivery") => "Application fixture delivery phase failed",
|
|
Some("delete") => "Application fixture saved deletion phase failed",
|
|
Some("baseline_apply") => "Application fixture baseline setup failed",
|
|
Some("fixture_apply") => "Application fixture apply phase failed",
|
|
Some("failure_prevents_application") => "Application fixture backup-failure phase failed",
|
|
Some("reader_failures") => "Application fixture runtime-reader failure checks failed",
|
|
Some("commit_backup_failure") => "Application fixture Commit backup-failure check failed",
|
|
Some("restart_backup_failure") => "Application fixture restart backup-failure check failed",
|
|
Some("end") => "Application fixture protocol termination failed",
|
|
_ => "Application fixture bootstrap failed",
|
|
}
|
|
}
|
|
|
|
fn missing_driver_phase_category(output: &str) -> Option<&'static str> {
|
|
let has = |marker: &str| output.split_once(marker).is_some();
|
|
if !has("phase=fixture_bootstrap status=ok") {
|
|
return Some("Application fixture report omitted bootstrap completion");
|
|
}
|
|
if !has("phase=create status=ok public_key=") {
|
|
return Some("Application fixture report omitted Create completion");
|
|
}
|
|
if !has("phase=commit status=ok") {
|
|
return Some("Application fixture report omitted Commit completion");
|
|
}
|
|
if !has("phase=delivery status=ok activation_warning=true") {
|
|
return Some("Application fixture report omitted first delivery or activation warning");
|
|
}
|
|
if !has("current_record=removed backup_record=retained address_reserved=true") {
|
|
return Some("Application fixture report omitted saved-deletion retention checks");
|
|
}
|
|
if !has("phase=restore status=confirmed")
|
|
|| !has("target_client_present=true pending_address_reserved=false")
|
|
|| !has("target_client_present=false pending_address_reserved=true")
|
|
|| !has("precautionary_backup=pre-restore")
|
|
{
|
|
return Some("Application fixture report omitted password-free restore separation checks");
|
|
}
|
|
if !has("phase=fixture_apply status=verified address_released=true") {
|
|
return Some("Application fixture report omitted verified fixture apply");
|
|
}
|
|
if !has("phase=end status=ok") {
|
|
return Some("Application fixture report omitted protocol termination");
|
|
}
|
|
None
|
|
}
|
|
|
|
fn exec_smoke_command<const N: usize>(
|
|
container: &OwnedContainer,
|
|
arguments: &[&str; N],
|
|
category: &'static str,
|
|
) -> Result<(), &'static str> {
|
|
exec_bounded(
|
|
container,
|
|
arguments,
|
|
COMMAND_STDOUT_LIMIT,
|
|
COMMAND_STDERR_LIMIT,
|
|
category,
|
|
)
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// Cleans exact resources only after their name and per-worker owner label match.
|
|
/// The caller must stop and reap the worker first, preventing later creation.
|
|
pub fn cleanup_confirmed_probe_resources(
|
|
selection: &Selection,
|
|
names: &ProbeNames,
|
|
owner: &str,
|
|
budget: Duration,
|
|
) -> Vec<&'static str> {
|
|
let deadline = Instant::now() + budget;
|
|
let mut failures = Vec::new();
|
|
|
|
for (kind, name) in [
|
|
(ResourceKind::Container, names.client.as_str()),
|
|
(ResourceKind::Container, names.baseline.as_str()),
|
|
(ResourceKind::Container, names.server.as_str()),
|
|
(ResourceKind::Network, names.network.as_str()),
|
|
] {
|
|
if let Err(category) = remove_if_confirmed_owned(selection, kind, name, owner, deadline) {
|
|
failures.push(category);
|
|
}
|
|
}
|
|
failures
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ResourceKind {
|
|
Container,
|
|
Network,
|
|
}
|
|
|
|
fn remove_if_confirmed_owned(
|
|
selection: &Selection,
|
|
kind: ResourceKind,
|
|
name: &str,
|
|
owner: &str,
|
|
deadline: Instant,
|
|
) -> Result<(), &'static str> {
|
|
let timeout = deadline
|
|
.checked_duration_since(Instant::now())
|
|
.filter(|remaining| !remaining.is_zero())
|
|
.ok_or("cleanup budget expired")?;
|
|
let endpoint = format!("--host={}", selection.endpoint);
|
|
let (resource, format) = match kind {
|
|
ResourceKind::Container => (
|
|
"container",
|
|
"--format={{.Id}} {{index .Config.Labels \"wireguard-admin.m8_3_owner\"}}",
|
|
),
|
|
ResourceKind::Network => (
|
|
"network",
|
|
"--format={{.Id}} {{index .Labels \"wireguard-admin.m8_3_owner\"}}",
|
|
),
|
|
};
|
|
let output = match docker_text_with_timeout(
|
|
&[&endpoint, resource, "inspect", format, name],
|
|
timeout.min(Duration::from_secs(5)),
|
|
) {
|
|
Ok(output) => output,
|
|
Err("Docker named resource was not found") => return Ok(()),
|
|
Err(_) => return Err("owned resource status could not be confirmed"),
|
|
};
|
|
let id = owned_resource_id(&output, owner)
|
|
.ok_or("resource name exists without the expected ownership label")?;
|
|
|
|
let timeout = deadline
|
|
.checked_duration_since(Instant::now())
|
|
.filter(|remaining| !remaining.is_zero())
|
|
.ok_or("cleanup budget expired")?;
|
|
let removal = match kind {
|
|
ResourceKind::Container => docker_text_with_timeout(
|
|
&[&endpoint, "container", "rm", "--force", &id],
|
|
timeout.min(Duration::from_secs(5)),
|
|
),
|
|
ResourceKind::Network => docker_text_with_timeout(
|
|
&[&endpoint, "network", "rm", &id],
|
|
timeout.min(Duration::from_secs(5)),
|
|
),
|
|
};
|
|
removal
|
|
.map(|_| ())
|
|
.map_err(|_| "confirmed owned resource could not be removed")
|
|
}
|
|
|
|
fn owned_resource_id(output: &str, expected_owner: &str) -> Option<String> {
|
|
let (id, actual_owner) = output.trim().split_once(' ')?;
|
|
if actual_owner != expected_owner
|
|
|| id.len() < 12
|
|
|| id.len() > 64
|
|
|| !id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
{
|
|
return None;
|
|
}
|
|
Some(id.to_owned())
|
|
}
|
|
|
|
fn verify_daemon(selection: &Selection) -> Result<(), &'static str> {
|
|
let output = docker_text(&["--host", &selection.endpoint, "info", "--format={{.ID}}"])?;
|
|
if output.trim() != selection.daemon_id {
|
|
return Err("selected daemon ID does not match the live daemon");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_cached_image(selection: &Selection) -> Result<(), &'static str> {
|
|
let endpoint = format!("--host={}", selection.endpoint);
|
|
let image = "--format={{.Id}} {{.Os}}/{{.Architecture}}";
|
|
let output = docker_text(&[&endpoint, "image", "inspect", image, IMAGE])?;
|
|
if output.trim() != format!("{IMAGE_ID} linux/amd64") {
|
|
return Err("cached Alpine fixture image identity does not match the pin");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn start_fixture_container(
|
|
selection: &Selection,
|
|
name: &str,
|
|
network: &str,
|
|
) -> Result<OwnedContainer, &'static str> {
|
|
let owner = std::process::id().to_string();
|
|
let container = fixture_container_request(name, network, owner.clone()).start();
|
|
let container = match container {
|
|
Ok(container) => container,
|
|
Err(error) => {
|
|
let category = testcontainers_start_failure_category(&error);
|
|
match partial_start_status(find_owned_container(selection, name, &owner)) {
|
|
PartialStartStatus::ConfirmedOwned(id) => {
|
|
let endpoint = format!("--host={}", selection.endpoint);
|
|
if docker_text(&[&endpoint, "container", "rm", "--force", &id]).is_err() {
|
|
eprintln!(
|
|
"Testcontainers cleanup incomplete; owned container may remain: {name}"
|
|
);
|
|
}
|
|
}
|
|
PartialStartStatus::Uncertain => eprintln!(
|
|
"Testcontainers start failed; resource status remains uncertain after unconfirmed exact-name inspection: {name}"
|
|
),
|
|
}
|
|
return Err(category);
|
|
}
|
|
};
|
|
|
|
Ok(OwnedContainer {
|
|
name: name.to_owned(),
|
|
selection: selection.clone(),
|
|
container: Some(container),
|
|
})
|
|
}
|
|
|
|
fn fixture_container_request(
|
|
name: &str,
|
|
network: &str,
|
|
owner: String,
|
|
) -> testcontainers::ContainerRequest<GenericImage> {
|
|
GenericImage::new("wireguard-admin-alpine", "3.22.7")
|
|
.with_entrypoint("/bin/sh")
|
|
.with_cmd(["-c", "while :; do sleep 1; done"])
|
|
.with_container_name(name)
|
|
.with_network(network)
|
|
.with_cap_add("NET_ADMIN")
|
|
.with_label("wireguard-admin.m8_3_owner", owner)
|
|
.with_startup_timeout(CONTAINER_STARTUP_TIMEOUT)
|
|
}
|
|
|
|
enum PartialStartStatus {
|
|
ConfirmedOwned(String),
|
|
Uncertain,
|
|
}
|
|
|
|
fn partial_start_status(inspection: Result<Option<String>, &'static str>) -> PartialStartStatus {
|
|
match inspection {
|
|
Ok(Some(id)) => PartialStartStatus::ConfirmedOwned(id),
|
|
Ok(None) | Err(_) => PartialStartStatus::Uncertain,
|
|
}
|
|
}
|
|
|
|
fn testcontainers_start_failure_category(error: &TestcontainersError) -> &'static str {
|
|
match error {
|
|
TestcontainersError::Client(ClientError::Init(_)) => {
|
|
"Testcontainers Docker client initialization failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::CreateContainer(_)) => {
|
|
"Testcontainers container creation failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::StartContainer(_)) => {
|
|
"Testcontainers container start failed"
|
|
}
|
|
TestcontainersError::WaitContainer(WaitContainerError::StartupTimeout) => {
|
|
"Testcontainers container readiness timed out"
|
|
}
|
|
TestcontainersError::WaitContainer(_) => "Testcontainers container readiness failed",
|
|
TestcontainersError::Client(ClientError::Configuration(_)) => {
|
|
"Testcontainers Docker client configuration failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::InvalidDockerHost(_)) => {
|
|
"Testcontainers Docker host configuration failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::PullImage { .. }) => {
|
|
"Testcontainers container creation not-found image fallback failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::BuildImage { .. }) => {
|
|
"Testcontainers fixture image build failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::PortMapping(_)) => {
|
|
"Testcontainers container port mapping failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::ListContainers(_)) => {
|
|
"Testcontainers container listing failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::RemoveContainer(_)) => {
|
|
"Testcontainers container removal failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::StopContainer(_)) => {
|
|
"Testcontainers container stop failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::PauseContainer(_)) => {
|
|
"Testcontainers container pause failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::UnpauseContainer(_)) => {
|
|
"Testcontainers container resume failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::InspectContainer(_)) => {
|
|
"Testcontainers container inspection failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::CreateNetwork(_)) => {
|
|
"Testcontainers network creation failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::InspectNetwork(_)) => {
|
|
"Testcontainers network inspection failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::ListNetworks(_)) => {
|
|
"Testcontainers network listing failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::RemoveNetwork(_)) => {
|
|
"Testcontainers network removal failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::InitExec(_)) => {
|
|
"Testcontainers exec initialization failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::InspectExec(_)) => {
|
|
"Testcontainers exec inspection failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::UploadToContainerError(_)) => {
|
|
"Testcontainers fixture upload failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::CopyToContainerError(_)) => {
|
|
"Testcontainers fixture preparation failed"
|
|
}
|
|
TestcontainersError::Client(ClientError::CopyFromContainerError(_)) => {
|
|
"Testcontainers fixture download failed"
|
|
}
|
|
TestcontainersError::PortNotExposed { .. } => {
|
|
"Testcontainers container configuration failed"
|
|
}
|
|
TestcontainersError::MissingInfo(_) => "Testcontainers container information unavailable",
|
|
TestcontainersError::Exec(_) => "Testcontainers container execution failed",
|
|
TestcontainersError::Io(_) => "Testcontainers host I/O failed",
|
|
TestcontainersError::Other(_) => "Testcontainers container startup failed",
|
|
}
|
|
}
|
|
|
|
fn find_owned_container(
|
|
selection: &Selection,
|
|
name: &str,
|
|
expected_owner: &str,
|
|
) -> Result<Option<String>, &'static str> {
|
|
let endpoint = format!("--host={}", selection.endpoint);
|
|
let format = "--format={{.Id}} {{index .Config.Labels \"wireguard-admin.m8_3_owner\"}}";
|
|
let output = docker_text(&[&endpoint, "container", "inspect", format, name])?;
|
|
let Some((id, owner)) = output.trim().split_once(' ') else {
|
|
return Ok(None);
|
|
};
|
|
if owner != expected_owner
|
|
|| id.len() < 12
|
|
|| id.len() > 64
|
|
|| !id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
{
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(id.to_owned()))
|
|
}
|
|
|
|
fn verify_distinct_namespaces(
|
|
server: &OwnedContainer,
|
|
client: &OwnedContainer,
|
|
) -> Result<(), &'static str> {
|
|
let server_namespace = exec_small(server, ["readlink", "/proc/self/ns/net"])?;
|
|
let client_namespace = exec_small(client, ["readlink", "/proc/self/ns/net"])?;
|
|
if server_namespace.is_empty() || server_namespace == client_namespace {
|
|
return Err("server and client do not have distinct network namespaces");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn verify_wireguard_and_ipv4(container: &OwnedContainer) -> Result<(), &'static str> {
|
|
exec_success(
|
|
container,
|
|
["ip", "link", "add", "wg-m83-probe", "type", "wireguard"],
|
|
)?;
|
|
exec_success(
|
|
container,
|
|
["ip", "-4", "addr", "add", "192.0.2.1/32", "dev", "lo"],
|
|
)?;
|
|
const ROUTE: &str = "198.51.100.0/24";
|
|
exec_success(container, ["ip", "-4", "route", "add", "blackhole", ROUTE])?;
|
|
let routes = exec_small(container, ["ip", "-4", "route", "show", "exact", ROUTE])?;
|
|
if !ipv4_blackhole_route_present(&routes, ROUTE) {
|
|
return Err("IPv4 route mutation was not visible in the fixture namespace");
|
|
}
|
|
exec_success(container, ["ip", "-4", "route", "del", "blackhole", ROUTE])?;
|
|
let routes = exec_small(container, ["ip", "-4", "route", "show", "exact", ROUTE])?;
|
|
if !ipv4_route_absent(&routes, ROUTE) {
|
|
return Err("IPv4 fixture route remained after deletion");
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn ipv4_blackhole_route_present(routes: &str, prefix: &str) -> bool {
|
|
let expected = format!("blackhole {prefix}");
|
|
routes.lines().any(|line| line.starts_with(&expected))
|
|
}
|
|
|
|
fn ipv4_route_absent(routes: &str, prefix: &str) -> bool {
|
|
!routes
|
|
.lines()
|
|
.any(|line| line.split_whitespace().any(|part| part == prefix))
|
|
}
|
|
|
|
fn exec_success<const N: usize>(
|
|
container: &OwnedContainer,
|
|
arguments: [&str; N],
|
|
) -> Result<(), &'static str> {
|
|
exec_bounded(
|
|
container,
|
|
&arguments,
|
|
COMMAND_STDOUT_LIMIT,
|
|
COMMAND_STDERR_LIMIT,
|
|
"WireGuard or IPv4 capability probe failed",
|
|
)
|
|
.map(|_| ())
|
|
}
|
|
|
|
fn exec_small<const N: usize>(
|
|
container: &OwnedContainer,
|
|
arguments: [&str; N],
|
|
) -> Result<String, &'static str> {
|
|
let output = exec_bounded(
|
|
container,
|
|
&arguments,
|
|
NAMESPACE_STDOUT_LIMIT,
|
|
COMMAND_STDERR_LIMIT,
|
|
"Testcontainers namespace probe returned nonzero status",
|
|
)?;
|
|
String::from_utf8(output)
|
|
.map(|value| value.trim().to_owned())
|
|
.map_err(|_| "Testcontainers namespace output was invalid")
|
|
}
|
|
|
|
fn bounded_command<'a>(arguments: &[&'a str]) -> Vec<&'a str> {
|
|
let mut bounded = vec![
|
|
"/bin/busybox",
|
|
"timeout",
|
|
"-s",
|
|
"KILL",
|
|
FIXTURE_COMMAND_TIMEOUT_SECONDS,
|
|
];
|
|
bounded.extend_from_slice(arguments);
|
|
bounded
|
|
}
|
|
|
|
fn exec_bounded<const N: usize>(
|
|
container: &OwnedContainer,
|
|
arguments: &[&str; N],
|
|
stdout_limit: usize,
|
|
stderr_limit: usize,
|
|
failure_category: &'static str,
|
|
) -> Result<Vec<u8>, &'static str> {
|
|
let endpoint = format!("--host={}", container.selection.endpoint);
|
|
let mut command = vec![endpoint.as_str(), "exec", container.container().id()];
|
|
command.extend(bounded_command(arguments));
|
|
match docker_text_with_timeout_and_limits(
|
|
&command,
|
|
FIXTURE_TRANSPORT_TIMEOUT,
|
|
stdout_limit,
|
|
stderr_limit,
|
|
) {
|
|
Ok(stdout) => Ok(stdout.into_bytes()),
|
|
Err("Docker command timed out") => {
|
|
eprintln!(
|
|
"Docker exec timed out; command completion inside the owned container is uncertain: {}",
|
|
container.name
|
|
);
|
|
Err("Fixture command timed out; completion inside the owned container is uncertain")
|
|
}
|
|
Err("In-container fixture command timed out") => {
|
|
Err("In-container fixture command timed out")
|
|
}
|
|
Err("Docker command output exceeded its limit") => {
|
|
Err("Fixture command output exceeded its limit")
|
|
}
|
|
Err("Docker command returned a nonzero status") => Err(failure_category),
|
|
Err(_) => Err(failure_category),
|
|
}
|
|
}
|
|
|
|
fn find_owned_network(
|
|
selection: &Selection,
|
|
name: &str,
|
|
expected_owner: &str,
|
|
) -> Result<Option<String>, &'static str> {
|
|
let endpoint = format!("--host={}", selection.endpoint);
|
|
let format = "--format={{.Id}} {{index .Labels \"wireguard-admin.m8_3_owner\"}}";
|
|
let output = docker_text(&[&endpoint, "network", "inspect", format, name])?;
|
|
let Some((id, owner)) = output.trim().split_once(' ') else {
|
|
return Ok(None);
|
|
};
|
|
if owner != expected_owner
|
|
|| id.len() < 12
|
|
|| id.len() > 64
|
|
|| !id.bytes().all(|byte| byte.is_ascii_hexdigit())
|
|
{
|
|
return Ok(None);
|
|
}
|
|
Ok(Some(id.to_owned()))
|
|
}
|
|
|
|
struct OwnedNetwork {
|
|
selection: Selection,
|
|
name: String,
|
|
id: String,
|
|
removed: bool,
|
|
}
|
|
|
|
struct OwnedContainer {
|
|
name: String,
|
|
selection: Selection,
|
|
container: Option<testcontainers::Container<GenericImage>>,
|
|
}
|
|
|
|
impl OwnedContainer {
|
|
fn container(&self) -> &testcontainers::Container<GenericImage> {
|
|
self.container
|
|
.as_ref()
|
|
.expect("owned fixture container is present before cleanup")
|
|
}
|
|
|
|
fn remove(mut self) -> Result<(), &'static str> {
|
|
if let Some(container) = self.container.take()
|
|
&& container.rm().is_err()
|
|
{
|
|
eprintln!(
|
|
"Testcontainers cleanup incomplete; owned container may remain: {}",
|
|
self.name
|
|
);
|
|
return Err("Testcontainers could not remove an owned fixture container");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for OwnedContainer {
|
|
fn drop(&mut self) {
|
|
if let Some(container) = self.container.take()
|
|
&& container.rm().is_err()
|
|
{
|
|
eprintln!(
|
|
"Testcontainers cleanup incomplete; owned container may remain: {}",
|
|
self.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
impl OwnedNetwork {
|
|
fn create(selection: &Selection, name: String) -> Result<Self, &'static str> {
|
|
let owner = format!("wireguard-admin.m8_3_owner={}", std::process::id());
|
|
let create_result = docker_text(&[
|
|
"--host",
|
|
&selection.endpoint,
|
|
"network",
|
|
"create",
|
|
"--driver",
|
|
"bridge",
|
|
"--internal",
|
|
"--subnet",
|
|
NETWORK_IPV4,
|
|
"--label",
|
|
&owner,
|
|
&name,
|
|
]);
|
|
let id = match create_result {
|
|
Ok(id) => id,
|
|
Err("Docker command timed out") => {
|
|
match find_owned_network(selection, &name, &std::process::id().to_string()) {
|
|
Ok(Some(id)) => id,
|
|
Ok(None) => {
|
|
eprintln!(
|
|
"Docker network creation timed out; exact resource status is unconfirmed: {name}"
|
|
);
|
|
return Err("Docker network creation timed out without an owned network");
|
|
}
|
|
Err(_) => {
|
|
eprintln!(
|
|
"Docker network creation timed out; exact resource status is unknown: {name}"
|
|
);
|
|
return Err("Docker network creation timed out; cleanup status is unknown");
|
|
}
|
|
}
|
|
}
|
|
Err(error) => return Err(error),
|
|
};
|
|
let id = id.trim();
|
|
if id.len() < 12 || id.len() > 64 || !id.bytes().all(|byte| byte.is_ascii_hexdigit()) {
|
|
return Err("Docker returned an invalid ID for the owned test network");
|
|
}
|
|
Ok(Self {
|
|
selection: selection.clone(),
|
|
name,
|
|
id: id.to_owned(),
|
|
removed: false,
|
|
})
|
|
}
|
|
|
|
fn remove(mut self) -> Result<(), &'static str> {
|
|
self.remove_inner()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn remove_inner(&mut self) -> Result<(), &'static str> {
|
|
if self.removed {
|
|
return Ok(());
|
|
}
|
|
let endpoint = format!("--host={}", self.selection.endpoint);
|
|
docker_text(&[&endpoint, "network", "rm", &self.id])?;
|
|
self.removed = true;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
impl Drop for OwnedNetwork {
|
|
fn drop(&mut self) {
|
|
if !self.removed && self.remove_inner().is_err() {
|
|
eprintln!(
|
|
"Testcontainers cleanup incomplete; owned network may remain: {}",
|
|
self.name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
IMAGE, MAX_PHASE_REQUEST_BYTES, PartialStartStatus, ProbeNames, bounded_command,
|
|
client_connection_material, docker_exchange_report, driver_phase_failure_category,
|
|
driver_ran_exactly_one_test, endpoint_host_route, fixture_container_request,
|
|
ipv4_blackhole_route_present, ipv4_route_absent, missing_driver_phase_category,
|
|
owned_resource_id, parse_ping_summary, partial_start_status, peer_counters,
|
|
peer_has_exact_ipv4_routes, phase_archive_name, require_zero_ping_replies,
|
|
testcontainers_start_failure_category,
|
|
};
|
|
use std::io;
|
|
use std::time::Duration;
|
|
use testcontainers::{
|
|
TestcontainersError,
|
|
core::error::{ClientError, WaitContainerError},
|
|
};
|
|
|
|
#[test]
|
|
fn successful_driver_report_requires_restore_and_address_lifecycle_evidence() {
|
|
let report = "test container_driver::alpine_scenario_driver ... phase=fixture_bootstrap status=ok\nphase=create status=ok public_key=public\nphase=commit status=ok\nphase=delivery status=ok activation_warning=true\nphase=delete status=saved current_record=removed backup_record=retained address_reserved=true\nphase=restore status=confirmed precautionary_backup=pre-restore target_client_present=true pending_address_reserved=false\nphase=restore status=confirmed precautionary_backup=pre-restore target_client_present=false pending_address_reserved=true\nphase=fixture_apply status=verified address_released=true\nphase=end status=ok\n";
|
|
assert_eq!(missing_driver_phase_category(report), None);
|
|
}
|
|
|
|
#[test]
|
|
fn phase_archive_name_skips_started_commit_and_delete_lines() {
|
|
let report = "phase=commit status=started\nphase=commit status=ok backup_name=commit-123.tar\nphase=delete status=started backup_name=wrong.tar\nphase=delete status=saved backup_name=delete-456.tar";
|
|
|
|
assert_eq!(phase_archive_name(report, "commit"), Ok("commit-123.tar"));
|
|
assert_eq!(phase_archive_name(report, "delete"), Ok("delete-456.tar"));
|
|
}
|
|
|
|
#[test]
|
|
fn phase_archive_name_rejects_missing_or_malformed_completed_archive() {
|
|
assert_eq!(
|
|
phase_archive_name(
|
|
"phase=commit status=started backup_name=commit-123.tar",
|
|
"commit"
|
|
),
|
|
Err("Application phase omitted its archive identity")
|
|
);
|
|
assert_eq!(
|
|
phase_archive_name("phase=delete status=saved", "delete"),
|
|
Err("Application phase omitted its archive identity")
|
|
);
|
|
assert_eq!(
|
|
phase_archive_name("phase=commit status=ok backup_name=../escape.tar", "commit"),
|
|
Err("Application phase archive identity was invalid")
|
|
);
|
|
assert_eq!(
|
|
phase_archive_name(
|
|
"phase=delete status=saved backup_name=first.tar backup_name=second.tar",
|
|
"delete"
|
|
),
|
|
Err("Application phase archive identity was invalid")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn driver_report_rejects_zero_test_exact_filter_mismatch() {
|
|
assert!(!driver_ran_exactly_one_test(
|
|
"running 0 tests\ntest result: ok. 0 passed; 0 failed"
|
|
));
|
|
assert!(driver_ran_exactly_one_test(
|
|
"running 1 test\ntest container_driver::alpine_scenario_driver ... phase=fixture_bootstrap"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn host_phase_transport_rejects_oversized_request_before_starting_docker() {
|
|
let result = docker_exchange_report(
|
|
&["--version"],
|
|
&vec![b'x'; MAX_PHASE_REQUEST_BYTES + 1],
|
|
Duration::from_secs(1),
|
|
16,
|
|
16,
|
|
);
|
|
assert_eq!(result, Err("fixture phase request exceeded its limit"));
|
|
}
|
|
|
|
#[test]
|
|
fn delivered_config_material_keeps_wireguard_fields_and_ipv4_only_setup() {
|
|
let bytes = b"[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.2/32, fd20::2/128\nDNS = 10.20.0.1\n\n[Peer]\nPublicKey = synthetic-server\nAllowedIPs = 0.0.0.0/0, ::/0\nEndpoint = 192.168.253.2:51820\n";
|
|
let (address, endpoint) =
|
|
client_connection_material(bytes).expect("synthetic delivery should parse");
|
|
assert_eq!(address, "10.20.0.2/32");
|
|
assert_eq!(endpoint, "192.168.253.2:51820");
|
|
}
|
|
|
|
#[test]
|
|
fn delivered_config_rejects_extra_ipv4_full_tunnel_prefixes() {
|
|
let bytes = b"[Interface]\nPrivateKey = synthetic\nAddress = 10.20.0.2/32\n\n[Peer]\nPublicKey = server\nAllowedIPs = 0.0.0.0/0, 10.20.0.0/24, ::/0\nEndpoint = 192.168.253.2:51820\n";
|
|
assert_eq!(
|
|
client_connection_material(bytes),
|
|
Err("delivered client file did not contain the expected IPv4 full-tunnel route")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn observed_peer_route_check_rejects_extra_ipv4_prefixes_but_ignores_ipv6() {
|
|
assert!(peer_has_exact_ipv4_routes(
|
|
"peer=key routes=10.20.0.10/32,fd20::10/128",
|
|
"10.20.0.10/32"
|
|
));
|
|
assert!(!peer_has_exact_ipv4_routes(
|
|
"peer=key routes=10.20.0.10/32,10.20.0.0/24,fd20::10/128",
|
|
"10.20.0.10/32"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn delivered_config_material_rejects_missing_ipv4_full_tunnel_route() {
|
|
let bytes = b"[Interface]\nPrivateKey = synthetic\nAddress = fd20::2/128\n\n[Peer]\nPublicKey = server\nAllowedIPs = ::/0\nEndpoint = 192.168.253.2:51820\n";
|
|
assert_eq!(
|
|
client_connection_material(bytes),
|
|
Err("delivered client file did not contain the expected IPv4 full-tunnel route")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn server_endpoint_keeps_a_host_route_on_the_underlay() {
|
|
assert_eq!(
|
|
endpoint_host_route("192.168.253.4:51820"),
|
|
Ok("192.168.253.4/32".to_owned())
|
|
);
|
|
assert_eq!(
|
|
endpoint_host_route("vpn.example.test:51820"),
|
|
Err("delivered client endpoint was not IPv4")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reader_counter_parser_requires_both_numeric_directions() {
|
|
assert_eq!(
|
|
peer_counters("peer=public received=12 transmitted=34"),
|
|
Some((12, 34))
|
|
);
|
|
assert_eq!(
|
|
peer_counters("peer=public received=unavailable transmitted=34"),
|
|
None
|
|
);
|
|
assert_eq!(
|
|
peer_counters("peer=public received=12 transmitted=unavailable"),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn baseline_ping_summary_parses_busybox_packet_counts() {
|
|
assert_eq!(
|
|
parse_ping_summary("3 packets transmitted, 2 packets received, 33% packet loss"),
|
|
Ok((3, 2))
|
|
);
|
|
assert_eq!(
|
|
parse_ping_summary("3 packets transmitted, 0 packets received, 100% packet loss"),
|
|
Ok((3, 0))
|
|
);
|
|
assert_eq!(
|
|
parse_ping_summary("packets transmitted, 0 packets received, 100% packet loss"),
|
|
Err("Baseline ping summary was malformed")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn negative_reachability_requires_three_transmits_and_zero_replies() {
|
|
assert_eq!(require_zero_ping_replies(Ok((3, 0))), Ok(()));
|
|
assert_eq!(
|
|
require_zero_ping_replies(Ok((3, 1))),
|
|
Err("client still reached the tunnel during a negative reachability probe")
|
|
);
|
|
assert_eq!(
|
|
require_zero_ping_replies(Ok((0, 0))),
|
|
Err("negative reachability probe did not transmit three packets")
|
|
);
|
|
assert_eq!(
|
|
require_zero_ping_replies(Err("Baseline reachability probe failed")),
|
|
Err("Baseline reachability probe failed")
|
|
);
|
|
assert_eq!(
|
|
require_zero_ping_replies(Err("In-container fixture command timed out")),
|
|
Err("In-container fixture command timed out")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn failed_driver_report_maps_only_the_last_typed_phase() {
|
|
assert_eq!(
|
|
driver_phase_failure_category(
|
|
"test container_driver::alpine_scenario_driver ... phase=fixture_bootstrap status=ok\nphase=create status=ok public_key=synthetic\nphase=commit status=started\n"
|
|
),
|
|
"Application fixture Commit phase failed"
|
|
);
|
|
assert_eq!(
|
|
driver_phase_failure_category("secret-like malformed output"),
|
|
"Application fixture bootstrap failed"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn generated_resource_names_are_unique_and_scoped() {
|
|
let first = ProbeNames::unique();
|
|
let second = ProbeNames::unique();
|
|
assert_ne!(first.network, second.network);
|
|
assert!(first.network.starts_with("wga-m83-net-"));
|
|
assert!(first.server.starts_with("wga-m83-server-"));
|
|
assert!(first.baseline.starts_with("wga-m85-baseline-"));
|
|
assert!(first.client.starts_with("wga-m83-client-"));
|
|
assert_ne!(first.baseline, first.client);
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_request_keeps_cached_image_and_sets_owned_unique_container_name() {
|
|
let first_name = "wga-m83-server-test-a";
|
|
let second_name = "wga-m83-client-test-b";
|
|
let first = fixture_container_request(first_name, "wga-m83-net-test", "owner-a".into());
|
|
let second = fixture_container_request(second_name, "wga-m83-net-test", "owner-b".into());
|
|
|
|
assert_eq!(first.descriptor(), IMAGE);
|
|
assert_eq!(second.descriptor(), IMAGE);
|
|
assert_eq!(first.container_name().as_deref(), Some(first_name));
|
|
assert_eq!(second.container_name().as_deref(), Some(second_name));
|
|
assert_ne!(
|
|
first.container_name().as_deref(),
|
|
second.container_name().as_deref()
|
|
);
|
|
assert_eq!(
|
|
first
|
|
.labels()
|
|
.get("wireguard-admin.m8_3_owner")
|
|
.map(String::as_str),
|
|
Some("owner-a")
|
|
);
|
|
assert_eq!(
|
|
second
|
|
.labels()
|
|
.get("wireguard-admin.m8_3_owner")
|
|
.map(String::as_str),
|
|
Some("owner-b")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cleanup_requires_the_exact_run_owner_label_and_a_valid_id() {
|
|
assert_eq!(
|
|
owned_resource_id("0123456789abcdef owner-7", "owner-7"),
|
|
Some("0123456789abcdef".to_owned())
|
|
);
|
|
assert_eq!(
|
|
owned_resource_id("0123456789abcdef owner-8", "owner-7"),
|
|
None
|
|
);
|
|
assert_eq!(owned_resource_id("not-an-id owner-7", "owner-7"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn absent_or_uninspectable_partial_start_remains_uncertain() {
|
|
assert!(matches!(
|
|
partial_start_status(Ok(None)),
|
|
PartialStartStatus::Uncertain
|
|
));
|
|
assert!(matches!(
|
|
partial_start_status(Err("Docker named resource was not found")),
|
|
PartialStartStatus::Uncertain
|
|
));
|
|
assert!(matches!(
|
|
partial_start_status(Ok(Some("0123456789abcdef".to_owned()))),
|
|
PartialStartStatus::ConfirmedOwned(_)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn fixture_commands_use_busybox_timeout_and_escalate_to_kill() {
|
|
assert_eq!(
|
|
bounded_command(&["ip", "-4", "route", "show"]),
|
|
[
|
|
"/bin/busybox",
|
|
"timeout",
|
|
"-s",
|
|
"KILL",
|
|
"15",
|
|
"ip",
|
|
"-4",
|
|
"route",
|
|
"show"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn testcontainers_start_failures_map_to_fixed_redacted_categories() {
|
|
let client_init = TestcontainersError::Client(ClientError::Init(
|
|
io::Error::other("secret docker client detail").into(),
|
|
));
|
|
let create = TestcontainersError::Client(ClientError::CreateContainer(
|
|
io::Error::other("secret create detail").into(),
|
|
));
|
|
let start = TestcontainersError::Client(ClientError::StartContainer(
|
|
io::Error::other("secret start detail").into(),
|
|
));
|
|
let timeout = TestcontainersError::WaitContainer(WaitContainerError::StartupTimeout);
|
|
let readiness = TestcontainersError::WaitContainer(WaitContainerError::StateUnavailable);
|
|
let invalid_host = TestcontainersError::Client(ClientError::InvalidDockerHost(
|
|
"tcp://secret.example.invalid".to_owned(),
|
|
));
|
|
let host_io = TestcontainersError::Io(io::Error::other("private socket path"));
|
|
let other = TestcontainersError::other("secret-bearing daemon response");
|
|
let image_fallback = TestcontainersError::Client(ClientError::PullImage {
|
|
descriptor: "wireguard-admin-alpine:3.22.7".to_owned(),
|
|
err: io::Error::other("synthetic create-not-found fallback detail").into(),
|
|
});
|
|
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&client_init),
|
|
"Testcontainers Docker client initialization failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&create),
|
|
"Testcontainers container creation failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&start),
|
|
"Testcontainers container start failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&timeout),
|
|
"Testcontainers container readiness timed out"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&readiness),
|
|
"Testcontainers container readiness failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&invalid_host),
|
|
"Testcontainers Docker host configuration failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&host_io),
|
|
"Testcontainers host I/O failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&other),
|
|
"Testcontainers container startup failed"
|
|
);
|
|
assert_eq!(
|
|
testcontainers_start_failure_category(&image_fallback),
|
|
"Testcontainers container creation not-found image fallback failed"
|
|
);
|
|
let categories = [
|
|
testcontainers_start_failure_category(&client_init),
|
|
testcontainers_start_failure_category(&create),
|
|
testcontainers_start_failure_category(&start),
|
|
testcontainers_start_failure_category(&timeout),
|
|
testcontainers_start_failure_category(&readiness),
|
|
testcontainers_start_failure_category(&invalid_host),
|
|
testcontainers_start_failure_category(&host_io),
|
|
testcontainers_start_failure_category(&other),
|
|
testcontainers_start_failure_category(&image_fallback),
|
|
];
|
|
assert!(
|
|
categories
|
|
.iter()
|
|
.all(|category| !category.contains("secret"))
|
|
);
|
|
assert!(
|
|
categories
|
|
.iter()
|
|
.all(|category| !category.contains("private"))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn route_probe_mutates_then_observes_and_removes_ipv4_route() {
|
|
assert!(ipv4_blackhole_route_present(
|
|
"blackhole 198.51.100.0/24 proto static\n",
|
|
"198.51.100.0/24"
|
|
));
|
|
assert!(!ipv4_blackhole_route_present(
|
|
"192.0.2.0/24 dev lo\n",
|
|
"198.51.100.0/24"
|
|
));
|
|
assert!(ipv4_route_absent(
|
|
"192.0.2.0/24 dev lo\n",
|
|
"198.51.100.0/24"
|
|
));
|
|
assert!(!ipv4_route_absent(
|
|
"blackhole 198.51.100.0/24 proto static\n",
|
|
"198.51.100.0/24"
|
|
));
|
|
}
|
|
}
|