agentknock 0.2.0

Developer secrets on your phone, provided only to approved commands.
Documentation
#![cfg(target_os = "linux")]

use std::{
    fs,
    io::{Read as _, Write as _},
    os::unix::{fs::PermissionsExt as _, net::UnixStream},
    path::Path,
    process::{Child, Command, Stdio},
    thread,
    time::{Duration, Instant},
};

use serde_json::{Value, json};

const STARTUP: &str = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";

struct ChildGuard(Child);

impl Drop for ChildGuard {
    fn drop(&mut self) {
        let _ = self.0.kill();
        let _ = self.0.wait();
    }
}

#[test]
fn creates_a_private_runtime_directory_and_follows_the_owner_lifetime() {
    let mut owner = ChildGuard(
        Command::new("sleep")
            .arg("30")
            .spawn()
            .expect("start invocation owner"),
    );
    let mut service = ChildGuard(start_service());
    let response = send_startup(
        &mut service.0,
        &json!({
            "owner_pid": owner.0.id(),
            "invocation_id": "01K00000000000000000000000",
            "invocation_token": STARTUP,
            "secret": "test-ssh",
            "public_key": "ssh-ed25519 AAAA test",
            "quiet": false,
            "verbose": false,
        }),
    );
    assert_eq!(response["status"], "ready", "{response}");
    let runtime_directory = response["runtime_directory"].as_str().unwrap();
    let metadata = fs::metadata(runtime_directory).unwrap();
    assert!(metadata.is_dir());
    assert_eq!(metadata.permissions().mode() & 0o777, 0o700);

    let mut unauthorized = UnixStream::connect(Path::new(runtime_directory).join("service.sock"))
        .expect("connect from a process outside the invocation");
    unauthorized
        .write_all(br#"{"operation":"public_key"}"#)
        .unwrap();
    unauthorized.shutdown(std::net::Shutdown::Write).unwrap();
    let mut response = Vec::new();
    if let Err(error) = unauthorized.read_to_end(&mut response) {
        assert_eq!(error.kind(), std::io::ErrorKind::ConnectionReset);
    }
    assert!(response.is_empty());
    assert!(service.0.try_wait().unwrap().is_none());

    owner.0.kill().unwrap();
    owner.0.wait().unwrap();
    wait_for_exit(&mut service.0);
    assert!(!fs::exists(runtime_directory).unwrap());
}

#[test]
fn reports_a_malformed_startup_request() {
    let mut service = ChildGuard(start_service());
    service
        .0
        .stdin
        .take()
        .unwrap()
        .write_all(b"not json")
        .unwrap();
    let response: Value =
        serde_json::from_reader(service.0.stdout.take().unwrap()).expect("startup error response");
    assert_eq!(response["status"], "error");
    assert!(
        response["message"]
            .as_str()
            .unwrap()
            .contains("invalid invocation service startup request")
    );
    assert!(!service.0.wait().unwrap().success());
}

#[test]
fn delegates_other_signing_keys_to_ssh_keygen() {
    let directory = tempfile::tempdir().unwrap();
    let private_key = directory.path().join("signing-key");
    run(Command::new("ssh-keygen")
        .args(["-q", "-t", "ed25519", "-N", "", "-f"])
        .arg(&private_key));
    let message = directory.path().join("message");
    fs::write(&message, "signed by the configured key\n").unwrap();

    let mut service = ChildGuard(start_service());
    let response = send_startup(
        &mut service.0,
        &json!({
            "owner_pid": std::process::id(),
            "invocation_id": "01K00000000000000000000000",
            "invocation_token": STARTUP,
            "secret": "test-ssh",
            "public_key": "ssh-ed25519 AAAA agentknock",
            "quiet": false,
            "verbose": false,
        }),
    );
    assert_eq!(response["status"], "ready", "{response}");
    let helper = Path::new(response["runtime_directory"].as_str().unwrap()).join("git-sign");
    run(Command::new(helper)
        .args(["-Y", "sign", "-n", "git", "-f"])
        .arg(&private_key)
        .arg(&message));

    let mut signature = message.into_os_string();
    signature.push(".sig");
    assert!(fs::exists(signature).unwrap());
}

fn start_service() -> Child {
    Command::new(env!("CARGO_BIN_EXE_agentknock"))
        .arg("__invocation-service")
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("start invocation service")
}

fn run(command: &mut Command) {
    let output = command.output().unwrap();
    assert!(
        output.status.success(),
        "command failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn send_startup(service: &mut Child, request: &Value) -> Value {
    serde_json::to_writer(service.stdin.take().unwrap(), request).unwrap();
    serde_json::from_reader(service.stdout.take().unwrap()).expect("startup response")
}

fn wait_for_exit(process: &mut Child) {
    let deadline = Instant::now() + Duration::from_secs(5);
    loop {
        if let Some(status) = process.try_wait().unwrap() {
            assert!(status.success());
            return;
        }
        assert!(Instant::now() < deadline, "invocation service didn't exit");
        thread::sleep(Duration::from_millis(10));
    }
}