Skip to main content

kcode_k1_web_coding/
lib.rs

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;