Skip to main content

harmos_packaging/
build.rs

1/* ***
2 *
3 * Harmos
4 *
5 * SPDX-License-Identifier: Apache-2.0
6 *
7 * ***
8 */
9
10// Select Artifacts And Schema Outputs From One Successful Cargo Invocation.
11
12/* === Modules === */
13
14/* === Re-Exports === */
15
16/* === Imports === */
17
18// Standard Library Imports
19use std::collections::{BTreeMap, BTreeSet};
20use std::io::{BufRead, BufReader};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23
24// Third Party Imports
25use crate::digest;
26use serde::Deserialize;
27
28// Local Imports
29use super::{BuildCancellation, Error, metadata::Metadata, process::Process};
30use crate::config::{BuildBackend, BuildProfile, BuildSettings};
31
32/* === Constants === */
33
34/* === Macros === */
35
36/* === Type Aliases === */
37
38type BuildMessages = (Vec<Artifact>, BTreeSet<(String, PathBuf)>);
39
40/* === Structs === */
41
42#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
43#[serde(deny_unknown_fields)]
44pub struct Artifact {
45    pub producer: String,
46    pub name: String,
47    pub path: PathBuf,
48    pub sha256: String,
49}
50
51#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Schema {
54    pub producer: String,
55    pub catalog: String,
56    pub directory: PathBuf,
57    pub files: BTreeMap<String, String>,
58}
59
60#[derive(Debug, serde::Serialize, serde::Deserialize)]
61#[serde(deny_unknown_fields)]
62pub struct Build {
63    pub backend: BuildBackend,
64    pub profile: BuildProfile,
65    pub target: String,
66    pub artifacts: Vec<Artifact>,
67    pub schemas: Vec<Schema>,
68}
69
70#[derive(Debug, serde::Serialize, serde::Deserialize)]
71pub struct PackageOutput {
72    pub producer: String,
73    pub path: PathBuf,
74    pub sha256: String,
75}
76
77// A Narrow Projection Of The Generic Archive Owner's Versioned Receipt.
78#[derive(Deserialize)]
79struct Capture {
80    schema: u32,
81    profile: BuildProfile,
82    target: String,
83    command: Vec<String>,
84    inputs: CaptureInputs,
85    messages: CaptureFile,
86    metadata: CaptureFile,
87    artifacts: BTreeMap<PathBuf, String>,
88    generated: BTreeMap<PathBuf, String>,
89}
90
91#[derive(Deserialize)]
92struct CaptureInputs {
93    rustc: String,
94}
95
96#[derive(Deserialize)]
97struct CaptureFile {
98    sha256: String,
99}
100
101#[derive(Deserialize)]
102struct Message {
103    reason: String,
104    package_id: Option<String>,
105    executable: Option<PathBuf>,
106
107    #[serde(default)]
108    filenames: Vec<PathBuf>,
109
110    target: Option<Target>,
111    out_dir: Option<PathBuf>,
112    message: Option<Diagnostic>,
113    success: Option<bool>,
114    profile: Option<Profile>,
115}
116
117#[derive(Deserialize)]
118struct Diagnostic {
119    rendered: Option<String>,
120}
121
122#[derive(Deserialize)]
123struct Profile {
124    test: bool,
125}
126
127#[derive(Deserialize)]
128struct Target {
129    name: String,
130    kind: Vec<String>,
131}
132
133/* === Enums === */
134
135/* === Traits === */
136
137/* === Implementations === */
138
139impl Artifact {
140    // Stamp An Existing Cargo Output Without Launching A Compiler.
141    pub fn read(producer: String, name: String, path: PathBuf) -> Result<Self, Error> {
142        safe_name(&producer)?;
143        safe_name(&name)?;
144        use sha2::{Digest, Sha256};
145        if path.is_symlink() || !path.is_file() {
146            return Err(Error::Settings("artifact must be a regular file".into()));
147        }
148        let mut file = BufReader::new(std::fs::File::open(&path)?);
149        let mut hash = Sha256::new();
150        loop {
151            let bytes = file.fill_buf()?;
152            if bytes.is_empty() {
153                break;
154            }
155            hash.update(bytes);
156            let length = bytes.len();
157            file.consume(length);
158        }
159        let sha256 = hash.finalize().iter().map(|byte| format!("{byte:02x}")).collect();
160        Ok(Self { producer, name, path, sha256 })
161    }
162}
163
164impl Schema {
165    pub fn read(producer: String, catalog: String, directory: PathBuf) -> Result<Self, Error> {
166        safe_name(&producer)?;
167        safe_name(&catalog)?;
168        let files = schema_files(&directory)?.into_iter().map(|(name, bytes)| (name, digest(&bytes))).collect();
169        Ok(Self { producer, catalog, directory, files })
170    }
171}
172
173impl Build {
174    // The Archive Producer Owns Invocation Identity; This Collector Binds Its
175    // Captured Streams And Checks Output Locations Without Running A Compiler.
176    pub fn from_cargo_outputs(
177        mut messages: impl BufRead, mut metadata: impl std::io::Read, receipt: impl std::io::Read, settings: &BuildSettings,
178    ) -> Result<Self, Error> {
179        validate_backend(settings)?;
180        let receipt: Capture = serde_json::from_reader(receipt)?;
181        if receipt.schema != 1 || receipt.command.is_empty() || receipt.target.is_empty() {
182            return Err(Error::Settings("unsupported or incomplete archive producer receipt".into()));
183        }
184        if receipt.profile != settings.profile || settings.target.as_ref().is_some_and(|target| target != &receipt.target) {
185            return Err(Error::Settings("requested profile or target disagrees with archive producer receipt".into()));
186        }
187        let mut message_bytes = Vec::new();
188        messages.read_to_end(&mut message_bytes)?;
189        let mut metadata_bytes = Vec::new();
190        metadata.read_to_end(&mut metadata_bytes)?;
191        if digest(&message_bytes) != receipt.messages.sha256 || digest(&metadata_bytes) != receipt.metadata.sha256 {
192            return Err(Error::Settings("captured Cargo streams disagree with archive producer byte stamps".into()));
193        }
194        let metadata: Metadata = serde_json::from_slice(&metadata_bytes)?;
195        let packages = metadata.select(settings)?;
196        if packages.is_empty() || packages.values().collect::<BTreeSet<_>>().len() != packages.len() {
197            return Err(Error::Settings("collection needs unambiguous selected Cargo packages".into()));
198        }
199        if (settings.backend == BuildBackend::Guest) != receipt.target.starts_with("wasm32-") {
200            return Err(Error::Settings("backend and producer target disagree".into()));
201        }
202        let (artifacts, outputs) = read_messages(std::io::Cursor::new(message_bytes), &packages)?;
203        let profile_directory = match receipt.profile {
204            BuildProfile::Dev | BuildProfile::Test => "debug",
205            BuildProfile::Release | BuildProfile::Bench => "release",
206        };
207        let native = receipt.inputs.rustc.lines().find_map(|line| line.strip_prefix("host: "));
208        let expected = metadata.target_directory.join(&receipt.target).join(profile_directory);
209        let host = metadata.target_directory.join(profile_directory);
210        let matches = |path: &Path| path.starts_with(&expected) || (native == Some(receipt.target.as_str()) && path.starts_with(&host));
211        if artifacts.iter().any(|artifact| !matches(&artifact.path)) || outputs.iter().any(|(_, directory)| !matches(directory)) {
212            return Err(Error::Settings("captured artifacts do not match the producer target/profile output directories".into()));
213        }
214        let provided = artifacts.iter().map(|artifact| artifact.producer.as_str()).collect::<BTreeSet<_>>();
215        if packages.values().any(|name| !provided.contains(name.as_str())) {
216            return Err(Error::Settings("captured build is missing a selected package artifact".into()));
217        }
218        if provided.len() != artifacts.len() {
219            return Err(Error::Settings("captured build contains ambiguous package artifacts".into()));
220        }
221        for artifact in &artifacts {
222            if receipt.artifacts.get(&artifact.path) != Some(&artifact.sha256) {
223                return Err(Error::ArtifactChanged(artifact.path.clone()));
224            }
225        }
226        finish_build(
227            settings.backend,
228            receipt.profile,
229            receipt.target,
230            artifacts,
231            outputs,
232            &BuildCancellation::default(),
233            Some(&receipt.generated),
234        )
235    }
236
237    // Relocation Changes Locations Only; It Never Replaces The Original Byte Stamps.
238    pub fn relocate(&mut self, from: &Path, to: &Path) -> Result<(), Error> {
239        let relocate = |path: &Path| {
240            path.strip_prefix(from)
241                .map(|relative| to.join(relative))
242                .map_err(|_| Error::Settings("build receipt paths do not share the selected source root".into()))
243        };
244        let artifacts = self.artifacts.iter().map(|artifact| relocate(&artifact.path)).collect::<Result<Vec<_>, _>>()?;
245        let schemas = self.schemas.iter().map(|schema| relocate(&schema.directory)).collect::<Result<Vec<_>, _>>()?;
246        for (artifact, path) in self.artifacts.iter_mut().zip(artifacts) {
247            artifact.path = path;
248        }
249        for (schema, directory) in self.schemas.iter_mut().zip(schemas) {
250            schema.directory = directory;
251        }
252        Ok(())
253    }
254}
255
256/* === Public Functions === */
257
258pub fn build(settings: &BuildSettings) -> Result<Build, Error> {
259    build_with_cancellation(settings, &BuildCancellation::default())
260}
261
262pub fn build_with_cancellation(settings: &BuildSettings, cancellation: &BuildCancellation) -> Result<Build, Error> {
263    validate_backend(settings)?;
264    let metadata = Metadata::read(settings, cancellation)?;
265    build_resolved(settings, cancellation, metadata)
266}
267
268pub(super) fn build_resolved(settings: &BuildSettings, cancellation: &BuildCancellation, metadata: Metadata) -> Result<Build, Error> {
269    let manifest = settings.manifest.canonicalize()?;
270    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
271    let directory = manifest.parent().ok_or_else(|| Error::Settings("manifest has no project directory".into()))?;
272    let selected = metadata.select(settings)?;
273    let target = match (&settings.backend, &settings.target) {
274        (BuildBackend::Guest, None) => "wasm32-unknown-unknown".to_owned(),
275        (_, Some(target)) => target.clone(),
276        (BuildBackend::Sidecar, None) => {
277            let mut command = Command::new("rustc");
278            command.args(["-vV"]).stdout(Stdio::piped()).stderr(Stdio::piped());
279            let version = Process::spawn(command, cancellation)?.output()?;
280            cancellation.check()?;
281            if !version.status.success() {
282                return Err(Error::Cargo("rustc host discovery failed".into()));
283            }
284            String::from_utf8_lossy(&version.stdout)
285                .lines()
286                .find_map(|line| line.strip_prefix("host: "))
287                .ok_or_else(|| Error::Cargo("rustc did not report its host target".into()))?
288                .to_owned()
289        }
290    };
291    if (settings.backend == BuildBackend::Guest) != target.starts_with("wasm32-") {
292        return Err(Error::Settings("backend and selected target disagree".into()));
293    }
294    let mut command = Command::new(cargo);
295    command.current_dir(directory);
296    command.args(["build", "--locked", "--message-format=json-render-diagnostics", "--manifest-path"]).arg(&manifest);
297    for package in selected.values() {
298        command.args(["--package", package]);
299    }
300    command.args(["--profile", settings.profile.name()]);
301    if settings.timings {
302        command.arg("--timings");
303    }
304    command.args(["--target", &target]);
305    if let Some(directory) = &settings.target_directory {
306        command.arg("--target-dir").arg(directory);
307    }
308    if !settings.default_features {
309        command.arg("--no-default-features");
310    }
311    if !settings.features.is_empty() {
312        command.args(["--features", &settings.features.join(",")]);
313    }
314    command.stdout(Stdio::piped()).stderr(Stdio::inherit());
315    let mut child = Process::spawn(command, cancellation)?;
316    let parsed = read_messages(BufReader::new(child.stdout()), &selected);
317    if parsed.is_err() {
318        child.kill()?;
319    }
320    let status = child.wait()?;
321    cancellation.check()?;
322    let (artifacts, outputs) = parsed?;
323    if !status.success() {
324        return Err(Error::Cargo(status.to_string()));
325    }
326
327    finish_build(settings.backend, settings.profile, target, artifacts, outputs, cancellation, None)
328}
329
330fn finish_build(
331    backend: BuildBackend, profile: BuildProfile, target: String, mut artifacts: Vec<Artifact>, outputs: BTreeSet<(String, PathBuf)>,
332    cancellation: &BuildCancellation, generated: Option<&BTreeMap<PathBuf, String>>,
333) -> Result<Build, Error> {
334    artifacts
335        .retain(|artifact| (artifact.path.extension().is_some_and(|extension| extension == "wasm")) == (backend == BuildBackend::Guest));
336    if artifacts.is_empty() {
337        return Err(Error::Settings("selected packages contain no artifact for this backend".into()));
338    }
339    // Preserve Compiler Outputs And Stamp Separate Shipping Bytes.
340    let mut shipping = BTreeMap::new();
341    for artifact in &mut artifacts {
342        cancellation.check()?;
343        let source = Artifact::read(artifact.producer.clone(), artifact.name.clone(), artifact.path.clone())?;
344        if source.sha256 != artifact.sha256 {
345            return Err(Error::ArtifactChanged(artifact.path.clone()));
346        }
347        let parent = artifact.path.parent().ok_or_else(|| Error::Settings("artifact has no parent directory".into()))?;
348        let file_name = artifact.path.file_name().ok_or_else(|| Error::Settings("artifact has no file name".into()))?;
349        let output = parent.join("harmos").join(&artifact.producer).join(file_name);
350        #[cfg(feature = "guest")]
351        let bytes = if backend == BuildBackend::Guest {
352            let module = std::fs::read(&artifact.path)?;
353            super::component::strip_debug(&module).map_err(Error::Component)?
354        } else {
355            native_shipping_bytes(&artifact.path, cancellation)?
356        };
357        #[cfg(not(feature = "guest"))]
358        let bytes = native_shipping_bytes(&artifact.path, cancellation)?;
359        if bytes.len() > crate::bundle::Limits::default().file_bytes {
360            return Err(Error::Settings("artifact exceeds bundle byte limit".into()));
361        }
362        let permissions = std::fs::metadata(&artifact.path)?.permissions();
363        artifact.sha256 = digest(&bytes);
364        shipping.insert(output.clone(), (bytes, permissions));
365        artifact.path = output;
366    }
367    #[cfg(feature = "guest")]
368    let mut components: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
369    #[cfg(not(feature = "guest"))]
370    let components: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
371    // Cargo Emits Core Modules; Consumers Receive Ready-To-Load Components.
372    #[cfg(feature = "guest")]
373    for artifact in &mut artifacts {
374        if artifact.path.extension().is_none_or(|extension| extension != "wasm") {
375            continue;
376        }
377        let module = &shipping[&artifact.path].0;
378        if digest(module) != artifact.sha256 {
379            return Err(Error::ArtifactChanged(artifact.path.clone()));
380        }
381        super::Declaration::inspect(module, super::Facility::Guest)?;
382        if module.starts_with(b"\0asm\x0d\0\x01\0") {
383            continue;
384        }
385        let component = super::componentize(module).map_err(Error::Component)?;
386        super::Declaration::inspect(&component, super::Facility::Guest)?;
387        let output = artifact.path.with_extension("component.wasm");
388        artifact.sha256 = digest(&component);
389        components.insert(output.clone(), component);
390        artifact.path = output;
391    }
392
393    let expected_schemas = generated.map(|generated| {
394        generated
395            .iter()
396            .filter(|(path, _)| outputs.iter().any(|(_, directory)| path.starts_with(directory.join("grpc"))))
397            .map(|(path, sha256)| (path.clone(), sha256.clone()))
398            .collect::<BTreeMap<_, _>>()
399    });
400    let mut schemas = BTreeMap::new();
401    for (producer, directory) in outputs {
402        let root = directory.join("grpc");
403        if !root.exists() {
404            continue;
405        }
406        for entry in std::fs::read_dir(root)? {
407            let entry = entry?;
408            if !entry.file_type()?.is_dir() {
409                continue;
410            }
411            let catalog = entry.file_name().into_string().map_err(|_| Error::Settings("schema catalog is not UTF-8".into()))?;
412            let key = (producer.clone(), catalog.clone());
413            if schemas
414                .insert(
415                    key.clone(),
416                    Schema {
417                        producer: producer.clone(),
418                        catalog,
419                        files: schema_files(&entry.path())?.into_iter().map(|(name, bytes)| (name, digest(&bytes))).collect(),
420                        directory: entry.path(),
421                    },
422                )
423                .is_some()
424            {
425                return Err(Error::Collision(format!("{}/{}", key.0, key.1)));
426            }
427        }
428    }
429    let schemas: Vec<_> = schemas.into_values().collect();
430    if let Some(expected) = expected_schemas {
431        let actual = schemas
432            .iter()
433            .flat_map(|schema| schema.files.iter().map(|(name, sha256)| (schema.directory.join(name), sha256.clone())))
434            .collect::<BTreeMap<_, _>>();
435        if actual != expected {
436            return Err(Error::Settings("generated schema set differs from the archive producer snapshot".into()));
437        }
438    }
439    cancellation.check()?;
440    for (path, (bytes, permissions)) in shipping {
441        publish(&path, &bytes)?;
442        std::fs::set_permissions(path, permissions)?;
443    }
444    for (path, bytes) in components {
445        publish(&path, &bytes)?;
446    }
447    Ok(Build {
448        backend,
449        profile,
450        target,
451        artifacts,
452        schemas,
453    })
454}
455
456/* === Private Functions === */
457
458fn native_shipping_bytes(path: &Path, cancellation: &BuildCancellation) -> Result<Vec<u8>, Error> {
459    let mut rustc = Command::new("rustc");
460    rustc.args(["--print", "sysroot"]).stdout(Stdio::piped()).stderr(Stdio::piped());
461    let sysroot = Process::spawn(rustc, cancellation)?.output()?;
462    if !sysroot.status.success() {
463        return Err(Error::Cargo("rustc sysroot discovery failed".into()));
464    }
465    let tools = PathBuf::from(String::from_utf8_lossy(&sysroot.stdout).trim()).join("lib/rustlib");
466    let executable = format!("llvm-objcopy{}", std::env::consts::EXE_SUFFIX);
467    let objcopy = std::fs::read_dir(tools)?
468        .filter_map(Result::ok)
469        .map(|entry| entry.path().join("bin").join(&executable))
470        .find(|path| path.is_file())
471        .ok_or_else(|| Error::Settings("packaging requires the Rust llvm-tools component".into()))?;
472
473    let temporary = tempfile::NamedTempFile::new()?;
474    let mut command = Command::new(&objcopy);
475    command.arg("--strip-debug").arg(path).arg(temporary.path()).stdout(Stdio::piped()).stderr(Stdio::piped());
476    let result = Process::spawn(command, cancellation)?.output()?;
477    if !result.status.success() {
478        return Err(Error::Cargo(format!("debug separation failed: {}", String::from_utf8_lossy(&result.stderr))));
479    }
480    read_artifact(temporary.path())
481}
482
483pub(super) fn validate_backend(settings: &BuildSettings) -> Result<(), Error> {
484    let enabled = match settings.backend {
485        BuildBackend::Sidecar => cfg!(feature = "sidecar"),
486        BuildBackend::Guest => cfg!(feature = "guest"),
487    };
488    if !enabled {
489        return Err(Error::Settings("selected packaging backend is disabled".into()));
490    }
491    Ok(())
492}
493
494// Package Only The Exact Bytes Stamped By An Earlier Build. This Path Never Runs Cargo.
495pub fn package(build: &Build, output: &Path) -> Result<Vec<PackageOutput>, Error> {
496    package_with_cancellation(build, output, &BuildCancellation::default())
497}
498
499pub fn package_with_cancellation(build: &Build, output: &Path, cancellation: &BuildCancellation) -> Result<Vec<PackageOutput>, Error> {
500    if build.artifacts.is_empty() || build.target.is_empty() {
501        return Err(Error::Settings("a stamped build with artifacts and a target is required".into()));
502    }
503    let artifacts = &build.artifacts;
504    let schemas = &build.schemas;
505    let backend = build.backend;
506    let target = &build.target;
507    use crate::bundle::{Bundle, Executable, Facility, Identity, Limits, Source};
508    let facility = match backend {
509        BuildBackend::Sidecar => Facility::Sidecar,
510        BuildBackend::Guest => Facility::Guest,
511    };
512    let inspected = match backend {
513        BuildBackend::Sidecar => super::Facility::Sidecar,
514        BuildBackend::Guest => super::Facility::Guest,
515    };
516    let mut prepared = BTreeMap::new();
517    for artifact in artifacts {
518        cancellation.check()?;
519        safe_name(&artifact.producer)?;
520        safe_name(&artifact.name)?;
521        let content = read_artifact(&artifact.path)?;
522        if digest(&content) != artifact.sha256 {
523            return Err(Error::ArtifactChanged(artifact.path.clone()));
524        }
525        let declaration = super::Declaration::inspect(&content, inspected)?;
526        let executable = artifact
527            .path
528            .file_name()
529            .and_then(|name| name.to_str())
530            .ok_or_else(|| Error::Settings("artifact filename is not UTF-8".into()))?
531            .to_owned();
532        let mut files = BTreeMap::from([(executable.clone(), content)]);
533        for schema in schemas.iter().filter(|schema| schema.producer == artifact.producer) {
534            safe_name(&schema.catalog)?;
535            let contents = schema_files(&schema.directory)?;
536            let hashes: BTreeMap<_, _> = contents.iter().map(|(name, bytes)| (name.clone(), digest(bytes))).collect();
537            if hashes != schema.files {
538                return Err(Error::ArtifactChanged(schema.directory.clone()));
539            }
540            for (name, bytes) in contents {
541                let name = format!("grpc/{}/{name}", schema.catalog);
542                if files.insert(name.clone(), bytes).is_some() {
543                    return Err(Error::Collision(name));
544                }
545            }
546        }
547        let sources: Vec<_> = files
548            .iter()
549            .map(|(path, bytes)| Source {
550                path,
551                bytes,
552                executable: (path == &executable).then(|| Executable {
553                    facility,
554                    target: target.to_owned(),
555                    registrations: Vec::new(),
556                }),
557            })
558            .collect();
559        let identity = Identity {
560            name: declaration.id,
561            version: declaration.version,
562            description: declaration.description,
563            package: Default::default(),
564        };
565        let bundle = Bundle::build(identity, &sources, Limits::default())?;
566        let destination = output.join(&artifact.producer).join(format!("{}.harmos", artifact.name));
567        if prepared.insert(destination.clone(), (artifact.producer.clone(), bundle)).is_some() {
568            return Err(Error::Collision(destination.display().to_string()));
569        }
570    }
571    cancellation.check()?;
572    // Publish Only After Every Artifact And Schema Has Passed Preparation.
573    let mut packages = Vec::new();
574    for (path, (producer, bundle)) in prepared {
575        let sha256 = digest(bundle.bytes());
576        publish(&path, bundle.bytes())?;
577        packages.push(PackageOutput { producer, path, sha256 });
578    }
579    Ok(packages)
580}
581
582fn safe_name(name: &str) -> Result<(), Error> {
583    if name.is_empty()
584        || name.len() > 255
585        || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
586        || matches!(name, "." | "..")
587    {
588        return Err(Error::Settings("artifact names must be single path components".into()));
589    }
590    Ok(())
591}
592
593fn read_artifact(path: &Path) -> Result<Vec<u8>, Error> {
594    use std::io::Read as _;
595    let limit = crate::bundle::Limits::default().file_bytes;
596    if path.is_symlink() {
597        return Err(Error::Settings("artifact must be a regular file".into()));
598    }
599    let file = std::fs::File::open(path)?;
600    if !file.metadata()?.is_file() {
601        return Err(Error::Settings("artifact must be a regular file".into()));
602    }
603    let mut bytes = Vec::new();
604    file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
605    if bytes.len() > limit {
606        return Err(Error::Settings("artifact exceeds bundle byte limit".into()));
607    }
608    Ok(bytes)
609}
610
611fn schema_files(directory: &Path) -> Result<BTreeMap<String, Vec<u8>>, Error> {
612    let manifest = read_artifact(&directory.join("manifest.txt"))?;
613    let text = std::str::from_utf8(&manifest).map_err(|_| Error::Settings("schema manifest must be UTF-8".into()))?;
614    let names: Vec<_> = text.lines().collect();
615    if names.first() != Some(&"descriptors.bin")
616        || names.len() > crate::bundle::Limits::default().files
617        || names.iter().copied().collect::<BTreeSet<_>>().len() != names.len()
618        || names.contains(&"manifest.txt")
619    {
620        return Err(Error::Settings("schema manifest is incomplete or duplicated".into()));
621    }
622    let mut files = BTreeMap::new();
623    for name in names {
624        let relative = Path::new(name);
625        if relative.components().any(|part| !matches!(part, std::path::Component::Normal(_))) || name.contains(['\\', ':']) {
626            return Err(Error::Settings("schema manifest contains an unsafe path".into()));
627        }
628        files.insert(name.to_owned(), read_artifact(&directory.join(relative))?);
629    }
630    files.insert("manifest.txt".into(), manifest);
631    Ok(files)
632}
633
634fn publish(path: &std::path::Path, bytes: &[u8]) -> Result<(), Error> {
635    use std::io::Write;
636    let parent = path.parent().ok_or_else(|| Error::Settings("output has no parent directory".into()))?;
637    std::fs::create_dir_all(parent)?;
638    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
639    temporary.write_all(bytes)?;
640    temporary.persist(path).map_err(|error| Error::Io(error.error))?;
641    Ok(())
642}
643
644fn read_messages(reader: impl BufRead, selected: &BTreeMap<String, String>) -> Result<BuildMessages, Error> {
645    let mut artifacts = Vec::new();
646    let mut outputs = BTreeSet::new();
647    let mut finished = false;
648    for line in reader.lines() {
649        let message: Message = serde_json::from_str(&line?)?;
650        if finished {
651            return Err(Error::Cargo("messages follow the completed Cargo build".into()));
652        }
653        if message.reason == "build-finished" {
654            if message.success != Some(true) {
655                return Err(Error::Cargo("captured Cargo build did not succeed".into()));
656            }
657            finished = true;
658            continue;
659        }
660        if let Some(rendered) = message.message.as_ref().and_then(|diagnostic| diagnostic.rendered.as_ref()) {
661            eprint!("{rendered}");
662        }
663        let Some(producer) = message.package_id.as_ref().and_then(|id| selected.get(id)) else {
664            continue;
665        };
666        if message.reason == "build-script-executed" {
667            if let Some(directory) = message.out_dir {
668                outputs.insert((producer.clone(), directory));
669            }
670            continue;
671        }
672        if message.reason != "compiler-artifact" || message.profile.is_some_and(|profile| profile.test) {
673            continue;
674        }
675        let Some(target) = message.target else {
676            continue;
677        };
678        if target.kind.iter().any(|kind| kind == "custom-build" || kind == "example" || kind == "test" || kind == "bench") {
679            continue;
680        }
681        let files = message
682            .executable
683            .into_iter()
684            .chain(message.filenames.into_iter().filter(|path| path.extension().is_some_and(|extension| extension == "wasm")));
685        for path in files {
686            artifacts.push(Artifact::read(producer.clone(), target.name.clone(), path)?);
687        }
688    }
689    if !finished {
690        return Err(Error::Cargo("captured Cargo stream has no successful build-finished event".into()));
691    }
692    artifacts.sort_by(|left, right| (&left.producer, &left.name, &left.path).cmp(&(&right.producer, &right.name, &right.path)));
693    artifacts.dedup_by(|left, right| left.producer == right.producer && left.name == right.name && left.path == right.path);
694    Ok((artifacts, outputs))
695}
696
697/* === Tests === */