Skip to main content

kcode_k1_web_bootstrap_import/
lib.rs

1#![doc = include_str!("../Documentation.md")]
2#![forbid(unsafe_code)]
3
4use kcode_k1_transaction_id::TxId;
5use kcode_k1_web_bootstrap_archive::{Archive, Package};
6use kcode_k1_web_coding::{CheckOutcome, K1WebCoding, WebCodingConfig, WebCodingRevisions};
7use kcode_k1_web_package::{
8    AuthorityId, DependencySelector, SourceFile, SourcePackage, WebFamily, WebId,
9};
10use kcode_k1_web_podman::WebPodmanConfig;
11use kcode_k1_web_projection::{K1WebProjection, PublishStatus};
12use semver::Version;
13use serde::Serialize;
14use sha2::{Digest, Sha256};
15use std::collections::{BTreeMap, BTreeSet};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct ImporterRevisions {
21    pub boot: String,
22    pub schema: String,
23    pub route: String,
24    pub harness: String,
25    pub check_policy: String,
26}
27
28#[derive(Clone, Debug, Eq, PartialEq)]
29pub struct ImportedPackage {
30    name: String,
31    version: Version,
32    source_sha256: [u8; 32],
33}
34
35impl ImportedPackage {
36    pub fn name(&self) -> &str {
37        &self.name
38    }
39
40    pub fn version(&self) -> &Version {
41        &self.version
42    }
43
44    pub const fn source_sha256(&self) -> [u8; 32] {
45        self.source_sha256
46    }
47}
48
49#[derive(Clone)]
50pub struct WebBootstrapImporter {
51    inner: Arc<Inner>,
52}
53
54struct Inner {
55    cache_root: PathBuf,
56    projection_root: PathBuf,
57    revisions: ImporterRevisions,
58    podman: WebPodmanConfig,
59    projection: Arc<K1WebProjection>,
60}
61
62impl WebBootstrapImporter {
63    pub fn new(
64        cache_root: impl Into<PathBuf>,
65        projection_root: impl Into<PathBuf>,
66        revisions: ImporterRevisions,
67        podman: WebPodmanConfig,
68        projection: Arc<K1WebProjection>,
69    ) -> Result<Self, String> {
70        let cache_root = cache_root.into();
71        let projection_root = projection_root.into();
72        require_directory(&cache_root, "Web bootstrap cache")?;
73        require_directory(&projection_root, "Web projection")?;
74        if [
75            &revisions.boot,
76            &revisions.schema,
77            &revisions.route,
78            &revisions.harness,
79            &revisions.check_policy,
80        ]
81        .iter()
82        .any(|value| value.is_empty())
83        {
84            return Err("Web bootstrap importer revisions must be nonempty".to_owned());
85        }
86        Ok(Self {
87            inner: Arc::new(Inner {
88                cache_root,
89                projection_root,
90                revisions,
91                podman,
92                projection,
93            }),
94        })
95    }
96
97    pub fn import_all(
98        &self,
99        archive: &Archive,
100        authority: [u8; 12],
101        user: [u8; 12],
102    ) -> Result<Vec<ImportedPackage>, String> {
103        let authority = AuthorityId::new(TxId::from_bytes(authority));
104        let prepared = prepare_all(archive, authority)?;
105
106        let revisions = &self.inner.revisions;
107        let config = WebCodingConfig::new(
108            WebCodingRevisions {
109                boot: revisions.boot.clone(),
110                schema: revisions.schema.clone(),
111                route: revisions.route.clone(),
112                harness: revisions.harness.clone(),
113                check_policy: revisions.check_policy.clone(),
114            },
115            self.inner.projection_root.clone(),
116            self.inner.podman.clone(),
117        )
118        .map_err(|error| format!("configure Web bootstrap coding: {error}"))?;
119        let mut coding = K1WebCoding::open(
120            &self.inner.cache_root,
121            TxId::from_bytes(user),
122            config,
123            Arc::clone(&self.inner.projection),
124        )
125        .map_err(|error| format!("open Web bootstrap coding: {error}"))?;
126        let authorize = |family: &WebFamily| Ok(family.authority() == authority);
127
128        let mut imported = Vec::with_capacity(prepared.len());
129        for item in prepared {
130            let label = format!("{} {}", item.coordinate.name, item.coordinate.version);
131            coding
132                .write(&item.source, &authorize)
133                .map_err(|error| format!("stage Web bootstrap package {label}: {error}"))?;
134            let check = coding
135                .check_fresh(item.source.id(), &authorize)
136                .map_err(|error| format!("check Web bootstrap package {label}: {error}"))?;
137            if !matches!(check, CheckOutcome::Checked(_)) {
138                return Err(format!("fresh Web check was reused for {label}"));
139            }
140
141            match self
142                .inner
143                .projection
144                .load(item.source.id())
145                .map_err(|error| format!("inspect published Web package {label}: {error}"))?
146            {
147                Some(existing) if existing.as_ref() == &item.source => {}
148                Some(_) => {
149                    return Err(format!(
150                        "published Web package {label} differs from bootstrap source"
151                    ));
152                }
153                None => {
154                    let completion = coding
155                        .publish(item.source.id(), &authorize, &|_, _| Ok(true))
156                        .map_err(|error| {
157                            format!("publish Web bootstrap package {label}: {error}")
158                        })?;
159                    if completion.outcome.status == PublishStatus::Conflict {
160                        return Err(format!(
161                            "published Web package {label} conflicts with bootstrap source"
162                        ));
163                    }
164                }
165            }
166
167            imported.push(ImportedPackage {
168                name: item.coordinate.name,
169                version: item.coordinate.version,
170                source_sha256: item.source_sha256,
171            });
172        }
173        Ok(imported)
174    }
175}
176
177#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)]
178struct Coordinate {
179    name: String,
180    version: Version,
181}
182
183struct PlannedPackage {
184    index: usize,
185    dependencies: Vec<GeneratedDependency>,
186    edges: BTreeSet<Coordinate>,
187}
188
189#[derive(Debug)]
190struct PreparedPackage {
191    coordinate: Coordinate,
192    source_sha256: [u8; 32],
193    source: SourcePackage,
194}
195
196#[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd, Serialize)]
197struct GeneratedDependency {
198    authority: String,
199    name: String,
200    selector: String,
201}
202
203#[derive(Serialize)]
204struct Header<'a> {
205    dependencies: &'a [GeneratedDependency],
206}
207
208#[derive(Serialize)]
209struct Manifest<'a> {
210    name: &'a str,
211    version: String,
212    entry: &'a str,
213    tests: &'a str,
214    dependencies: &'a [GeneratedDependency],
215}
216
217fn prepare_all(archive: &Archive, authority: AuthorityId) -> Result<Vec<PreparedPackage>, String> {
218    let mut coordinates = BTreeMap::new();
219    for (index, package) in archive.packages().iter().enumerate() {
220        let coordinate = Coordinate {
221            name: package.name().to_owned(),
222            version: package.version().clone(),
223        };
224        if coordinates.insert(coordinate.clone(), index).is_some() {
225            return Err(format!(
226                "duplicate Web bootstrap package {} {}",
227                coordinate.name, coordinate.version
228            ));
229        }
230    }
231
232    let authority_text = authority.to_string();
233    let mut plans = BTreeMap::new();
234    for (coordinate, index) in &coordinates {
235        let package = &archive.packages()[*index];
236        let mut dependencies = Vec::new();
237        let mut edges = BTreeSet::new();
238
239        for dependency in package.dependencies() {
240            WebFamily::new(authority, dependency.name().to_owned()).map_err(|error| {
241                format!(
242                    "invalid dependency {} from {} {}: {error}",
243                    dependency.name(),
244                    coordinate.name,
245                    coordinate.version
246                )
247            })?;
248            let selector = DependencySelector::parse(dependency.selector()).map_err(|error| {
249                format!(
250                    "invalid selector {} for dependency {} from {} {}: {error}",
251                    dependency.selector(),
252                    dependency.name(),
253                    coordinate.name,
254                    coordinate.version
255                )
256            })?;
257
258            if dependency.name() == coordinate.name && selector.matches(&coordinate.version) {
259                continue;
260            }
261
262            let selected = coordinates
263                .keys()
264                .filter(|candidate| {
265                    candidate.name == dependency.name() && selector.matches(&candidate.version)
266                })
267                .max_by(|left, right| left.version.cmp(&right.version))
268                .cloned()
269                .ok_or_else(|| {
270                    format!(
271                        "unresolved Web bootstrap dependency {} {} from {} {}",
272                        dependency.name(),
273                        dependency.selector(),
274                        coordinate.name,
275                        coordinate.version
276                    )
277                })?;
278
279            edges.insert(selected);
280            dependencies.push(GeneratedDependency {
281                authority: authority_text.clone(),
282                name: dependency.name().to_owned(),
283                selector: dependency.selector().to_owned(),
284            });
285        }
286
287        dependencies.sort();
288        plans.insert(
289            coordinate.clone(),
290            PlannedPackage {
291                index: *index,
292                dependencies,
293                edges,
294            },
295        );
296    }
297
298    let order = dependency_order(&plans)?;
299    let mut prepared = Vec::with_capacity(order.len());
300    for coordinate in order {
301        let plan = plans
302            .get(&coordinate)
303            .expect("dependency order contains only planned coordinates");
304        let package = &archive.packages()[plan.index];
305        let source = prepare_package(package, authority, &plan.dependencies)?;
306        prepared.push(PreparedPackage {
307            coordinate,
308            source_sha256: source_digest(&source),
309            source,
310        });
311    }
312    Ok(prepared)
313}
314
315fn dependency_order(
316    plans: &BTreeMap<Coordinate, PlannedPackage>,
317) -> Result<Vec<Coordinate>, String> {
318    fn visit(
319        coordinate: &Coordinate,
320        plans: &BTreeMap<Coordinate, PlannedPackage>,
321        states: &mut BTreeMap<Coordinate, u8>,
322        stack: &mut Vec<Coordinate>,
323        order: &mut Vec<Coordinate>,
324    ) -> Result<(), String> {
325        match states.get(coordinate).copied() {
326            Some(2) => return Ok(()),
327            Some(1) => {
328                let start = stack
329                    .iter()
330                    .position(|item| item == coordinate)
331                    .unwrap_or(0);
332                let mut cycle = stack[start..].to_vec();
333                cycle.push(coordinate.clone());
334                let text = cycle
335                    .iter()
336                    .map(|item| format!("{} {}", item.name, item.version))
337                    .collect::<Vec<_>>()
338                    .join(" -> ");
339                return Err(format!("non-self Web bootstrap dependency cycle: {text}"));
340            }
341            _ => {}
342        }
343
344        states.insert(coordinate.clone(), 1);
345        stack.push(coordinate.clone());
346        for dependency in &plans
347            .get(coordinate)
348            .expect("visited coordinate is planned")
349            .edges
350        {
351            visit(dependency, plans, states, stack, order)?;
352        }
353        let popped = stack.pop();
354        debug_assert_eq!(popped.as_ref(), Some(coordinate));
355        states.insert(coordinate.clone(), 2);
356        order.push(coordinate.clone());
357        Ok(())
358    }
359
360    let mut states = BTreeMap::new();
361    let mut stack = Vec::new();
362    let mut order = Vec::with_capacity(plans.len());
363    for coordinate in plans.keys() {
364        visit(coordinate, plans, &mut states, &mut stack, &mut order)?;
365    }
366    Ok(order)
367}
368
369fn prepare_package(
370    package: &Package,
371    authority: AuthorityId,
372    dependencies: &[GeneratedDependency],
373) -> Result<SourcePackage, String> {
374    let family = WebFamily::new(authority, package.name().to_owned())
375        .map_err(|error| format!("assign Web package {} authority: {error}", package.name()))?;
376    let id = WebId::new(family, package.version().clone())
377        .map_err(|error| format!("assign Web package {} version: {error}", package.name()))?;
378    let authority_text = authority.to_string();
379
380    let original_documentation = package
381        .files()
382        .iter()
383        .find(|file| file.path() == "Documentation.md")
384        .ok_or_else(|| {
385            format!(
386                "Web bootstrap package {} {} lacks Documentation.md",
387                package.name(),
388                package.version()
389            )
390        })?
391        .bytes();
392
393    let header = serde_json::to_string(&Header { dependencies }).map_err(|error| {
394        format!(
395            "encode Web dependencies for {} {}: {error}",
396            package.name(),
397            package.version()
398        )
399    })?;
400    let mut documentation = format!("<!-- k1-web/v1\n{header}\n-->\n").into_bytes();
401    documentation.extend_from_slice(original_documentation);
402
403    let manifest = serde_json::to_vec(&Manifest {
404        name: package.name(),
405        version: package.version().to_string(),
406        entry: package.entry(),
407        tests: package.tests(),
408        dependencies,
409    })
410    .map_err(|error| {
411        format!(
412            "encode Web manifest for {} {}: {error}",
413            package.name(),
414            package.version()
415        )
416    })?;
417
418    let mut files = Vec::with_capacity(package.files().len() + 1);
419    for file in package.files() {
420        if matches!(file.path(), "Documentation.md" | "k1-web.json") {
421            continue;
422        }
423        files.push(SourceFile::new(
424            file.path(),
425            rewrite_file(
426                file.path(),
427                file.bytes(),
428                &authority_text,
429                package.dependencies(),
430            ),
431        ));
432    }
433    files.push(SourceFile::new("Documentation.md", documentation));
434    files.push(SourceFile::new("k1-web.json", manifest));
435
436    SourcePackage::new(id, files).map_err(|error| {
437        format!(
438            "construct Web bootstrap package {} {}: {error}",
439            package.name(),
440            package.version()
441        )
442    })
443}
444
445fn rewrite_file(
446    path: &str,
447    bytes: &[u8],
448    authority: &str,
449    dependencies: &[kcode_k1_web_bootstrap_archive::Dependency],
450) -> Vec<u8> {
451    if !matches!(extension(path), "js" | "mjs" | "html" | "css") {
452        return bytes.to_vec();
453    }
454    let Ok(source) = std::str::from_utf8(bytes) else {
455        return bytes.to_vec();
456    };
457
458    let mut rewritten = source.to_owned();
459    let declarations = dependencies
460        .iter()
461        .map(|dependency| {
462            (
463                dependency.name().to_owned(),
464                dependency.selector().to_owned(),
465            )
466        })
467        .collect::<BTreeSet<_>>();
468    for (name, selector) in declarations {
469        let legacy = format!("/module/{name}/v{selector}/");
470        let route_selector = if selector == "*" {
471            "*".to_owned()
472        } else {
473            format!("v{selector}")
474        };
475        let current = format!("/{authority}/{name}/{route_selector}/");
476        rewritten = rewritten.replace(&legacy, &current);
477    }
478    rewritten.into_bytes()
479}
480
481fn extension(path: &str) -> &str {
482    path.rsplit_once('.').map_or("", |(_, extension)| extension)
483}
484
485fn source_digest(source: &SourcePackage) -> [u8; 32] {
486    let mut hash = Sha256::new();
487    for file in source.files() {
488        hash.update((file.path().len() as u64).to_le_bytes());
489        hash.update(file.path().as_bytes());
490        hash.update((file.bytes().len() as u64).to_le_bytes());
491        hash.update(file.bytes());
492    }
493    hash.finalize().into()
494}
495
496fn require_directory(path: &Path, label: &str) -> Result<(), String> {
497    let metadata = std::fs::symlink_metadata(path)
498        .map_err(|error| format!("inspect {label} {}: {error}", path.display()))?;
499    if metadata.is_dir() && !metadata.file_type().is_symlink() {
500        Ok(())
501    } else {
502        Err(format!("{label} is not an ordinary directory"))
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509    use kcode_k1_web_bootstrap_archive::read;
510    use serde_json::Value;
511    use std::fs::File;
512    use std::io::Write;
513    use zip::write::SimpleFileOptions;
514
515    fn archive(root: &Path, name: &str, manifest: &str, entries: &[(&str, &[u8])]) -> Archive {
516        let path = root.join(name);
517        let file = File::create(&path).unwrap();
518        let mut zip = zip::ZipWriter::new(file);
519        zip.start_file("manifest.json", SimpleFileOptions::default())
520            .unwrap();
521        zip.write_all(manifest.as_bytes()).unwrap();
522        for (path, bytes) in entries {
523            zip.start_file(*path, SimpleFileOptions::default()).unwrap();
524            zip.write_all(bytes).unwrap();
525        }
526        zip.finish().unwrap();
527        read(&path).unwrap().archive
528    }
529
530    fn file<'a>(source: &'a SourcePackage, path: &str) -> &'a [u8] {
531        source
532            .files()
533            .iter()
534            .find(|file| file.path() == path)
535            .unwrap()
536            .bytes()
537    }
538
539    #[test]
540    fn prepares_complete_trees_current_routes_and_exact_metadata() {
541        let root = tempfile::tempdir().unwrap();
542        let manifest = r#"{"schema":1,"packages":[{"name":"dep","version":"1.5.0","entry":"index.js","tests":"tests.js","dependencies":[]},{"name":"dep","version":"2.1.0","entry":"index.js","tests":"tests.js","dependencies":[]},{"name":"bridge","version":"1.0.0","entry":"index.js","tests":"tests.js","dependencies":[{"name":"dep","selector":"2"}]},{"name":"app","version":"1.0.0","entry":"src/index.js","tests":"tests.js","dependencies":[{"name":"app","selector":"1"},{"name":"dep","selector":"1"}]}]}"#;
543        let entries: Vec<(&str, &[u8])> = vec![
544            (
545                "packages/dep/1.5.0/Documentation.md",
546                b"dep one docs\r\n",
547            ),
548            (
549                "packages/dep/1.5.0/index.js",
550                b"export const version=1;\n",
551            ),
552            (
553                "packages/dep/1.5.0/tests.js",
554                b"export function runTests(){}\n",
555            ),
556            (
557                "packages/dep/1.5.0/assets/deep/image.bin",
558                &[0, 1, 255],
559            ),
560            (
561                "packages/dep/2.1.0/Documentation.md",
562                b"dep two docs\n",
563            ),
564            (
565                "packages/dep/2.1.0/index.js",
566                b"export const version=2;\n",
567            ),
568            (
569                "packages/dep/2.1.0/tests.js",
570                b"export function runTests(){}\n",
571            ),
572            (
573                "packages/bridge/1.0.0/Documentation.md",
574                b"bridge docs\n",
575            ),
576            (
577                "packages/bridge/1.0.0/index.js",
578                b"import '/module/dep/v2/index.js';\nexport {};\n",
579            ),
580            (
581                "packages/bridge/1.0.0/tests.js",
582                b"export function runTests(){}\n",
583            ),
584            (
585                "packages/app/1.0.0/Documentation.md",
586                b"app docs\r\n",
587            ),
588            (
589                "packages/app/1.0.0/src/index.js",
590                b"import '/module/dep/v1/index.js';\nexport const self='/module/app/v1/src/index.js';\n",
591            ),
592            (
593                "packages/app/1.0.0/tests.js",
594                b"export function runTests(){}\n",
595            ),
596            (
597                "packages/app/1.0.0/styles/main.css",
598                b"@import '/module/dep/v1/style.css';\n",
599            ),
600        ];
601        let archive = archive(root.path(), "complete.zip", manifest, &entries);
602        let authority = AuthorityId::new(TxId::from_bytes([1; 12]));
603        let authority_text = authority.to_string();
604        let prepared = prepare_all(&archive, authority).unwrap();
605
606        let dep_one = prepared
607            .iter()
608            .find(|item| {
609                item.coordinate.name == "dep" && item.coordinate.version == Version::new(1, 5, 0)
610            })
611            .unwrap();
612        assert_eq!(file(&dep_one.source, "assets/deep/image.bin"), [0, 1, 255]);
613
614        let app = prepared
615            .iter()
616            .find(|item| item.coordinate.name == "app")
617            .unwrap();
618        assert_eq!(app.source.entry(), "src/index.js");
619        assert_eq!(app.source.tests(), "tests.js");
620        assert_eq!(app.source.dependencies().len(), 1);
621        assert_eq!(app.source.dependencies()[0].name(), "dep");
622        let expected_documentation = format!(
623            "<!-- k1-web/v1\n{{\"dependencies\":[{{\"authority\":\"{authority_text}\",\"name\":\"dep\",\"selector\":\"1\"}}]}}\n-->\napp docs\r\n"
624        );
625        assert_eq!(
626            file(&app.source, "Documentation.md"),
627            expected_documentation.as_bytes()
628        );
629
630        let generated: Value = serde_json::from_slice(file(&app.source, "k1-web.json")).unwrap();
631        assert_eq!(generated["name"], "app");
632        assert_eq!(generated["version"], "1.0.0");
633        assert_eq!(generated["entry"], "src/index.js");
634        assert_eq!(generated["tests"], "tests.js");
635        assert_eq!(generated["dependencies"].as_array().unwrap().len(), 1);
636
637        let code = std::str::from_utf8(file(&app.source, "src/index.js")).unwrap();
638        assert!(code.contains(&format!("/{authority_text}/dep/v1/index.js")));
639        assert!(code.contains(&format!("/{authority_text}/app/v1/src/index.js")));
640        assert!(!code.contains("/module/"));
641        assert!(!code.contains(&format!("/module/{authority_text}")));
642
643        let style = std::str::from_utf8(file(&app.source, "styles/main.css")).unwrap();
644        assert!(style.contains(&format!("/{authority_text}/dep/v1/style.css")));
645
646        let dep_one_position = prepared
647            .iter()
648            .position(|item| {
649                item.coordinate.name == "dep" && item.coordinate.version == Version::new(1, 5, 0)
650            })
651            .unwrap();
652        let app_position = prepared
653            .iter()
654            .position(|item| item.coordinate.name == "app")
655            .unwrap();
656        let dep_two_position = prepared
657            .iter()
658            .position(|item| {
659                item.coordinate.name == "dep" && item.coordinate.version == Version::new(2, 1, 0)
660            })
661            .unwrap();
662        let bridge_position = prepared
663            .iter()
664            .position(|item| item.coordinate.name == "bridge")
665            .unwrap();
666        assert!(dep_one_position < app_position);
667        assert!(dep_two_position < bridge_position);
668    }
669
670    #[test]
671    fn rejects_unresolved_dependencies_and_non_self_cycles() {
672        let root = tempfile::tempdir().unwrap();
673        let unresolved_manifest = r#"{"schema":1,"packages":[{"name":"app","version":"1.0.0","entry":"index.js","tests":"tests.js","dependencies":[{"name":"missing","selector":"1"}]}]}"#;
674        let app_entries: Vec<(&str, &[u8])> = vec![
675            ("packages/app/1.0.0/Documentation.md", b"app docs\n"),
676            ("packages/app/1.0.0/index.js", b"export {};\n"),
677            (
678                "packages/app/1.0.0/tests.js",
679                b"export function runTests(){}\n",
680            ),
681        ];
682        let unresolved = archive(
683            root.path(),
684            "unresolved.zip",
685            unresolved_manifest,
686            &app_entries,
687        );
688        let authority = AuthorityId::new(TxId::from_bytes([2; 12]));
689        assert!(
690            prepare_all(&unresolved, authority)
691                .unwrap_err()
692                .contains("unresolved Web bootstrap dependency")
693        );
694
695        let cycle_manifest = r#"{"schema":1,"packages":[{"name":"a","version":"1.0.0","entry":"index.js","tests":"tests.js","dependencies":[{"name":"b","selector":"1"}]},{"name":"b","version":"1.0.0","entry":"index.js","tests":"tests.js","dependencies":[{"name":"a","selector":"1"}]}]}"#;
696        let cycle_entries: Vec<(&str, &[u8])> = vec![
697            ("packages/a/1.0.0/Documentation.md", b"a docs\n"),
698            ("packages/a/1.0.0/index.js", b"export {};\n"),
699            (
700                "packages/a/1.0.0/tests.js",
701                b"export function runTests(){}\n",
702            ),
703            ("packages/b/1.0.0/Documentation.md", b"b docs\n"),
704            ("packages/b/1.0.0/index.js", b"export {};\n"),
705            (
706                "packages/b/1.0.0/tests.js",
707                b"export function runTests(){}\n",
708            ),
709        ];
710        let cycle = archive(root.path(), "cycle.zip", cycle_manifest, &cycle_entries);
711        assert!(
712            prepare_all(&cycle, authority)
713                .unwrap_err()
714                .contains("non-self Web bootstrap dependency cycle")
715        );
716    }
717}