1use kcode_k1_transaction_id::TxId;
2use kcode_k1_web_cache::{
3 CheckIdentity, Digest, ResolutionEntry, ResolutionIdentity, ResolutionManifest, SourceIdentity,
4 UserWebCache,
5};
6use kcode_k1_web_checker_protocol::{Outcome, Report, SelectionInput, WebIdInput};
7use kcode_k1_web_package::{DependencySelector, SourcePackage, WebDependency, WebFamily, WebId};
8use kcode_k1_web_podman::{
9 CheckInput, CheckOutput, CommandDiagnostics, WebPodman, WebPodmanConfig, WebPodmanError,
10};
11use kcode_k1_web_projection::{K1WebProjection, ProjectionSnapshot, PublishError, PublishOutcome};
12use sha2::{Digest as _, Sha256};
13use std::collections::{BTreeMap, btree_map::Entry};
14use std::fmt::{Debug, Display, Formatter};
15use std::fs;
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20type CodingResult<T> = Result<T, WebCodingError>;
21type ResolutionKey = (WebFamily, DependencySelector);
22type ResolutionGraph = BTreeMap<ResolutionKey, Resolved>;
23
24pub struct WebCodingRevisions {
25 pub boot: String,
26 pub schema: String,
27 pub route: String,
28 pub harness: String,
29 pub check_policy: String,
30}
31
32pub struct WebCodingConfig {
33 revisions: WebCodingRevisions,
34 projection_root: PathBuf,
35 podman: Option<WebPodmanConfig>,
36}
37
38impl WebCodingConfig {
39 pub fn new(
40 revisions: WebCodingRevisions,
41 projection_root: PathBuf,
42 podman: WebPodmanConfig,
43 ) -> CodingResult<Self> {
44 let values = [
45 &revisions.boot,
46 &revisions.schema,
47 &revisions.route,
48 &revisions.harness,
49 &revisions.check_policy,
50 ];
51 if values.iter().any(|value| value.is_empty()) {
52 return Err(WebCodingError::State(
53 "Web coding revisions must be nonempty".into(),
54 ));
55 }
56 if !exact_directory(&projection_root) {
57 return Err(WebCodingError::State(
58 "projection root must be a canonical ordinary directory".into(),
59 ));
60 }
61 Ok(Self {
62 revisions,
63 projection_root,
64 podman: Some(podman),
65 })
66 }
67}
68
69#[derive(Debug)]
70pub struct CheckExecution {
71 pub diagnostics: CommandDiagnostics,
72 pub report: Report,
73}
74
75#[derive(Debug)]
76pub enum CheckOutcome {
77 Reused,
78 Checked(Box<CheckExecution>),
79}
80
81#[derive(Debug)]
82pub struct PublishResult {
83 pub check: CheckOutcome,
84 pub outcome: PublishOutcome,
85}
86
87#[derive(Debug)]
88pub enum WebCodingError {
89 State(String),
90 Cache(String),
91 Authorization(String),
92 WorkspaceDenied,
93 DependencyUnavailable,
94 Podman(Box<WebPodmanError>),
95 CheckFailed(Box<CheckExecution>),
96 PublicReleaseDenied,
97 Projection(String),
98 ProjectionAfterSubmit { submitted: String, message: String },
99}
100
101impl Display for WebCodingError {
102 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
103 Debug::fmt(self, formatter)
104 }
105}
106
107impl std::error::Error for WebCodingError {}
108
109#[derive(Clone)]
110struct RuntimeIdentity {
111 checker: Digest,
112 image: Digest,
113 chromium: String,
114 command: String,
115}
116
117#[derive(Clone)]
118struct Resolved {
119 winning: TxId,
120 package: Arc<SourcePackage>,
121}
122
123trait FrozenProjection {
124 fn highest(&self, family: &WebFamily, selector: &DependencySelector) -> Option<Resolved>;
125}
126
127impl FrozenProjection for ProjectionSnapshot {
128 fn highest(&self, family: &WebFamily, selector: &DependencySelector) -> Option<Resolved> {
129 let version = self
130 .versions(family)
131 .into_iter()
132 .rev()
133 .find(|version| selector.matches(version))?;
134 let id = WebId::new(family.clone(), version).ok()?;
135 let winning = self.transaction(&id)?.to_string().parse().ok()?;
136 Some(Resolved {
137 winning,
138 package: self.load(&id)?,
139 })
140 }
141}
142
143struct OpenTimer(Instant);
144
145impl Drop for OpenTimer {
146 fn drop(&mut self) {
147 if self.0.elapsed() > Duration::from_millis(100) {
148 eprintln!("{{\"level\":\"warning\",\"event\":\"k1_web_coding_open_slow\"}}");
149 }
150 }
151}
152
153pub struct K1WebCoding {
154 config: WebCodingConfig,
155 projection: Arc<K1WebProjection>,
156 cache: UserWebCache,
157 podman: WebPodman,
158 runtime: RuntimeIdentity,
159}
160
161impl K1WebCoding {
162 pub fn open(
163 cache_root: impl AsRef<Path>,
164 user: TxId,
165 mut config: WebCodingConfig,
166 projection: Arc<K1WebProjection>,
167 ) -> CodingResult<Self> {
168 let _timer = OpenTimer(Instant::now());
169 let podman = WebPodman::new(config.podman.take().expect("unopened configuration"))
170 .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
171 let runtime = RuntimeIdentity {
172 checker: parse_digest(podman.checker_digest())?,
173 image: parse_digest(podman.image_digest())?,
174 chromium: podman.chromium_version().to_owned(),
175 command: podman.command_policy_identity().to_owned(),
176 };
177 let revisions = &config.revisions;
178 let cache = UserWebCache::open(
179 cache_root,
180 user,
181 &revisions.boot,
182 &revisions.schema,
183 &revisions.check_policy,
184 )
185 .map_err(WebCodingError::Cache)?;
186 Ok(Self {
187 config,
188 projection,
189 cache,
190 podman,
191 runtime,
192 })
193 }
194
195 pub fn cache_epoch(&self) -> u64 {
196 self.cache.epoch()
197 }
198
199 pub fn check(
200 &mut self,
201 candidate: &SourcePackage,
202 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
203 ) -> CodingResult<CheckOutcome> {
204 match authorize_workspace(candidate.id().family()) {
205 Ok(true) => {}
206 Ok(false) => return Err(WebCodingError::WorkspaceDenied),
207 Err(cause) => return Err(WebCodingError::Authorization(cause)),
208 }
209 let source = self
210 .cache
211 .materialize(candidate)
212 .map_err(WebCodingError::Cache)?;
213 let snapshot = self.projection.snapshot().map_err(WebCodingError::State)?;
214 let cursor = projection_cursor(&snapshot)?;
215 let graph = resolve_graph(&snapshot, candidate)?;
216 let manifest = ResolutionManifest::new(
217 candidate,
218 source,
219 cursor,
220 direct_entries(candidate, &graph)?,
221 )
222 .map_err(WebCodingError::State)?;
223 let resolution = self
224 .cache
225 .record_resolution(&manifest)
226 .map_err(WebCodingError::Cache)?;
227 let identity = receipt_identity(
228 self.cache.epoch(),
229 source,
230 resolution,
231 graph_digest(&graph),
232 cursor,
233 &self.runtime,
234 &self.config.revisions,
235 );
236 if self
237 .cache
238 .has_check(&identity)
239 .map_err(WebCodingError::Cache)?
240 {
241 return Ok(CheckOutcome::Reused);
242 }
243 let input = check_input(
244 candidate,
245 self.cache.source_root(),
246 &self.config.projection_root,
247 &graph,
248 );
249 let CheckOutput {
250 diagnostics,
251 report,
252 } = self
253 .podman
254 .check(input)
255 .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
256 let execution = Box::new(CheckExecution {
257 diagnostics,
258 report,
259 });
260 if !matches!(&execution.report.outcome, Outcome::Success) {
261 return Err(WebCodingError::CheckFailed(execution));
262 }
263 self.cache
264 .record_check(&identity)
265 .map_err(WebCodingError::Cache)?;
266 Ok(CheckOutcome::Checked(execution))
267 }
268
269 pub fn publish(
270 &mut self,
271 candidate: &SourcePackage,
272 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
273 authorize_public_release: &dyn Fn(&SourcePackage, Digest) -> Result<bool, String>,
274 ) -> CodingResult<PublishResult> {
275 let check = self.check(candidate, authorize_workspace)?;
276 let source = self
277 .cache
278 .source_identity()
279 .map_err(WebCodingError::Cache)?
280 .ok_or_else(|| WebCodingError::State("checked source identity is missing".into()))?;
281 match authorize_public_release(candidate, source.digest) {
282 Ok(true) => {}
283 Ok(false) => return Err(WebCodingError::PublicReleaseDenied),
284 Err(cause) => return Err(WebCodingError::Authorization(cause)),
285 }
286 let outcome = self
287 .projection
288 .publish(candidate)
289 .map_err(publication_error)?;
290 Ok(PublishResult { check, outcome })
291 }
292
293 pub fn reset(&mut self) -> CodingResult<()> {
294 let revisions = &self.config.revisions;
295 self.cache
296 .reset(&revisions.boot, &revisions.schema, &revisions.check_policy)
297 .map_err(WebCodingError::Cache)
298 }
299}
300
301fn projection_cursor(snapshot: &ProjectionSnapshot) -> CodingResult<Option<TxId>> {
302 snapshot
303 .cursor()
304 .map(|cursor| cursor.to_string().parse::<TxId>())
305 .transpose()
306 .map_err(|cause| WebCodingError::State(cause.to_string()))
307}
308
309fn resolve_graph(
310 snapshot: &impl FrozenProjection,
311 candidate: &SourcePackage,
312) -> CodingResult<ResolutionGraph> {
313 let mut pending = candidate
314 .dependencies()
315 .iter()
316 .map(dependency_key)
317 .collect::<CodingResult<Vec<_>>>()?;
318 let mut graph = BTreeMap::new();
319 while let Some(key) = pending.pop() {
320 let Entry::Vacant(entry) = graph.entry(key) else {
321 continue;
322 };
323 let resolved = snapshot
324 .highest(&entry.key().0, &entry.key().1)
325 .ok_or(WebCodingError::DependencyUnavailable)?;
326 for dependency in resolved.package.dependencies() {
327 pending.push(dependency_key(dependency)?);
328 }
329 entry.insert(resolved);
330 }
331 Ok(graph)
332}
333
334fn dependency_key(dependency: &WebDependency) -> CodingResult<ResolutionKey> {
335 let family = WebFamily::new(dependency.authority(), dependency.name().to_owned())
336 .map_err(|cause| WebCodingError::State(cause.to_string()))?;
337 Ok((family, dependency.selector().clone()))
338}
339
340fn direct_entries(
341 candidate: &SourcePackage,
342 graph: &ResolutionGraph,
343) -> CodingResult<Vec<ResolutionEntry>> {
344 candidate
345 .dependencies()
346 .iter()
347 .map(|dependency| {
348 let key = dependency_key(dependency)?;
349 let resolved = graph
350 .get(&key)
351 .ok_or_else(|| WebCodingError::State("resolved route is missing".into()))?;
352 Ok(ResolutionEntry {
353 family: key.0,
354 selector: key.1,
355 resolved: resolved.package.id().clone(),
356 winning: resolved.winning,
357 })
358 })
359 .collect()
360}
361
362fn check_input(
363 candidate: &SourcePackage,
364 candidate_root: PathBuf,
365 projection_root: &Path,
366 graph: &ResolutionGraph,
367) -> CheckInput {
368 let selections = graph
369 .iter()
370 .map(|((family, selector), resolved)| SelectionInput {
371 family_authority: family.authority().to_string(),
372 family_name: family.logical_name().to_owned(),
373 selector: selector.to_string(),
374 resolved: web_id_input(resolved.package.id()),
375 })
376 .collect();
377 CheckInput {
378 candidate: web_id_input(candidate.id()),
379 candidate_root,
380 projection_root: projection_root.to_path_buf(),
381 entry: candidate.entry().to_owned(),
382 tests: candidate.tests().to_owned(),
383 selections,
384 }
385}
386
387fn web_id_input(id: &WebId) -> WebIdInput {
388 WebIdInput {
389 authority: id.family().authority().to_string(),
390 name: id.family().logical_name().to_owned(),
391 version: id.version().to_string(),
392 }
393}
394
395fn graph_digest(graph: &ResolutionGraph) -> Digest {
396 let mut hash = Sha256::new();
397 append(&mut hash, b"K1WEBGRAPH1");
398 for ((family, selector), resolved) in graph {
399 append(&mut hash, family.authority().transaction_id().as_bytes());
400 append(&mut hash, family.logical_name().as_bytes());
401 append(&mut hash, selector.to_string().as_bytes());
402 append(
403 &mut hash,
404 resolved.package.id().version().to_string().as_bytes(),
405 );
406 append(&mut hash, resolved.winning.as_bytes());
407 for file in resolved.package.files() {
408 append(&mut hash, file.path().as_bytes());
409 append(&mut hash, file.bytes());
410 }
411 }
412 Digest(hash.finalize().into())
413}
414
415fn append(hash: &mut Sha256, bytes: &[u8]) {
416 hash.update((bytes.len() as u64).to_be_bytes());
417 hash.update(bytes);
418}
419
420fn parse_digest(value: &str) -> CodingResult<Digest> {
421 let text = value
422 .strip_prefix("sha256:")
423 .ok_or_else(|| WebCodingError::State("runtime digest is not SHA-256".into()))?;
424 let lowercase_hex = |byte: u8| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f');
425 if text.len() != 64 || !text.bytes().all(lowercase_hex) {
426 return Err(WebCodingError::State(
427 "runtime digest is not canonical SHA-256".into(),
428 ));
429 }
430 let mut bytes = [0; 32];
431 for (index, byte) in bytes.iter_mut().enumerate() {
432 *byte = u8::from_str_radix(&text[index * 2..index * 2 + 2], 16)
433 .map_err(|cause| WebCodingError::State(cause.to_string()))?;
434 }
435 Ok(Digest(bytes))
436}
437
438fn receipt_identity(
439 cache_epoch: u64,
440 source: SourceIdentity,
441 resolution: ResolutionIdentity,
442 graph: Digest,
443 projection_cursor: Option<TxId>,
444 runtime: &RuntimeIdentity,
445 revisions: &WebCodingRevisions,
446) -> CheckIdentity {
447 CheckIdentity {
448 cache_epoch,
449 source,
450 resolution,
451 graph,
452 projection_cursor,
453 checker_executable: runtime.checker,
454 container_image: runtime.image,
455 chromium_version: runtime.chromium.clone(),
456 route_revision: revisions.route.clone(),
457 harness_revision: revisions.harness.clone(),
458 check_policy_revision: revisions.check_policy.clone(),
459 command_policy: runtime.command.clone(),
460 }
461}
462
463fn publication_error(error: PublishError) -> WebCodingError {
464 match error.submitted {
465 Some(submitted) => WebCodingError::ProjectionAfterSubmit {
466 submitted: submitted.to_string(),
467 message: error.message,
468 },
469 None => WebCodingError::Projection(error.message),
470 }
471}
472
473fn exact_directory(path: &Path) -> bool {
474 fs::symlink_metadata(path).is_ok_and(|value| value.is_dir() && !value.file_type().is_symlink())
475 && fs::canonicalize(path).is_ok_and(|value| value == path)
476}
477
478#[cfg(test)]
479mod tests;