wsb 0.251.6311

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! Where a command line dials, given where the daemon is listening.

use super::*;

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

fn rendezvous(listening: IpAddr, port: u16) -> crate::daemon::MachineRendezvous {
    crate::daemon::MachineRendezvous {
        version: crate::get_version().to_string(),
        pid: 4242,
        http_address: listening,
        http_port: port,
        machine_id: "k7f3q9zx2m".to_string(),
        machine_name: "desktop".to_string(),
        peer_address: std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED),
        peer_port: 7420,
        started_at: 1_700_000_000,
        control_token: String::new(),
    }
}

/// TEST9899: a daemon on every interface is reached over loopback.
///
/// `0.0.0.0` is where a socket LISTENS, not an address anything connects to.
/// Dialling it is a different thing on every platform and a wrong thing on
/// most of them, and the daemon answers on loopback like everywhere else.
#[test]
fn test9899_an_unspecified_address_is_dialled_on_loopback() {

    let everywhere = Client::from_rendezvous(
        &rendezvous(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 3000),
    )
    .expect("a client is built");
    assert_eq!(everywhere.workbench_url(), "http://127.0.0.1:3000");

    let everywhere_v6 = Client::from_rendezvous(
        &rendezvous(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 3000),
    )
    .expect("a client is built");
    assert_eq!(everywhere_v6.workbench_url(), "http://[::1]:3000");
}

/// TEST9900: a daemon bound to one interface is reached AT that interface.
///
/// This is why the address is recorded at all. Bound to a single address, the
/// daemon does not answer on 127.0.0.1 — so a client that assumed loopback
/// would report a running daemon as dead, and then try to start a second one.
#[test]
fn test9900_a_specific_address_is_dialled_where_it_listens() {

    let client = Client::from_rendezvous(
        &rendezvous("192.168.1.10".parse().expect("an address"), 3100),
    )
    .expect("a client is built");
    assert_eq!(client.workbench_url(), "http://192.168.1.10:3100");

    let loopback = Client::from_rendezvous(
        &rendezvous(IpAddr::V4(Ipv4Addr::LOCALHOST), 3000),
    )
    .expect("a client is built");
    assert_eq!(loopback.workbench_url(), "http://127.0.0.1:3000");
}

/// A daemon that answers one request, and hands back what it was sent.
///
/// Written by hand rather than mocked: the thing under test is what goes ON
/// THE WIRE, and a fake that the client talked to through its own types would
/// agree with any client at all.
fn one_shot_daemon() -> (u16, std::thread::JoinHandle<String>) {
    one_shot_daemon_answering("{}".to_string())
}

/// [`one_shot_daemon`], answering with `body`.
fn one_shot_daemon_answering(body: String) -> (u16, std::thread::JoinHandle<String>) {
    use std::io::{Read, Write};

    let listener = std::net::TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("a port");
    let port = listener.local_addr().expect("the bound address").port();
    let handle = std::thread::spawn(move || {
        let (mut stream, _) = listener.accept().expect("a connection");
        let mut request = vec![0u8; 4096];
        let read = stream.read(&mut request).expect("the request arrives");
        stream
            .write_all(
                format!(
                    "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\
                     Connection: close\r\n\r\n{body}",
                    body.len()
                )
                .as_bytes(),
            )
            .expect("the answer is written");
        let _ = stream.flush();
        String::from_utf8_lossy(&request[..read]).into_owned()
    });
    (port, handle)
}

/// TEST9598: stopping asks the daemon; it does not decide for it.
///
/// The client refused to send the request at all unless it held a session —
/// so on a machine whose workspaces have no store, where no session can ever
/// exist, `wsb daemon stop` asked for a passphrase that does not exist and the
/// only way to stop the daemon was to kill it. Whether stopping needs a
/// session is the daemon's to answer: it is the one that knows what it holds.
#[test]
fn test9598_stopping_without_a_session_still_reaches_the_daemon() {
    let (port, daemon) = one_shot_daemon();

    let client = Client::from_rendezvous(
        &rendezvous(IpAddr::V4(Ipv4Addr::LOCALHOST), port),
    )
    .expect("a client");

    client.shutdown().expect("the daemon answered, so the request was made");

    let request = daemon.join().expect("the daemon thread finishes");
    assert!(
        request.starts_with("POST /api/daemon/shutdown"),
        "the request that reached the daemon was: {request}"
    );
    assert!(
        !request.to_lowercase().contains("authorization:"),
        "a client with no session sent one anyway: {request}"
    );
}

