#![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())
);
}
}