//! Git over SSH, driven by a real `git` and a real `ssh`.
//!
//! The server is the real binary, reached the way a developer reaches it: a
//! key is registered through the webui, a repository is created through the
//! webui, and then `git` itself is pointed at the SSH port with
//! `GIT_SSH_COMMAND`. Nothing here reimplements the git protocol, because the
//! thing worth checking is that the two sides agree - and the only honest way
//! to check that is to let the real client speak.

mod support;

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU64, Ordering};

use support::{Client, Server, free_port};

/// The password the tests register and log in with.
const PASSWORD: &str = "correct horse battery staple";

/// Names the scratch directories, so several tests can run at once.
static COUNTER: AtomicU64 = AtomicU64::new(0);

fn spawn() -> (Server, u16) {
    let port = free_port();
    let mut server = Server::spawn(&format!("127.0.0.1:{port}"));
    server.wait_for_connection(port);
    // The SSH listener is bound before the web server starts, but its thread
    // still has to reach `accept`; waiting here keeps `git` from racing it.
    let ssh_port = server.ssh_port();
    server.wait_for_connection(ssh_port);
    (server, port)
}

/// Whether the external tools these tests drive are on the machine.
fn tooling_present() -> bool {
    fn have(binary: &str, arg: &str) -> bool {
        Command::new(binary).arg(arg).output().is_ok()
    }

    have("git", "--version") && have("ssh", "-V") && have("ssh-keygen", "--version")
}

/// Leaves the test quietly if `git`, `ssh` or `ssh-keygen` is missing, rather
/// than failing for a reason that says nothing about the server.
macro_rules! skip_without_tooling {
    () => {
        if !tooling_present() {
            eprintln!("skipping: this test needs `git`, `ssh` and `ssh-keygen` on PATH");
            return Ok(());
        }
    };
}

/// A directory of the test's own, removed when it goes out of scope.
struct Scratch {
    dir: PathBuf,
}

impl Scratch {
    fn new(label: &str) -> Self {
        let count = COUNTER.fetch_add(1, Ordering::Relaxed);
        let dir =
            std::env::temp_dir().join(format!("frogue-{label}-{}-{count}", std::process::id()));
        fs::create_dir_all(&dir).expect("the scratch directory should be creatable");
        Scratch { dir }
    }

    fn path(&self, name: &str) -> PathBuf {
        self.dir.join(name)
    }
}

impl Drop for Scratch {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.dir);
    }
}

/// Writes a fresh ed25519 keypair into `scratch`, and returns the private
/// half's path. The public half is left beside it as `<name>.pub`, where
/// `ssh-keygen` puts it.
fn keygen(scratch: &Scratch, name: &str) -> PathBuf {
    let key = scratch.path(name);

    let output = Command::new("ssh-keygen")
        .arg("-t")
        .arg("ed25519")
        .arg("-f")
        .arg(&key)
        .arg("-N")
        .arg("")
        .arg("-C")
        .arg(name)
        .output()
        .expect("ssh-keygen should run");

    assert!(
        output.status.success(),
        "ssh-keygen failed:\n{}",
        String::from_utf8_lossy(&output.stderr)
    );

    // The SSH client refuses a private key anyone else could read, so the
    // mode is set rather than left to the process umask.
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt as _;
        fs::set_permissions(&key, fs::Permissions::from_mode(0o600))
            .expect("the private key should be readable by its owner alone");
    }

    key
}

/// Registers `name` through the form, which logs them in.
fn register_and_login(client: &mut Client, name: &str, email: &str) {
    let response = client.post_form(
        "/register",
        &[("name", name), ("email", email), ("password", PASSWORD)],
    );

    assert_eq!(
        response.status, 303,
        "registering should log in\n{}",
        response.body
    );
}

/// Adds the public key at `public_key` to `owner`'s account.
fn add_key(client: &mut Client, owner: &str, public_key: &Path) {
    let key = fs::read_to_string(public_key).expect("the public key should be readable");

    let response = client.post_form(&format!("/~{owner}/ssh-keys"), &[("key", key.trim())]);

    assert_eq!(
        response.status, 303,
        "adding a key should redirect\n{}",
        response.body
    );
}

