261 lines
9.0 KiB
Rust
261 lines
9.0 KiB
Rust
//! End-to-end deploy/dial test (§7.3.6 — peer-to-peer transfer).
|
|
//!
|
|
//! Boots two `levcs` processes on loopback TCP — one runs `deploy`,
|
|
//! the other runs `dial` — and confirms:
|
|
//! 1. The handshake authenticates both sides against expected keys.
|
|
//! 2. The transferred archive reconstructs the same repo_id, branches,
|
|
//! and tree contents on the dialer side.
|
|
//! 3. The dialer rejects an impostor sender that signs with the wrong
|
|
//! Ed25519 key, even on the right host:port.
|
|
//! 4. The deployer refuses a dialer that authenticates with the wrong
|
|
//! key.
|
|
|
|
use std::io::{BufRead, BufReader};
|
|
use std::net::TcpListener;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Stdio};
|
|
use std::time::{Duration, Instant};
|
|
|
|
fn levcs_bin() -> String {
|
|
env!("CARGO_BIN_EXE_levcs").to_string()
|
|
}
|
|
|
|
fn run(args: &[&str], cwd: &Path, xdg: &Path) -> (i32, String, String) {
|
|
let out = Command::new(levcs_bin())
|
|
.args(args)
|
|
.current_dir(cwd)
|
|
.env("XDG_CONFIG_HOME", xdg)
|
|
.output()
|
|
.expect("run levcs");
|
|
(
|
|
out.status.code().unwrap_or(-1),
|
|
String::from_utf8_lossy(&out.stdout).to_string(),
|
|
String::from_utf8_lossy(&out.stderr).to_string(),
|
|
)
|
|
}
|
|
|
|
fn tempdir(prefix: &str) -> PathBuf {
|
|
let mut p = std::env::temp_dir();
|
|
let n = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_nanos())
|
|
.unwrap_or(0);
|
|
p.push(format!("{prefix}-{n}-{}", std::process::id()));
|
|
std::fs::create_dir_all(&p).unwrap();
|
|
p
|
|
}
|
|
|
|
fn pick_free_port() -> u16 {
|
|
let l = TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let port = l.local_addr().unwrap().port();
|
|
drop(l);
|
|
port
|
|
}
|
|
|
|
/// Read the public key for `label` from the given keychain. We invoke
|
|
/// `levcs key show` so the test exercises the same code path the user
|
|
/// would, and we do not have to crack open the keychain file format.
|
|
fn read_pub(label: &str, cwd: &Path, xdg: &Path) -> String {
|
|
let (code, out, err) = run(&["key", "show", label], cwd, xdg);
|
|
assert_eq!(code, 0, "key show {label}: {err}");
|
|
out.trim().to_string()
|
|
}
|
|
|
|
/// Create a source repository with one commit and return its path.
|
|
fn make_source_repo(name: &str, key_label: &str, xdg: &Path) -> PathBuf {
|
|
let src = tempdir(name);
|
|
let (code, _, e) = run(&["init", "--key", key_label], &src, xdg);
|
|
assert_eq!(code, 0, "init: {e}");
|
|
std::fs::write(src.join("README"), b"deploy/dial source content\n").unwrap();
|
|
std::fs::write(src.join("notes.md"), b"# notes\nan example\n").unwrap();
|
|
let (code, _, e) = run(&["track", "--all"], &src, xdg);
|
|
assert_eq!(code, 0, "track: {e}");
|
|
let (code, _, e) = run(&["commit", "-m", "initial"], &src, xdg);
|
|
assert_eq!(code, 0, "commit: {e}");
|
|
src
|
|
}
|
|
|
|
/// Spawn `levcs deploy` and wait for its first stderr line so we know
|
|
/// the listener is up before the dialer connects.
|
|
fn spawn_deployer(
|
|
repo: &Path,
|
|
xdg: &Path,
|
|
deployer_label: &str,
|
|
recipient_pub: &str,
|
|
listen: &str,
|
|
) -> std::process::Child {
|
|
let mut cmd = Command::new(levcs_bin());
|
|
cmd.args(&[
|
|
"deploy",
|
|
recipient_pub,
|
|
"--key",
|
|
deployer_label,
|
|
"--listen",
|
|
listen,
|
|
])
|
|
.current_dir(repo)
|
|
.env("XDG_CONFIG_HOME", xdg)
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped());
|
|
let mut child = cmd.spawn().expect("spawn deploy");
|
|
// Wait for the listener to print its first line — that tells us the
|
|
// bind succeeded so the dialer won't race the listener.
|
|
let stderr = child.stderr.take().expect("stderr");
|
|
let mut reader = BufReader::new(stderr);
|
|
let mut line = String::new();
|
|
let deadline = Instant::now() + Duration::from_secs(10);
|
|
while Instant::now() < deadline {
|
|
line.clear();
|
|
if reader.read_line(&mut line).unwrap_or(0) == 0 {
|
|
std::thread::sleep(Duration::from_millis(50));
|
|
continue;
|
|
}
|
|
if line.contains("listening on") {
|
|
// Re-attach the rest of stderr by spawning a drain thread
|
|
// so the child's stderr buffer never fills up.
|
|
std::thread::spawn(move || {
|
|
let mut sink = String::new();
|
|
let _ = reader.read_to_string(&mut sink);
|
|
});
|
|
return child;
|
|
}
|
|
}
|
|
panic!("deployer never reported `listening on` (last line: {line:?})");
|
|
}
|
|
|
|
use std::io::Read;
|
|
|
|
#[test]
|
|
fn deploy_and_dial_round_trip() {
|
|
let xdg = tempdir("levcs-p2p-xdg");
|
|
|
|
// Sender (alice) creates a repo with content. Init produces alice's
|
|
// key in the shared XDG keychain.
|
|
let source = make_source_repo("levcs-p2p-source", "alice", &xdg);
|
|
|
|
// Recipient (bob) gets a separate key in the same keychain.
|
|
let recv_parent = tempdir("levcs-p2p-recv-parent");
|
|
let (code, _, e) = run(&["key", "generate", "bob"], &recv_parent, &xdg);
|
|
assert_eq!(code, 0, "key generate bob: {e}");
|
|
|
|
let alice_pub = read_pub("alice", &source, &xdg);
|
|
let bob_pub = read_pub("bob", &recv_parent, &xdg);
|
|
|
|
// Sender starts listening, gated on bob's key.
|
|
let port = pick_free_port();
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let deployer = spawn_deployer(&source, &xdg, "alice", &bob_pub, &listen);
|
|
|
|
// Recipient dials in.
|
|
let dest = recv_parent.join("dialed");
|
|
let (code, _, e) = run(
|
|
&[
|
|
"dial",
|
|
&listen,
|
|
&alice_pub,
|
|
"--key",
|
|
"bob",
|
|
dest.to_str().unwrap(),
|
|
],
|
|
&recv_parent,
|
|
&xdg,
|
|
);
|
|
assert_eq!(code, 0, "dial: {e}");
|
|
|
|
let _ = deployer.wait_with_output();
|
|
|
|
// Verify: dialed repo exists and content matches.
|
|
assert!(dest.is_dir(), "dialed dest not created");
|
|
assert_eq!(
|
|
std::fs::read_to_string(dest.join("README")).unwrap(),
|
|
"deploy/dial source content\n"
|
|
);
|
|
assert_eq!(
|
|
std::fs::read_to_string(dest.join("notes.md")).unwrap(),
|
|
"# notes\nan example\n"
|
|
);
|
|
|
|
// repo_id must match the source — deploy/dial preserves identity
|
|
// exactly (per §5.7 same-repo movement, this is the no-rewrite path).
|
|
let src_repo_id = std::fs::read_to_string(source.join(".levcs/refs/authority/genesis"))
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
let dst_repo_id = std::fs::read_to_string(dest.join(".levcs/refs/authority/genesis"))
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(src_repo_id, dst_repo_id, "genesis authority must match");
|
|
|
|
// Branch tip must match.
|
|
let src_main = std::fs::read_to_string(source.join(".levcs/refs/branches/main"))
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
let dst_main = std::fs::read_to_string(dest.join(".levcs/refs/branches/main"))
|
|
.unwrap()
|
|
.trim()
|
|
.to_string();
|
|
assert_eq!(src_main, dst_main, "branch main tip must match");
|
|
|
|
// verify on the dialed repo passes — every signature and the full
|
|
// authority chain reconstruct correctly from what we received.
|
|
let (code, _, e) = run(&["verify"], &dest, &xdg);
|
|
assert_eq!(code, 0, "verify on dialed repo: {e}");
|
|
|
|
// Cleanup.
|
|
let _ = std::fs::remove_dir_all(&source);
|
|
let _ = std::fs::remove_dir_all(&recv_parent);
|
|
let _ = std::fs::remove_dir_all(&xdg);
|
|
}
|
|
|
|
#[test]
|
|
fn dial_rejects_wrong_sender_key() {
|
|
let xdg = tempdir("levcs-p2p-xdg-mismatch");
|
|
let source = make_source_repo("levcs-p2p-src-mismatch", "alice", &xdg);
|
|
let recv_parent = tempdir("levcs-p2p-recv-mismatch");
|
|
let (code, _, e) = run(&["key", "generate", "bob"], &recv_parent, &xdg);
|
|
assert_eq!(code, 0, "key generate bob: {e}");
|
|
// A third unrelated key — used as the *expected* sender on the dial
|
|
// side, even though alice is who's actually deploying.
|
|
let (code, _, e) = run(&["key", "generate", "mallory"], &recv_parent, &xdg);
|
|
assert_eq!(code, 0, "key generate mallory: {e}");
|
|
|
|
let bob_pub = read_pub("bob", &recv_parent, &xdg);
|
|
let mallory_pub = read_pub("mallory", &recv_parent, &xdg);
|
|
|
|
let port = pick_free_port();
|
|
let listen = format!("127.0.0.1:{port}");
|
|
let deployer = spawn_deployer(&source, &xdg, "alice", &bob_pub, &listen);
|
|
|
|
// Dial expecting `mallory` — alice's signature should not verify
|
|
// under mallory's key, so dial must abort with a non-zero exit.
|
|
let dest = recv_parent.join("dialed-bad");
|
|
let (code, _, err) = run(
|
|
&[
|
|
"dial",
|
|
&listen,
|
|
&mallory_pub,
|
|
"--key",
|
|
"bob",
|
|
dest.to_str().unwrap(),
|
|
],
|
|
&recv_parent,
|
|
&xdg,
|
|
);
|
|
assert_ne!(code, 0, "dial should have failed but didn't");
|
|
assert!(
|
|
err.contains("KeyMismatch")
|
|
|| err.contains("unexpected public key")
|
|
|| err.contains("BadSignature")
|
|
|| err.to_lowercase().contains("handshake"),
|
|
"expected handshake error in stderr, got: {err}"
|
|
);
|
|
assert!(!dest.exists(), "no repo should have been written");
|
|
let _ = deployer.wait_with_output();
|
|
|
|
let _ = std::fs::remove_dir_all(&source);
|
|
let _ = std::fs::remove_dir_all(&recv_parent);
|
|
let _ = std::fs::remove_dir_all(&xdg);
|
|
}
|