kcode-k1-web-checker-protocol 0.1.0

Own the K1 Web checker request and report protocol
Documentation
#![forbid(unsafe_code)]

use kcode_k1_transaction_id::TxId;
use kcode_k1_web_package::{AuthorityId, DependencySelector, WebFamily, WebId};
use semver::Version;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;

pub const SCHEMA_VERSION: u32 = 1;

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WebIdInput {
    pub authority: String,
    pub name: String,
    pub version: String,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SelectionInput {
    pub family_authority: String,
    pub family_name: String,
    pub selector: String,
    pub resolved: WebIdInput,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Request {
    pub schema: u32,
    pub candidate: WebIdInput,
    pub candidate_root: PathBuf,
    pub projection_root: PathBuf,
    pub entry: String,
    pub tests: String,
    pub selections: Vec<SelectionInput>,
    pub chromium: PathBuf,
    pub timeout_ms: u64,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Stage {
    Validation,
    Server,
    Browser,
    Test,
    Timeout,
    Cleanup,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Outcome {
    Success,
    Failure { stage: Stage, message: String },
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Timings {
    pub validation_ms: u64,
    pub server_ms: u64,
    pub browser_ms: u64,
    pub cleanup_ms: u64,
    pub total_ms: u64,
}

#[derive(Debug, Default, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Diagnostics {
    pub browser_stdout: Vec<u8>,
    pub browser_stderr: Vec<u8>,
    pub browser_exit: Option<String>,
    pub page_error: Option<String>,
    pub server_errors: Vec<String>,
    pub cleanup_errors: Vec<String>,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Report {
    pub schema: u32,
    pub outcome: Outcome,
    pub diagnostics: Diagnostics,
    pub timings: Timings,
}

pub struct ValidatedRequest {
    candidate: WebId,
    selections: Vec<(WebFamily, DependencySelector, WebId)>,
}

impl ValidatedRequest {
    pub fn into_routing_parts(self) -> (WebId, Vec<(WebFamily, DependencySelector, WebId)>) {
        (self.candidate, self.selections)
    }
}

pub fn validate_request(value: &Request) -> Result<ValidatedRequest, String> {
    require(value.schema == SCHEMA_VERSION, "unsupported schema")?;
    require(value.timeout_ms != 0, "timeout must be nonzero")?;
    for (path, label) in [
        (&value.candidate_root, "candidate root"),
        (&value.projection_root, "projection root"),
    ] {
        require(
            path.is_absolute() && ordinary_directory(path),
            &format!("{label} must be an absolute ordinary directory"),
        )?;
    }
    require(
        value.chromium.is_absolute() && ordinary_file(&value.chromium),
        "chromium must be an absolute ordinary file",
    )?;
    validate_module_path(&value.entry)?;
    validate_module_path(&value.tests)?;
    let candidate = web_id(&value.candidate)?;
    let mut selections = Vec::with_capacity(value.selections.len());
    for selection in &value.selections {
        let family = family(&selection.family_authority, &selection.family_name)?;
        let selector =
            DependencySelector::parse(&selection.selector).map_err(|error| error.to_string())?;
        selections.push((family, selector, web_id(&selection.resolved)?));
    }
    Ok(ValidatedRequest {
        candidate,
        selections,
    })
}

fn encode<T: Serialize>(value: &T) -> serde_json::Result<Vec<u8>> {
    let mut bytes = serde_json::to_vec(value)?;
    bytes.push(b'\n');
    Ok(bytes)
}

pub fn encode_request(value: &Request) -> serde_json::Result<Vec<u8>> {
    encode(value)
}

pub fn decode_request(bytes: &[u8]) -> serde_json::Result<Request> {
    serde_json::from_slice(bytes)
}

pub fn encode_report(value: &Report) -> serde_json::Result<Vec<u8>> {
    encode(value)
}

pub fn decode_report(bytes: &[u8]) -> serde_json::Result<Report> {
    serde_json::from_slice(bytes)
}

fn require(condition: bool, message: &str) -> Result<(), String> {
    condition.then_some(()).ok_or_else(|| message.to_owned())
}

fn family(authority: &str, name: &str) -> Result<WebFamily, String> {
    require(
        authority.len() == 24
            && authority
                .bytes()
                .all(|value| value.is_ascii_digit() || matches!(value, b'a'..=b'f')),
        "authority must be 24 lowercase hexadecimal characters",
    )?;
    let mut bytes = [0; 12];
    for (index, byte) in bytes.iter_mut().enumerate() {
        *byte = u8::from_str_radix(&authority[index * 2..index * 2 + 2], 16)
            .map_err(|error| error.to_string())?;
    }
    WebFamily::new(AuthorityId::new(TxId::from_bytes(bytes)), name)
        .map_err(|error| error.to_string())
}

fn web_id(value: &WebIdInput) -> Result<WebId, String> {
    let version = Version::parse(&value.version).map_err(|error| error.to_string())?;
    require(
        version.to_string() == value.version,
        "version is not canonical",
    )?;
    WebId::new(family(&value.authority, &value.name)?, version).map_err(|error| error.to_string())
}

fn validate_module_path(path: &str) -> Result<(), String> {
    let valid = !path.is_empty()
        && path.len() <= 4096
        && !path.starts_with('/')
        && !path.contains([':', '\\', '\0'])
        && path
            .split('/')
            .all(|part| !part.is_empty() && !matches!(part, "." | "..") && part.len() <= 255)
        && (path.ends_with(".js") || path.ends_with(".mjs"));
    require(
        valid,
        "entry and tests must be safe relative JavaScript paths",
    )
}

fn ordinary_directory(path: &std::path::Path) -> bool {
    fs::symlink_metadata(path)
        .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
}

fn ordinary_file(path: &std::path::Path) -> bool {
    fs::symlink_metadata(path)
        .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
}

#[cfg(test)]
mod tests {
    use super::*;

    fn request(candidate_root: PathBuf, projection_root: PathBuf, chromium: PathBuf) -> Request {
        Request {
            schema: 1,
            candidate: WebIdInput {
                authority: "010101010101010101010101".into(),
                name: "candidate".into(),
                version: "1.2.3".into(),
            },
            candidate_root,
            projection_root,
            entry: "index.js".into(),
            tests: "tests.mjs".into(),
            selections: Vec::new(),
            chromium,
            timeout_ms: 15_000,
        }
    }

    #[test]
    fn codecs_preserve_the_wire_contract() {
        let value = request(
            PathBuf::from("/candidate"),
            PathBuf::from("/projection"),
            PathBuf::from("/chromium"),
        );
        let encoded = encode_request(&value).unwrap();
        assert_eq!(
            encoded,
            concat!(
                "{\"schema\":1,\"candidate\":{\"authority\":\"010101010101010101010101\",",
                "\"name\":\"candidate\",\"version\":\"1.2.3\"},\"candidate_root\":\"/candidate\",",
                "\"projection_root\":\"/projection\",\"entry\":\"index.js\",\"tests\":\"tests.mjs\",",
                "\"selections\":[],\"chromium\":\"/chromium\",\"timeout_ms\":15000}\n"
            )
            .as_bytes()
        );
        assert_eq!(decode_request(&encoded).unwrap().timeout_ms, 15_000);
        assert!(decode_request(br#"{\"schema\":1,\"extra\":true}"#).is_err());

        let report = Report {
            schema: 1,
            outcome: Outcome::Failure {
                stage: Stage::Test,
                message: "failed".into(),
            },
            diagnostics: Diagnostics::default(),
            timings: Timings::default(),
        };
        let encoded = encode_report(&report).unwrap();
        assert_eq!(
            encoded,
            concat!(
                "{\"schema\":1,\"outcome\":{\"kind\":\"failure\",\"stage\":\"test\",\"message\":\"failed\"},",
                "\"diagnostics\":{\"browser_stdout\":[],\"browser_stderr\":[],\"browser_exit\":null,",
                "\"page_error\":null,\"server_errors\":[],\"cleanup_errors\":[]},",
                "\"timings\":{\"validation_ms\":0,\"server_ms\":0,\"browser_ms\":0,",
                "\"cleanup_ms\":0,\"total_ms\":0}}\n"
            )
            .as_bytes()
        );
        assert!(matches!(
            decode_report(&encoded).unwrap().outcome,
            Outcome::Failure {
                stage: Stage::Test,
                ..
            }
        ));
    }

    #[test]
    fn validation_returns_only_canonical_routing_inputs() {
        let root = tempfile::tempdir().unwrap();
        let candidate = root.path().join("candidate");
        let projection = root.path().join("projection");
        let chromium = root.path().join("chromium");
        fs::create_dir_all(&candidate).unwrap();
        fs::create_dir_all(&projection).unwrap();
        fs::write(&chromium, []).unwrap();
        let mut value = request(candidate, projection, chromium);

        let (candidate, selections) = validate_request(&value).unwrap().into_routing_parts();
        assert_eq!(candidate.family().logical_name(), "candidate");
        assert!(selections.is_empty());

        value.entry = "../index.js".into();
        assert_eq!(
            validate_request(&value).err().unwrap(),
            "entry and tests must be safe relative JavaScript paths"
        );
        value.entry = "index.js".into();
        value.candidate.version = "01.2.3".into();
        assert!(validate_request(&value).is_err());
    }

    #[test]
    fn validation_rejects_zero_timeout_and_accepts_nonzero_timeout() {
        let root = tempfile::tempdir().unwrap();
        let candidate = root.path().join("candidate");
        let projection = root.path().join("projection");
        let chromium = root.path().join("chromium");
        fs::create_dir_all(&candidate).unwrap();
        fs::create_dir_all(&projection).unwrap();
        fs::write(&chromium, []).unwrap();
        let mut value = request(candidate, projection, chromium);

        value.timeout_ms = 0;
        assert_eq!(
            validate_request(&value).err(),
            Some("timeout must be nonzero".into())
        );
        value.timeout_ms = 300_001;
        assert!(validate_request(&value).is_ok());
    }

    #[cfg(unix)]
    #[test]
    fn validation_rejects_chromium_symlink() {
        use std::os::unix::fs::symlink;

        let root = tempfile::tempdir().unwrap();
        let candidate = root.path().join("candidate");
        let projection = root.path().join("projection");
        let chromium_target = root.path().join("chromium-target");
        let chromium = root.path().join("chromium");
        fs::create_dir_all(&candidate).unwrap();
        fs::create_dir_all(&projection).unwrap();
        fs::write(&chromium_target, []).unwrap();
        symlink(&chromium_target, &chromium).unwrap();

        assert_eq!(
            validate_request(&request(candidate, projection, chromium)).err(),
            Some("chromium must be an absolute ordinary file".into())
        );
    }
}