sim-run-core 0.2.0

Core command entry API for the SIM bootloader.
Documentation
use std::{
    collections::BTreeMap,
    fs,
    io::{Read, Write},
    net::TcpListener,
    path::PathBuf,
    thread,
};

use crate::crates_io::ARTIFACT_FILE;

use super::*;

#[test]
fn fetches_matching_version_and_caches_artifact() {
    let artifact_bytes = b"artifact";
    let server = FixtureServer::start([
        (
            "/packages/sim-codec-lisp/index.txt",
            [
                index_row("0.0.9", ARTIFACT_FILE, b"old-artifact"),
                index_row("0.1.0", ARTIFACT_FILE, artifact_bytes),
                index_row("0.2.0", ARTIFACT_FILE, b"new-artifact"),
            ]
            .join("\n")
            .into_bytes(),
        ),
        (
            "/packages/sim-codec-lisp/0.1.0/artifact.simlib",
            artifact_bytes.to_vec(),
        ),
    ]);
    let cache = temp_cache("git-registry-fetch");
    let resolver = GitRegistryResolver::new(server.endpoint(), cache.clone()).unwrap();

    let resolved = resolver
        .resolve(&"sim-codec-lisp@^0.1".parse().unwrap())
        .unwrap();

    assert_eq!(resolved.version, "0.1.0");
    assert_eq!(fs::read(&resolved.artifact).unwrap(), artifact_bytes);
    assert!(resolved.artifact.starts_with(&cache));
    assert!(
        resolved
            .artifact
            .file_name()
            .unwrap()
            .to_string_lossy()
            .starts_with("sha256-")
    );
    server.join();
    let _ = fs::remove_dir_all(cache);
}

#[test]
fn rejects_tampered_fetch_and_does_not_cache_it() {
    let expected_bytes = b"artifact";
    let server = FixtureServer::start([
        (
            "/packages/sim-codec-lisp/index.txt",
            index_row("0.1.0", ARTIFACT_FILE, expected_bytes).into_bytes(),
        ),
        (
            "/packages/sim-codec-lisp/0.1.0/artifact.simlib",
            b"tampered".to_vec(),
        ),
    ]);
    let cache = temp_cache("git-registry-tampered-fetch");
    let resolver = GitRegistryResolver::new(server.endpoint(), cache.clone()).unwrap();

    let err = resolver
        .resolve(&"sim-codec-lisp@0.1.0".parse().unwrap())
        .unwrap_err()
        .to_string();

    assert!(err.contains("hash mismatch"), "unexpected error: {err}");
    assert!(
        !verified_cache_artifact_path(
            &cache,
            "sim-codec-lisp",
            &IndexedArtifact {
                version: "0.1.0".to_owned(),
                file_name: ARTIFACT_FILE.to_owned(),
                sha256: sha256(expected_bytes),
            },
        )
        .exists()
    );
    server.join();
    let _ = fs::remove_dir_all(cache);
}

#[test]
fn rejects_poisoned_cache_on_reuse() {
    let expected_bytes = b"artifact";
    let selected = IndexedArtifact {
        version: "0.1.0".to_owned(),
        file_name: ARTIFACT_FILE.to_owned(),
        sha256: sha256(expected_bytes),
    };
    let cache = temp_cache("git-registry-poisoned-cache");
    let artifact = verified_cache_artifact_path(&cache, "sim-codec-lisp", &selected);
    fs::create_dir_all(artifact.parent().unwrap()).unwrap();
    fs::write(&artifact, b"poisoned").unwrap();
    let server = FixtureServer::start([(
        "/packages/sim-codec-lisp/index.txt",
        index_row("0.1.0", ARTIFACT_FILE, expected_bytes).into_bytes(),
    )]);
    let resolver = GitRegistryResolver::new(server.endpoint(), cache.clone()).unwrap();

    let err = resolver
        .resolve(&"sim-codec-lisp@0.1.0".parse().unwrap())
        .unwrap_err()
        .to_string();

    assert!(err.contains("hash mismatch"), "unexpected error: {err}");
    assert_eq!(fs::read(&artifact).unwrap(), b"poisoned");
    server.join();
    let _ = fs::remove_dir_all(cache);
}