/// TEST11498: the request to stop carries the proof, and the proof is not a
/// passphrase.
///
/// The daemon publishes a secret in its rendezvous file, which is written
/// 0600 — so having read that file is what says the caller is the account the
/// daemon runs as. This client read it to find the port; presenting it costs
/// nothing and it is the whole reason `wsb daemon restart` no longer opens a
/// secret store before it will stop anything.
///
/// Asserted on the wire, because that is where it either is or is not: a
/// client checked through its own types would agree with any daemon at all.
#[test]
fn test11498_stopping_presents_the_machines_control_secret() {
    let (port, daemon) = one_shot_daemon();

    let mut published = rendezvous(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
    published.control_token = "d3adb33f".repeat(8);

    let client = Client::from_rendezvous(&published).expect("a client");
    client.shutdown().expect("the daemon answered");

    let request = daemon.join().expect("the daemon thread finishes");
    let lowered = request.to_lowercase();
    assert!(
        lowered.contains(crate::daemon::protocol::route::CONTROL_HEADER),
        "the request carried no control secret, so a daemon holding a store \
         would ask for a passphrase: {request}"
    );
    assert!(
        request.contains(&published.control_token),
        "the header is there and the secret is not: {request}"
    );
    // And still no session, because it has none and does not need one.
    assert!(
        !lowered.contains("authorization:"),
        "a client with no session sent one anyway: {request}"
    );
}

/// TEST11499: a rendezvous written by a daemon that publishes no secret leaves
/// the caller nothing to present.
///
/// Not a special case to carry anywhere: no secret means no proof, and the
/// command line falls back to earning a session exactly as it did before. What
/// must not happen is an empty header going out, which reads on the other side
/// as a caller presenting something.
#[test]
fn test11499_no_control_secret_means_no_header() {
    let (port, daemon) = one_shot_daemon();

    // `rendezvous` builds one with an empty token, which is what an older
    // daemon's file deserialises to.
    let published = rendezvous(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
    assert!(published.control_token.is_empty());

    let client = Client::from_rendezvous(&published).expect("a client");
    client.shutdown().expect("the daemon answered");

    let request = daemon.join().expect("the daemon thread finishes");
    assert!(
        !request
            .to_lowercase()
            .contains(crate::daemon::protocol::route::CONTROL_HEADER),
        "an empty control secret was sent as though it were one: {request}"
    );
}

/// TEST20042: a resolution naming a worktree the directory is not in is
/// refused.
///
/// The daemon once answered every worktree of a project with the first one
/// opened, and the command line took the answer as given: a command in
/// `fix-worktree` read and wrote `main-worktree`'s state. A sibling whose
/// name merely starts the same is another worktree, not this one.
#[test]
fn test20042_a_resolution_must_contain_the_directory() {
    let scratch = tempfile::tempdir().expect("a scratch directory");
    let main = scratch.path().join("main-worktree");
    let copy = scratch.path().join("main-worktree-copy");
    for dir in [main.join("src"), copy.clone()] {
        std::fs::create_dir_all(&dir).expect("mkdir");
    }
    let answer = |root: &std::path::Path| crate::daemon::WorktreeInfo {
        id: "alpha:main".to_string(),
        project: Some("alpha".to_string()),
        name: "main".to_string(),
        root: root.display().to_string(),
        workspace: None,
        no_continuity: Some("no commits".to_string()),
    };

    check_resolution(&main.join("src"), &answer(&main)).expect("inside its own worktree");
    check_resolution(&main, &answer(&main)).expect("at its root");
    let refusal = check_resolution(&copy, &answer(&main)).expect_err("another worktree");
    assert!(refusal.contains("does not contain it"), "{refusal}");

    // And through the client: a daemon answering with another worktree is
    // refused, and the client is left resolved to nothing.
    let resolving = |cwd: &std::path::Path, answered: &std::path::Path| {
        let (port, daemon) =
            one_shot_daemon_answering(serde_json::to_string(&answer(answered)).expect("encodes"));
        let client = Client::from_rendezvous(&rendezvous(IpAddr::V4(Ipv4Addr::LOCALHOST), port))
            .expect("a client");
        let outcome = client.resolve_worktree(cwd);
        daemon.join().expect("the daemon thread finishes");
        (outcome, client.worktree_info())
    };
    let (outcome, held) = resolving(&copy, &main);
    assert!(outcome.expect_err("another worktree's answer").contains("does not contain it"));
    assert_eq!(held, None, "the wrong worktree was kept for later requests");
    let (outcome, held) = resolving(&main.join("src"), &main);
    assert_eq!(outcome.expect("its own worktree").root, main.display().to_string());
    assert!(held.is_some());
}