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, ¤t);
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}