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}