1use kcode_k1_transaction_id::TxId;
2use kcode_k1_web_cache::{Digest, ResolutionManifest, UserWebCache};
3use kcode_k1_web_check_plan::{CheckRevisions, RuntimeIdentity, check_identity, check_input};
4use kcode_k1_web_checker_protocol::{Outcome, Report};
5use kcode_k1_web_package::{SourcePackage, WebFamily, WebId};
6use kcode_k1_web_podman::{
7 CheckOutput, CommandDiagnostics, WebPodman, WebPodmanConfig, WebPodmanError,
8};
9use kcode_k1_web_projection::{K1WebProjection, PublishError, PublishOutcome};
10use kcode_k1_web_release_gate::{ReleaseGateError, admit_source_preserved_release};
11use kcode_k1_web_selection::{SelectionError, select};
12use std::fmt::{Debug, Display, Formatter};
13use std::fs;
14use std::path::{Path, PathBuf};
15use std::sync::Arc;
16use std::time::{Duration, Instant};
17
18type CodingResult<T> = Result<T, WebCodingError>;
19
20pub struct WebCodingRevisions {
21 pub boot: String,
22 pub schema: String,
23 pub route: String,
24 pub harness: String,
25 pub check_policy: String,
26}
27
28pub struct WebCodingConfig {
29 revisions: WebCodingRevisions,
30 projection_root: PathBuf,
31 podman: Option<WebPodmanConfig>,
32}
33
34impl WebCodingConfig {
35 pub fn new(
36 revisions: WebCodingRevisions,
37 projection_root: PathBuf,
38 podman: WebPodmanConfig,
39 ) -> CodingResult<Self> {
40 let values = [
41 &revisions.boot,
42 &revisions.schema,
43 &revisions.route,
44 &revisions.harness,
45 &revisions.check_policy,
46 ];
47 if values.iter().any(|value| value.is_empty()) {
48 return Err(WebCodingError::State(
49 "Web coding revisions must be nonempty".into(),
50 ));
51 }
52 if !exact_directory(&projection_root) {
53 return Err(WebCodingError::State(
54 "projection root must be a canonical ordinary directory".into(),
55 ));
56 }
57 Ok(Self {
58 revisions,
59 projection_root,
60 podman: Some(podman),
61 })
62 }
63}
64
65#[derive(Debug)]
66pub struct CheckExecution {
67 pub diagnostics: CommandDiagnostics,
68 pub report: Report,
69}
70
71#[derive(Debug)]
72pub enum CheckOutcome {
73 Reused,
74 Checked(Box<CheckExecution>),
75}
76
77#[derive(Debug)]
78pub struct PublishResult {
79 pub source: Arc<SourcePackage>,
80 pub check: CheckOutcome,
81 pub outcome: PublishOutcome,
82}
83
84#[derive(Debug)]
85pub enum WebCodingError {
86 State(String),
87 Cache(String),
88 Authorization(String),
89 WorkspaceDenied,
90 CandidateUnavailable,
91 DependencyUnavailable,
92 Podman(Box<WebPodmanError>),
93 CheckFailed(Box<CheckExecution>),
94 SourcePreservation(String),
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 OpenTimer(Instant);
109
110impl Drop for OpenTimer {
111 fn drop(&mut self) {
112 if self.0.elapsed() > Duration::from_millis(100) {
113 eprintln!("{{\"level\":\"warning\",\"event\":\"k1_web_coding_open_slow\"}}");
114 }
115 }
116}
117
118pub struct K1WebCoding {
119 config: WebCodingConfig,
120 projection: Arc<K1WebProjection>,
121 cache: UserWebCache,
122 podman: WebPodman,
123 runtime: RuntimeIdentity,
124}
125
126impl K1WebCoding {
127 pub fn open(
128 cache_root: impl AsRef<Path>,
129 user: TxId,
130 mut config: WebCodingConfig,
131 projection: Arc<K1WebProjection>,
132 ) -> CodingResult<Self> {
133 let _timer = OpenTimer(Instant::now());
134 let podman = WebPodman::new(config.podman.take().expect("unopened configuration"))
135 .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
136 let runtime = RuntimeIdentity::new(
137 podman.checker_digest(),
138 podman.image_digest(),
139 podman.chromium_version(),
140 podman.frozen_command_policy_identity(),
141 )
142 .map_err(|error| WebCodingError::State(error.to_string()))?;
143 let revisions = &config.revisions;
144 let cache = UserWebCache::open(
145 cache_root,
146 user,
147 &revisions.boot,
148 &revisions.schema,
149 &revisions.check_policy,
150 )
151 .map_err(|cause| WebCodingError::Cache(format!("open user cache: {cause}")))?;
152 Ok(Self {
153 config,
154 projection,
155 cache,
156 podman,
157 runtime,
158 })
159 }
160
161 pub fn cache_epoch(&self) -> u64 {
162 self.cache.epoch()
163 }
164
165 pub fn view(
166 &self,
167 id: &WebId,
168 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
169 ) -> CodingResult<Option<Arc<SourcePackage>>> {
170 let Some(candidate) = self.retained_candidate(id)? else {
171 return self.projection.load(id).map_err(|cause| {
172 WebCodingError::State(format!("load public projection: {cause}"))
173 });
174 };
175 match authorize_workspace(id.family()) {
176 Ok(true) => Ok(Some(candidate)),
177 Ok(false) => self
178 .projection
179 .load(id)
180 .map_err(|cause| WebCodingError::State(format!("load public projection: {cause}"))),
181 Err(cause) => Err(WebCodingError::Authorization(cause)),
182 }
183 }
184
185 pub fn write(
186 &mut self,
187 candidate: &SourcePackage,
188 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
189 ) -> CodingResult<Arc<SourcePackage>> {
190 authorize(candidate.id().family(), authorize_workspace)?;
191 cache_result(self.cache.materialize(candidate), "materialize candidate")?;
192 self.retained_candidate(candidate.id())?
193 .ok_or(WebCodingError::CandidateUnavailable)
194 }
195
196 pub fn check(
197 &mut self,
198 id: &WebId,
199 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
200 ) -> CodingResult<CheckOutcome> {
201 authorize(id.family(), authorize_workspace)?;
202 let candidate = self
203 .retained_candidate(id)?
204 .ok_or(WebCodingError::CandidateUnavailable)?;
205 self.check_retained(&candidate, true)
206 .map(|(outcome, _)| outcome)
207 }
208
209 pub fn check_fresh(
210 &mut self,
211 id: &WebId,
212 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
213 ) -> CodingResult<CheckOutcome> {
214 authorize(id.family(), authorize_workspace)?;
215 let candidate = self
216 .retained_candidate(id)?
217 .ok_or(WebCodingError::CandidateUnavailable)?;
218 self.check_retained(&candidate, false)
219 .map(|(outcome, _)| outcome)
220 }
221
222 pub fn publish(
223 &mut self,
224 id: &WebId,
225 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
226 authorize_public_release: &dyn Fn(&SourcePackage, Digest) -> Result<bool, String>,
227 ) -> CodingResult<PublishResult> {
228 self.publish_with_source_preservation(
229 id,
230 authorize_workspace,
231 &|_, _| Ok(()),
232 authorize_public_release,
233 )
234 }
235
236 pub fn publish_with_source_preservation(
237 &mut self,
238 id: &WebId,
239 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
240 preserve_source: &dyn Fn(&SourcePackage, Digest) -> Result<(), String>,
241 authorize_public_release: &dyn Fn(&SourcePackage, Digest) -> Result<bool, String>,
242 ) -> CodingResult<PublishResult> {
243 authorize(id.family(), authorize_workspace)?;
244 let candidate = self
245 .retained_candidate(id)?
246 .ok_or(WebCodingError::CandidateUnavailable)?;
247 let (check, digest) = self.check_retained(&candidate, true)?;
248 admit_source_preserved_release(
249 &candidate,
250 digest,
251 preserve_source,
252 authorize_public_release,
253 )
254 .map_err(release_gate_error)?;
255 let outcome = self
256 .projection
257 .publish(&candidate)
258 .map_err(publication_error)?;
259 Ok(PublishResult {
260 source: candidate,
261 check,
262 outcome,
263 })
264 }
265
266 pub fn reset(&mut self) -> CodingResult<()> {
267 let revisions = &self.config.revisions;
268 cache_result(
269 self.cache
270 .reset(&revisions.boot, &revisions.schema, &revisions.check_policy),
271 "reset user cache",
272 )
273 }
274
275 fn retained_candidate(&self, id: &WebId) -> CodingResult<Option<Arc<SourcePackage>>> {
276 cache_result(self.cache.candidate(id), "load retained candidate")
277 .map(|candidate| candidate.map(Arc::new))
278 }
279
280 fn check_retained(
281 &mut self,
282 candidate: &SourcePackage,
283 reuse_receipt: bool,
284 ) -> CodingResult<(CheckOutcome, Digest)> {
285 let source = cache_result(
286 self.cache.source_identity(),
287 "read retained source identity",
288 )?
289 .ok_or(WebCodingError::CandidateUnavailable)?;
290 let snapshot = self.projection.snapshot().map_err(|cause| {
291 WebCodingError::State(format!("capture projection snapshot: {cause}"))
292 })?;
293 let selected = select(&snapshot, candidate).map_err(selection_error)?;
294 let manifest = ResolutionManifest::from_snapshot(candidate, source, selected)
295 .map_err(|cause| WebCodingError::State(format!("build frozen resolution: {cause}")))?;
296 let resolution = cache_result(
297 self.cache.record_resolution(&manifest),
298 "record frozen resolution",
299 )?;
300 let revisions = CheckRevisions::new(
301 self.config.revisions.route.as_str(),
302 self.config.revisions.harness.as_str(),
303 self.config.revisions.check_policy.as_str(),
304 );
305 let identity = check_identity(
306 self.cache.epoch(),
307 source,
308 resolution,
309 manifest.view_digest(),
310 &self.runtime,
311 &revisions,
312 );
313 if reuse_receipt && cache_result(self.cache.has_check(&identity), "read check receipt")? {
314 return Ok((CheckOutcome::Reused, source.digest));
315 }
316 let input = check_input(
317 candidate,
318 self.cache.source_root(),
319 self.cache.resolution_root(),
320 manifest.entries(),
321 );
322 let CheckOutput {
323 diagnostics,
324 report,
325 } = self
326 .podman
327 .check_frozen(input, &self.config.projection_root)
328 .map_err(|cause| WebCodingError::Podman(Box::new(cause)))?;
329 let execution = Box::new(CheckExecution {
330 diagnostics,
331 report,
332 });
333 if !matches!(&execution.report.outcome, Outcome::Success) {
334 return Err(WebCodingError::CheckFailed(execution));
335 }
336 cache_result(self.cache.record_check(&identity), "record check receipt")?;
337 Ok((CheckOutcome::Checked(execution), source.digest))
338 }
339}
340
341fn cache_result<T>(result: Result<T, impl Display>, action: &str) -> CodingResult<T> {
342 result.map_err(|cause| WebCodingError::Cache(format!("{action}: {cause}")))
343}
344
345fn authorize(
346 family: &WebFamily,
347 authorize_workspace: &dyn Fn(&WebFamily) -> Result<bool, String>,
348) -> CodingResult<()> {
349 match authorize_workspace(family) {
350 Ok(true) => Ok(()),
351 Ok(false) => Err(WebCodingError::WorkspaceDenied),
352 Err(cause) => Err(WebCodingError::Authorization(cause)),
353 }
354}
355
356fn release_gate_error(error: ReleaseGateError) -> WebCodingError {
357 match error {
358 ReleaseGateError::SourcePreservation(message) => {
359 WebCodingError::SourcePreservation(message)
360 }
361 ReleaseGateError::Authorization(message) => WebCodingError::Authorization(message),
362 ReleaseGateError::PublicReleaseDenied => WebCodingError::PublicReleaseDenied,
363 }
364}
365
366fn selection_error(error: SelectionError) -> WebCodingError {
367 match error {
368 SelectionError::DependencyUnavailable => WebCodingError::DependencyUnavailable,
369 SelectionError::InvalidDependency(message) => {
370 WebCodingError::State(format!("select projection dependency: {message}"))
371 }
372 }
373}
374
375fn publication_error(error: PublishError) -> WebCodingError {
376 match error.submitted {
377 Some(submitted) => WebCodingError::ProjectionAfterSubmit {
378 submitted: submitted.to_string(),
379 message: format!("publish projection: {}", error.message),
380 },
381 None => WebCodingError::Projection(format!("publish projection: {}", error.message)),
382 }
383}
384
385fn exact_directory(path: &Path) -> bool {
386 fs::symlink_metadata(path).is_ok_and(|value| value.is_dir() && !value.file_type().is_symlink())
387 && fs::canonicalize(path).is_ok_and(|value| value == path)
388}
389
390#[cfg(test)]
391mod tests;