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