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