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 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;