Skip to main content

kcode_k1_web_checker_protocol/
lib.rs

1#![forbid(unsafe_code)]
2
3use kcode_k1_transaction_id::TxId;
4use kcode_k1_web_package::{AuthorityId, DependencySelector, WebFamily, WebId};
5use semver::Version;
6use serde::{Deserialize, Serialize};
7use std::fs;
8use std::path::PathBuf;
9
10pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Serialize, Deserialize)]
13#[serde(deny_unknown_fields)]
14pub struct WebIdInput {
15    pub authority: String,
16    pub name: String,
17    pub version: String,
18}
19
20#[derive(Debug, Serialize, Deserialize)]
21#[serde(deny_unknown_fields)]
22pub struct SelectionInput {
23    pub family_authority: String,
24    pub family_name: String,
25    pub selector: String,
26    pub resolved: WebIdInput,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Request {
32    pub schema: u32,
33    pub candidate: WebIdInput,
34    pub candidate_root: PathBuf,
35    pub projection_root: PathBuf,
36    pub entry: String,
37    pub tests: String,
38    pub selections: Vec<SelectionInput>,
39    pub chromium: PathBuf,
40    pub timeout_ms: u64,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44#[serde(rename_all = "snake_case")]
45pub enum Stage {
46    Validation,
47    Server,
48    Browser,
49    Test,
50    Timeout,
51    Cleanup,
52}
53
54#[derive(Debug, Serialize, Deserialize)]
55#[serde(tag = "kind", rename_all = "snake_case")]
56pub enum Outcome {
57    Success,
58    Failure { stage: Stage, message: String },
59}
60
61#[derive(Debug, Default, Serialize, Deserialize)]
62#[serde(deny_unknown_fields)]
63pub struct Timings {
64    pub validation_ms: u64,
65    pub server_ms: u64,
66    pub browser_ms: u64,
67    pub cleanup_ms: u64,
68    pub total_ms: u64,
69}
70
71#[derive(Debug, Default, Serialize, Deserialize)]
72#[serde(deny_unknown_fields)]
73pub struct Diagnostics {
74    pub browser_stdout: Vec<u8>,
75    pub browser_stderr: Vec<u8>,
76    pub browser_exit: Option<String>,
77    pub page_error: Option<String>,
78    pub server_errors: Vec<String>,
79    pub cleanup_errors: Vec<String>,
80}
81
82#[derive(Debug, Serialize, Deserialize)]
83#[serde(deny_unknown_fields)]
84pub struct Report {
85    pub schema: u32,
86    pub outcome: Outcome,
87    pub diagnostics: Diagnostics,
88    pub timings: Timings,
89}
90
91pub struct ValidatedRequest {
92    candidate: WebId,
93    selections: Vec<(WebFamily, DependencySelector, WebId)>,
94}
95
96impl ValidatedRequest {
97    pub fn into_routing_parts(self) -> (WebId, Vec<(WebFamily, DependencySelector, WebId)>) {
98        (self.candidate, self.selections)
99    }
100}
101
102pub fn validate_request(value: &Request) -> Result<ValidatedRequest, String> {
103    require(value.schema == SCHEMA_VERSION, "unsupported schema")?;
104    require(value.timeout_ms != 0, "timeout must be nonzero")?;
105    for (path, label) in [
106        (&value.candidate_root, "candidate root"),
107        (&value.projection_root, "projection root"),
108    ] {
109        require(
110            path.is_absolute() && ordinary_directory(path),
111            &format!("{label} must be an absolute ordinary directory"),
112        )?;
113    }
114    require(
115        value.chromium.is_absolute() && ordinary_file(&value.chromium),
116        "chromium must be an absolute ordinary file",
117    )?;
118    validate_module_path(&value.entry)?;
119    validate_module_path(&value.tests)?;
120    let candidate = web_id(&value.candidate)?;
121    let mut selections = Vec::with_capacity(value.selections.len());
122    for selection in &value.selections {
123        let family = family(&selection.family_authority, &selection.family_name)?;
124        let selector =
125            DependencySelector::parse(&selection.selector).map_err(|error| error.to_string())?;
126        selections.push((family, selector, web_id(&selection.resolved)?));
127    }
128    Ok(ValidatedRequest {
129        candidate,
130        selections,
131    })
132}
133
134fn encode<T: Serialize>(value: &T) -> serde_json::Result<Vec<u8>> {
135    let mut bytes = serde_json::to_vec(value)?;
136    bytes.push(b'\n');
137    Ok(bytes)
138}
139
140pub fn encode_request(value: &Request) -> serde_json::Result<Vec<u8>> {
141    encode(value)
142}
143
144pub fn decode_request(bytes: &[u8]) -> serde_json::Result<Request> {
145    serde_json::from_slice(bytes)
146}
147
148pub fn encode_report(value: &Report) -> serde_json::Result<Vec<u8>> {
149    encode(value)
150}
151
152pub fn decode_report(bytes: &[u8]) -> serde_json::Result<Report> {
153    serde_json::from_slice(bytes)
154}
155
156fn require(condition: bool, message: &str) -> Result<(), String> {
157    condition.then_some(()).ok_or_else(|| message.to_owned())
158}
159
160fn family(authority: &str, name: &str) -> Result<WebFamily, String> {
161    require(
162        authority.len() == 24
163            && authority
164                .bytes()
165                .all(|value| value.is_ascii_digit() || matches!(value, b'a'..=b'f')),
166        "authority must be 24 lowercase hexadecimal characters",
167    )?;
168    let mut bytes = [0; 12];
169    for (index, byte) in bytes.iter_mut().enumerate() {
170        *byte = u8::from_str_radix(&authority[index * 2..index * 2 + 2], 16)
171            .map_err(|error| error.to_string())?;
172    }
173    WebFamily::new(AuthorityId::new(TxId::from_bytes(bytes)), name)
174        .map_err(|error| error.to_string())
175}
176
177fn web_id(value: &WebIdInput) -> Result<WebId, String> {
178    let version = Version::parse(&value.version).map_err(|error| error.to_string())?;
179    require(
180        version.to_string() == value.version,
181        "version is not canonical",
182    )?;
183    WebId::new(family(&value.authority, &value.name)?, version).map_err(|error| error.to_string())
184}
185
186fn validate_module_path(path: &str) -> Result<(), String> {
187    let valid = !path.is_empty()
188        && path.len() <= 4096
189        && !path.starts_with('/')
190        && !path.contains([':', '\\', '\0'])
191        && path
192            .split('/')
193            .all(|part| !part.is_empty() && !matches!(part, "." | "..") && part.len() <= 255)
194        && (path.ends_with(".js") || path.ends_with(".mjs"));
195    require(
196        valid,
197        "entry and tests must be safe relative JavaScript paths",
198    )
199}
200
201fn ordinary_directory(path: &std::path::Path) -> bool {
202    fs::symlink_metadata(path)
203        .is_ok_and(|metadata| metadata.is_dir() && !metadata.file_type().is_symlink())
204}
205
206fn ordinary_file(path: &std::path::Path) -> bool {
207    fs::symlink_metadata(path)
208        .is_ok_and(|metadata| metadata.is_file() && !metadata.file_type().is_symlink())
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn request(candidate_root: PathBuf, projection_root: PathBuf, chromium: PathBuf) -> Request {
216        Request {
217            schema: 1,
218            candidate: WebIdInput {
219                authority: "010101010101010101010101".into(),
220                name: "candidate".into(),
221                version: "1.2.3".into(),
222            },
223            candidate_root,
224            projection_root,
225            entry: "index.js".into(),
226            tests: "tests.mjs".into(),
227            selections: Vec::new(),
228            chromium,
229            timeout_ms: 15_000,
230        }
231    }
232
233    #[test]
234    fn codecs_preserve_the_wire_contract() {
235        let value = request(
236            PathBuf::from("/candidate"),
237            PathBuf::from("/projection"),
238            PathBuf::from("/chromium"),
239        );
240        let encoded = encode_request(&value).unwrap();
241        assert_eq!(
242            encoded,
243            concat!(
244                "{\"schema\":1,\"candidate\":{\"authority\":\"010101010101010101010101\",",
245                "\"name\":\"candidate\",\"version\":\"1.2.3\"},\"candidate_root\":\"/candidate\",",
246                "\"projection_root\":\"/projection\",\"entry\":\"index.js\",\"tests\":\"tests.mjs\",",
247                "\"selections\":[],\"chromium\":\"/chromium\",\"timeout_ms\":15000}\n"
248            )
249            .as_bytes()
250        );
251        assert_eq!(decode_request(&encoded).unwrap().timeout_ms, 15_000);
252        assert!(decode_request(br#"{\"schema\":1,\"extra\":true}"#).is_err());
253
254        let report = Report {
255            schema: 1,
256            outcome: Outcome::Failure {
257                stage: Stage::Test,
258                message: "failed".into(),
259            },
260            diagnostics: Diagnostics::default(),
261            timings: Timings::default(),
262        };
263        let encoded = encode_report(&report).unwrap();
264        assert_eq!(
265            encoded,
266            concat!(
267                "{\"schema\":1,\"outcome\":{\"kind\":\"failure\",\"stage\":\"test\",\"message\":\"failed\"},",
268                "\"diagnostics\":{\"browser_stdout\":[],\"browser_stderr\":[],\"browser_exit\":null,",
269                "\"page_error\":null,\"server_errors\":[],\"cleanup_errors\":[]},",
270                "\"timings\":{\"validation_ms\":0,\"server_ms\":0,\"browser_ms\":0,",
271                "\"cleanup_ms\":0,\"total_ms\":0}}\n"
272            )
273            .as_bytes()
274        );
275        assert!(matches!(
276            decode_report(&encoded).unwrap().outcome,
277            Outcome::Failure {
278                stage: Stage::Test,
279                ..
280            }
281        ));
282    }
283
284    #[test]
285    fn validation_returns_only_canonical_routing_inputs() {
286        let root = tempfile::tempdir().unwrap();
287        let candidate = root.path().join("candidate");
288        let projection = root.path().join("projection");
289        let chromium = root.path().join("chromium");
290        fs::create_dir_all(&candidate).unwrap();
291        fs::create_dir_all(&projection).unwrap();
292        fs::write(&chromium, []).unwrap();
293        let mut value = request(candidate, projection, chromium);
294
295        let (candidate, selections) = validate_request(&value).unwrap().into_routing_parts();
296        assert_eq!(candidate.family().logical_name(), "candidate");
297        assert!(selections.is_empty());
298
299        value.entry = "../index.js".into();
300        assert_eq!(
301            validate_request(&value).err().unwrap(),
302            "entry and tests must be safe relative JavaScript paths"
303        );
304        value.entry = "index.js".into();
305        value.candidate.version = "01.2.3".into();
306        assert!(validate_request(&value).is_err());
307    }
308
309    #[test]
310    fn validation_rejects_zero_timeout_and_accepts_nonzero_timeout() {
311        let root = tempfile::tempdir().unwrap();
312        let candidate = root.path().join("candidate");
313        let projection = root.path().join("projection");
314        let chromium = root.path().join("chromium");
315        fs::create_dir_all(&candidate).unwrap();
316        fs::create_dir_all(&projection).unwrap();
317        fs::write(&chromium, []).unwrap();
318        let mut value = request(candidate, projection, chromium);
319
320        value.timeout_ms = 0;
321        assert_eq!(
322            validate_request(&value).err(),
323            Some("timeout must be nonzero".into())
324        );
325        value.timeout_ms = 300_001;
326        assert!(validate_request(&value).is_ok());
327    }
328
329    #[cfg(unix)]
330    #[test]
331    fn validation_rejects_chromium_symlink() {
332        use std::os::unix::fs::symlink;
333
334        let root = tempfile::tempdir().unwrap();
335        let candidate = root.path().join("candidate");
336        let projection = root.path().join("projection");
337        let chromium_target = root.path().join("chromium-target");
338        let chromium = root.path().join("chromium");
339        fs::create_dir_all(&candidate).unwrap();
340        fs::create_dir_all(&projection).unwrap();
341        fs::write(&chromium_target, []).unwrap();
342        symlink(&chromium_target, &chromium).unwrap();
343
344        assert_eq!(
345            validate_request(&request(candidate, projection, chromium)).err(),
346            Some("chromium must be an absolute ordinary file".into())
347        );
348    }
349}