/// Creates `owner`'s repository `slug` with the given visibility.
fn create_repository(client: &mut Client, owner: &str, slug: &str, visibility: &str) {
    let response = client.post_form(
        &format!("/~{owner}/repositories"),
        &[
            ("slug", slug),
            ("description", "a repository from the SSH tests"),
            ("visibility", visibility),
        ],
    );

    assert_eq!(
        response.status, 303,
        "creating a repository should redirect\n{}",
        response.body
    );
    assert_eq!(
        response.location.as_deref(),
        Some(format!("/~{owner}/{slug}").as_str()),
        "creating a repository should lead to its page"
    );
}

/// The `GIT_SSH_COMMAND` that points `git` at our server using `key`.
///
/// `BatchMode` matters most: without it a refused key turns into an
/// interactive password prompt and the test hangs instead of failing.
fn ssh_command(key: &Path, port: u16) -> String {
    format!(
        "ssh -i {} -o IdentitiesOnly=yes -o BatchMode=yes \
         -o PreferredAuthentications=publickey \
         -o StrictHostKeyChecking=accept-new \
         -o UserKnownHostsFile=/dev/null -p {port}",
        key.display()
    )
}

/// Runs `git` in `dir`, pointed at the server, and returns what it said.
///
/// The global and system configuration are turned off so that a developer's
/// own `insteadOf` or `core.sshCommand` cannot change what is being tested.
fn git(dir: &Path, key: &Path, port: u16, args: &[&str]) -> Output {
    Command::new("git")
        .current_dir(dir)
        .arg("-c")
        .arg("user.name=Test")
        .arg("-c")
        .arg("user.email=test@example.org")
        .args(args)
        .env("GIT_SSH_COMMAND", ssh_command(key, port))
        .env("GIT_TERMINAL_PROMPT", "0")
        .env("GIT_CONFIG_GLOBAL", "/dev/null")
        .env("GIT_CONFIG_SYSTEM", "/dev/null")
        .output()
        .expect("git should run")
}

/// Fails with `git`'s own words when it did not succeed.
fn assert_ok(output: &Output, what: &str) {
    assert!(
        output.status.success(),
        "{what} should succeed, but git exited with {:?}\nstdout:\n{}\nstderr:\n{}",
        output.status.code(),
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );
}

/// The `ssh://` URL for a repository on the test server.
fn url(port: u16, owner: &str, slug: &str) -> String {
    format!("ssh://git@127.0.0.1:{port}/{owner}/{slug}.git")
}

#[tokio::test]
async fn a_repository_round_trips_over_ssh() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("ssh-roundtrip");
    let key = keygen(&scratch, "id_ed25519");

    let mut client = Client::new(port);
    register_and_login(&mut client, "alice", "alice@example.org");
    add_key(&mut client, "alice", &scratch.path("id_ed25519.pub"));
    create_repository(&mut client, "alice", "test-repo", "public");

    let remote = url(ssh_port, "alice", "test-repo");

    // Cloning an empty repository: it comes down with nothing in it, which is
    // still a successful clone.
    let first = scratch.path("first");
    let output = git(
        &scratch.dir,
        &key,
        ssh_port,
        &["clone", &remote, first.to_str().unwrap()],
    );
    assert_ok(&output, "cloning an empty repository");

    // Commit something and push it back.
    fs::write(first.join("README.md"), "# Notes\n").expect("writing the file");
    assert_ok(
        &git(&first, &key, ssh_port, &["add", "README.md"]),
        "staging a file",
    );
    assert_ok(
        &git(&first, &key, ssh_port, &["commit", "-m", "Add a readme"]),
        "committing",
    );
    assert_ok(
        &git(&first, &key, ssh_port, &["push", "origin", "main"]),
        "pushing",
    );

    // Clone it again elsewhere, and find the commit there: the push has to
    // have reached the bare repository, not just the first working tree.
    let second = scratch.path("second");
    let output = git(
        &scratch.dir,
        &key,
        ssh_port,
        &["clone", &remote, second.to_str().unwrap()],
    );
    assert_ok(&output, "cloning a repository with content");
    assert_eq!(
        fs::read_to_string(second.join("README.md")).expect("the pushed file should be there"),
        "# Notes\n",
        "the pushed commit should be what a second clone sees"
    );

    Ok(())
}