#[test]
fn rejects_implicit_or_https_endpoint() {
    assert_eq!(
        GitRegistryResolver::new("", PathBuf::new())
            .unwrap_err()
            .to_string(),
        "git registry endpoint is empty"
    );
    assert_eq!(
        GitRegistryResolver::new("https://example.invalid/sim", PathBuf::new())
            .unwrap_err()
            .to_string(),
        "git registry endpoint must use http:// in this build"
    );
}

#[test]
fn rejects_non_loopback_insecure_endpoint() {
    let err = GitRegistryResolver::new("http://forge.example/sim", PathBuf::new())
        .unwrap_err()
        .to_string();
    assert!(err.contains("is not loopback"), "unexpected error: {err}");
}

#[test]
fn registry_rejects_overflowing_chunk_size() {
    let err = decode_git_registry_chunked(b"fffffffffffffffe\r\n", usize::MAX)
        .expect_err("overflowing chunk size must error, not panic")
        .to_string();
    assert!(err.contains("overflow"), "unexpected error: {err}");
}

#[test]
fn registry_rejects_oversized_content_length() {
    let response = b"HTTP/1.1 200 OK\r\nContent-Length: 4096\r\nConnection: close\r\n\r\n";
    let err = parse_http_response("http://loopback/index.txt", response, 16)
        .expect_err("body length past the cap must error")
        .to_string();
    assert!(err.contains("exceeds 16 bytes"), "unexpected error: {err}");
}

#[test]
fn registry_caps_chunked_decoded_length() {
    let err = decode_git_registry_chunked(b"8\r\nAAAAAAAA\r\n0\r\n", 4)
        .expect_err("decoded length past the cap must error")
        .to_string();
    assert!(err.contains("exceeds 4 bytes"), "unexpected error: {err}");
}

#[test]
fn rejects_unsafe_artifact_file_names() {
    assert_eq!(
        index_artifact(&format!(
            "0.1.0 ../artifact.simlib {}",
            sha256_hex(&sha256(b"artifact"))
        ))
        .unwrap_err()
        .to_string(),
        "git registry artifact file name is not a safe path component: ../artifact.simlib"
    );
}

#[test]
fn rejects_index_rows_without_a_hash() {
    assert_eq!(
        index_artifact("0.1.0 artifact.simlib")
            .unwrap_err()
            .to_string(),
        "git registry index row must use: <version> <file-name> <sha256-hex>"
    );
}

struct FixtureServer {
    endpoint: String,
    handle: thread::JoinHandle<()>,
}

impl FixtureServer {
    fn start<const N: usize>(routes: [(&'static str, Vec<u8>); N]) -> Self {
        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
        let endpoint = format!("http://{}", listener.local_addr().unwrap());
        let routes = routes
            .into_iter()
            .map(|(path, body)| (path.to_owned(), body))
            .collect::<BTreeMap<_, _>>();
        let handle = thread::spawn(move || {
            for _ in 0..N {
                let (mut stream, _) = listener.accept().unwrap();
                let mut request = [0_u8; 1024];
                let size = stream.read(&mut request).unwrap();
                let request = String::from_utf8_lossy(&request[..size]);
                let path = request
                    .lines()
                    .next()
                    .and_then(|line| line.split_ascii_whitespace().nth(1))
                    .unwrap();
                let Some(body) = routes.get(path) else {
                    let response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n";
                    stream.write_all(response).unwrap();
                    continue;
                };
                let response = format!(
                    "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
                    body.len()
                );
                stream.write_all(response.as_bytes()).unwrap();
                stream.write_all(body).unwrap();
            }
        });
        Self { endpoint, handle }
    }

    fn endpoint(&self) -> String {
        self.endpoint.clone()
    }

    fn join(self) {
        self.handle.join().unwrap();
    }
}

fn temp_cache(label: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .expect("system time should be after unix epoch")
        .as_nanos();
    std::env::temp_dir().join(format!(
        "sim-run-core-git-registry-cache-{}-{label}-{nanos}",
        std::process::id(),
    ))
}

fn index_row(version: &str, file_name: &str, bytes: &[u8]) -> String {
    format!("{version} {file_name} {}", sha256_hex(&sha256(bytes)))
}