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