#[tokio::test]
async fn a_private_repository_is_the_owners_alone_over_ssh() -> miette::Result<()> {
    skip_without_tooling!();

    let (server, port) = spawn();
    let ssh_port = server.ssh_port();
    let scratch = Scratch::new("ssh-visibility");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));
    create_repository(&mut alice, "alice", "secret", "private");
    create_repository(&mut alice, "alice", "public", "public");

    // The owner reaches her own private repository.
    let destination = scratch.path("alice-clone");
    let output = git(
        &scratch.dir,
        &alice_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "secret"),
            destination.to_str().unwrap(),
        ],
    );
    assert_ok(&output, "the owner cloning a private repository");

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    // A stranger is refused the private one - the server refuses the command,
    // so this fails rather than handing back an empty clone.
    let destination = scratch.path("bob-private");
    let output = git(
        &scratch.dir,
        &bob_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "secret"),
            destination.to_str().unwrap(),
        ],
    );
    assert!(
        !output.status.success(),
        "a stranger should not clone a private repository\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    // The public one, though, is his to read.
    let destination = scratch.path("bob-public");
    let output = git(
        &scratch.dir,
        &bob_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "public"),
            destination.to_str().unwrap(),
        ],
    );
    assert_ok(&output, "a stranger cloning a public repository");

    Ok(())
}

#[tokio::test]
async fn only_the_owner_pushes_over_ssh() -> miette::Result<()> {
    skip_without_tooling!();

    let (_server, port) = spawn();
    let ssh_port = _server.ssh_port();
    let scratch = Scratch::new("ssh-push");

    let alice_key = keygen(&scratch, "alice_key");
    let bob_key = keygen(&scratch, "bob_key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    add_key(&mut alice, "alice", &scratch.path("alice_key.pub"));

    let mut bob = Client::new(port);
    register_and_login(&mut bob, "bob", "bob@example.org");
    add_key(&mut bob, "bob", &scratch.path("bob_key.pub"));

    create_repository(&mut alice, "alice", "shared", "public");

    // The owner pushes, to show the refusal below is about who is pushing and
    // not about pushing being broken.
    let alice_clone = scratch.path("alice-clone");
    let output = git(
        &scratch.dir,
        &alice_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "shared"),
            alice_clone.to_str().unwrap(),
        ],
    );
    assert_ok(&output, "the owner cloning her repository");

    fs::write(alice_clone.join("README.md"), "# Shared\n").expect("writing the file");
    assert_ok(
        &git(&alice_clone, &alice_key, ssh_port, &["add", "README.md"]),
        "staging a file",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["commit", "-m", "Start the repository"],
        ),
        "committing",
    );
    assert_ok(
        &git(
            &alice_clone,
            &alice_key,
            ssh_port,
            &["push", "origin", "main"],
        ),
        "the owner pushing",
    );

    // Bob may read the public repository...
    let bob_clone = scratch.path("bob-clone");
    let output = git(
        &scratch.dir,
        &bob_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "shared"),
            bob_clone.to_str().unwrap(),
        ],
    );
    assert_ok(&output, "a stranger cloning a public repository");

    fs::write(bob_clone.join("intrusion.txt"), "hello\n").expect("writing the file");
    assert_ok(
        &git(&bob_clone, &bob_key, ssh_port, &["add", "intrusion.txt"]),
        "staging a file",
    );
    assert_ok(
        &git(
            &bob_clone,
            &bob_key,
            ssh_port,
            &["commit", "-m", "Unsolicited"],
        ),
        "committing",
    );

    // ... but writing is the owner's alone, public or not.
    let output = git(&bob_clone, &bob_key, ssh_port, &["push", "origin", "main"]);
    assert!(
        !output.status.success(),
        "a stranger should not push, even to a public repository\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}

#[tokio::test]
async fn a_key_that_belongs_to_nobody_reaches_nothing() -> miette::Result<()> {
    skip_without_tooling!();

    let (_server, port) = spawn();
    let ssh_port = _server.ssh_port();
    let scratch = Scratch::new("ssh-unknown-key");

    let mut alice = Client::new(port);
    register_and_login(&mut alice, "alice", "alice@example.org");
    create_repository(&mut alice, "alice", "test-repo", "public");

    // A key nobody has registered. Nothing is anonymous here, so it is refused
    // before any repository is even looked at.
    let stranger_key = keygen(&scratch, "stranger_key");

    let destination = scratch.path("clone");
    let output = git(
        &scratch.dir,
        &stranger_key,
        ssh_port,
        &[
            "clone",
            &url(ssh_port, "alice", "test-repo"),
            destination.to_str().unwrap(),
        ],
    );

    assert!(
        !output.status.success(),
        "an unregistered key should be refused\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr),
    );

    Ok(())
}
