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        let sha256 = digest(&read_artifact(&path)?);
145        Ok(Self { producer, name, path, sha256 })
146    }
147}
148
149impl Schema {
150    pub fn read(producer: String, catalog: String, directory: PathBuf) -> Result<Self, Error> {
151        safe_name(&producer)?;
152        safe_name(&catalog)?;
153        let files = schema_files(&directory)?.into_iter().map(|(name, bytes)| (name, digest(&bytes))).collect();
154        Ok(Self { producer, catalog, directory, files })
155    }
156}
157
158impl Build {
159    // The Archive Producer Owns Invocation Identity; This Collector Binds Its
160    // Captured Streams And Checks Output Locations Without Running A Compiler.
161    pub fn from_cargo_outputs(
162        mut messages: impl BufRead, mut metadata: impl std::io::Read, receipt: impl std::io::Read, settings: &BuildSettings,
163    ) -> Result<Self, Error> {
164        validate_backend(settings)?;
165        let receipt: Capture = serde_json::from_reader(receipt)?;
166        if receipt.schema != 1 || receipt.command.is_empty() || receipt.target.is_empty() {
167            return Err(Error::Settings("unsupported or incomplete archive producer receipt".into()));
168        }
169        if receipt.profile != settings.profile || settings.target.as_ref().is_some_and(|target| target != &receipt.target) {
170            return Err(Error::Settings("requested profile or target disagrees with archive producer receipt".into()));
171        }
172        let mut message_bytes = Vec::new();
173        messages.read_to_end(&mut message_bytes)?;
174        let mut metadata_bytes = Vec::new();
175        metadata.read_to_end(&mut metadata_bytes)?;
176        if digest(&message_bytes) != receipt.messages.sha256 || digest(&metadata_bytes) != receipt.metadata.sha256 {
177            return Err(Error::Settings("captured Cargo streams disagree with archive producer byte stamps".into()));
178        }
179        let metadata: Metadata = serde_json::from_slice(&metadata_bytes)?;
180        let packages = metadata.select(settings)?;
181        if packages.is_empty() || packages.values().collect::<BTreeSet<_>>().len() != packages.len() {
182            return Err(Error::Settings("collection needs unambiguous selected Cargo packages".into()));
183        }
184        if (settings.backend == BuildBackend::Guest) != receipt.target.starts_with("wasm32-") {
185            return Err(Error::Settings("backend and producer target disagree".into()));
186        }
187        let (artifacts, outputs) = read_messages(std::io::Cursor::new(message_bytes), &packages)?;
188        let profile_directory = match receipt.profile {
189            BuildProfile::Dev | BuildProfile::Test => "debug",
190            BuildProfile::Release | BuildProfile::Bench => "release",
191        };
192        let native = receipt.inputs.rustc.lines().find_map(|line| line.strip_prefix("host: "));
193        let expected = metadata.target_directory.join(&receipt.target).join(profile_directory);
194        let host = metadata.target_directory.join(profile_directory);
195        let matches = |path: &Path| path.starts_with(&expected) || (native == Some(receipt.target.as_str()) && path.starts_with(&host));
196        if artifacts.iter().any(|artifact| !matches(&artifact.path)) || outputs.iter().any(|(_, directory)| !matches(directory)) {
197            return Err(Error::Settings("captured artifacts do not match the producer target/profile output directories".into()));
198        }
199        let provided = artifacts.iter().map(|artifact| artifact.producer.as_str()).collect::<BTreeSet<_>>();
200        if packages.values().any(|name| !provided.contains(name.as_str())) {
201            return Err(Error::Settings("captured build is missing a selected package artifact".into()));
202        }
203        if provided.len() != artifacts.len() {
204            return Err(Error::Settings("captured build contains ambiguous package artifacts".into()));
205        }
206        for artifact in &artifacts {
207            if receipt.artifacts.get(&artifact.path) != Some(&artifact.sha256) {
208                return Err(Error::ArtifactChanged(artifact.path.clone()));
209            }
210        }
211        finish_build(
212            settings.backend,
213            receipt.profile,
214            receipt.target,
215            artifacts,
216            outputs,
217            &BuildCancellation::default(),
218            Some(&receipt.generated),
219        )
220    }
221
222    // Relocation Changes Locations Only; It Never Replaces The Original Byte Stamps.
223    pub fn relocate(&mut self, from: &Path, to: &Path) -> Result<(), Error> {
224        let relocate = |path: &Path| {
225            path.strip_prefix(from)
226                .map(|relative| to.join(relative))
227                .map_err(|_| Error::Settings("build receipt paths do not share the selected source root".into()))
228        };
229        let artifacts = self.artifacts.iter().map(|artifact| relocate(&artifact.path)).collect::<Result<Vec<_>, _>>()?;
230        let schemas = self.schemas.iter().map(|schema| relocate(&schema.directory)).collect::<Result<Vec<_>, _>>()?;
231        for (artifact, path) in self.artifacts.iter_mut().zip(artifacts) {
232            artifact.path = path;
233        }
234        for (schema, directory) in self.schemas.iter_mut().zip(schemas) {
235            schema.directory = directory;
236        }
237        Ok(())
238    }
239}
240
241/* === Public Functions === */
242
243pub fn build(settings: &BuildSettings) -> Result<Build, Error> {
244    build_with_cancellation(settings, &BuildCancellation::default())
245}
246
247pub fn build_with_cancellation(settings: &BuildSettings, cancellation: &BuildCancellation) -> Result<Build, Error> {
248    validate_backend(settings)?;
249    let metadata = Metadata::read(settings, cancellation)?;
250    build_resolved(settings, cancellation, metadata)
251}
252
253pub(super) fn build_resolved(settings: &BuildSettings, cancellation: &BuildCancellation, metadata: Metadata) -> Result<Build, Error> {
254    let manifest = settings.manifest.canonicalize()?;
255    let cargo = std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into());
256    let directory = manifest.parent().ok_or_else(|| Error::Settings("manifest has no project directory".into()))?;
257    let selected = metadata.select(settings)?;
258    let target = match (&settings.backend, &settings.target) {
259        (BuildBackend::Guest, None) => "wasm32-unknown-unknown".to_owned(),
260        (_, Some(target)) => target.clone(),
261        (BuildBackend::Sidecar, None) => {
262            let mut command = Command::new("rustc");
263            command.args(["-vV"]).stdout(Stdio::piped()).stderr(Stdio::piped());
264            let version = Process::spawn(command, cancellation)?.output()?;
265            cancellation.check()?;
266            if !version.status.success() {
267                return Err(Error::Cargo("rustc host discovery failed".into()));
268            }
269            String::from_utf8_lossy(&version.stdout)
270                .lines()
271                .find_map(|line| line.strip_prefix("host: "))
272                .ok_or_else(|| Error::Cargo("rustc did not report its host target".into()))?
273                .to_owned()
274        }
275    };
276    if (settings.backend == BuildBackend::Guest) != target.starts_with("wasm32-") {
277        return Err(Error::Settings("backend and selected target disagree".into()));
278    }
279    let mut command = Command::new(cargo);
280    command.current_dir(directory);
281    command.args(["build", "--locked", "--message-format=json-render-diagnostics", "--manifest-path"]).arg(&manifest);
282    for package in selected.values() {
283        command.args(["--package", package]);
284    }
285    command.args(["--profile", settings.profile.name()]);
286    if settings.timings {
287        command.arg("--timings");
288    }
289    command.args(["--target", &target]);
290    if let Some(directory) = &settings.target_directory {
291        command.arg("--target-dir").arg(directory);
292    }
293    if !settings.default_features {
294        command.arg("--no-default-features");
295    }
296    if !settings.features.is_empty() {
297        command.args(["--features", &settings.features.join(",")]);
298    }
299    command.stdout(Stdio::piped()).stderr(Stdio::inherit());
300    let mut child = Process::spawn(command, cancellation)?;
301    let parsed = read_messages(BufReader::new(child.stdout()), &selected);
302    if parsed.is_err() {
303        child.kill()?;
304    }
305    let status = child.wait()?;
306    cancellation.check()?;
307    let (artifacts, outputs) = parsed?;
308    if !status.success() {
309        return Err(Error::Cargo(status.to_string()));
310    }
311
312    finish_build(settings.backend, settings.profile, target, artifacts, outputs, cancellation, None)
313}
314
315fn finish_build(
316    backend: BuildBackend, profile: BuildProfile, target: String, mut artifacts: Vec<Artifact>, outputs: BTreeSet<(String, PathBuf)>,
317    cancellation: &BuildCancellation, generated: Option<&BTreeMap<PathBuf, String>>,
318) -> Result<Build, Error> {
319    artifacts
320        .retain(|artifact| (artifact.path.extension().is_some_and(|extension| extension == "wasm")) == (backend == BuildBackend::Guest));
321    if artifacts.is_empty() {
322        return Err(Error::Settings("selected packages contain no artifact for this backend".into()));
323    }
324    #[cfg(feature = "guest")]
325    let mut components: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
326    #[cfg(not(feature = "guest"))]
327    let components: BTreeMap<PathBuf, Vec<u8>> = BTreeMap::new();
328    // Cargo Emits Core Modules; Consumers Receive Ready-To-Load Components.
329    #[cfg(feature = "guest")]
330    for artifact in &mut artifacts {
331        if artifact.path.extension().is_none_or(|extension| extension != "wasm") {
332            continue;
333        }
334        let module = std::fs::read(&artifact.path)?;
335        if digest(&module) != artifact.sha256 {
336            return Err(Error::ArtifactChanged(artifact.path.clone()));
337        }
338        super::Declaration::inspect(&module, super::Facility::Guest)?;
339        if module.starts_with(b"\0asm\x0d\0\x01\0") {
340            continue;
341        }
342        let component = super::componentize(&module).map_err(Error::Component)?;
343        super::Declaration::inspect(&component, super::Facility::Guest)?;
344        let output = artifact.path.with_extension("component.wasm");
345        artifact.sha256 = digest(&component);
346        components.insert(output.clone(), component);
347        artifact.path = output;
348    }
349
350    let expected_schemas = generated.map(|generated| {
351        generated
352            .iter()
353            .filter(|(path, _)| outputs.iter().any(|(_, directory)| path.starts_with(directory.join("grpc"))))
354            .map(|(path, sha256)| (path.clone(), sha256.clone()))
355            .collect::<BTreeMap<_, _>>()
356    });
357    let mut schemas = BTreeMap::new();
358    for (producer, directory) in outputs {
359        let root = directory.join("grpc");
360        if !root.exists() {
361            continue;
362        }
363        for entry in std::fs::read_dir(root)? {
364            let entry = entry?;
365            if !entry.file_type()?.is_dir() {
366                continue;
367            }
368            let catalog = entry.file_name().into_string().map_err(|_| Error::Settings("schema catalog is not UTF-8".into()))?;
369            let key = (producer.clone(), catalog.clone());
370            if schemas
371                .insert(
372                    key.clone(),
373                    Schema {
374                        producer: producer.clone(),
375                        catalog,
376                        files: schema_files(&entry.path())?.into_iter().map(|(name, bytes)| (name, digest(&bytes))).collect(),
377                        directory: entry.path(),
378                    },
379                )
380                .is_some()
381            {
382                return Err(Error::Collision(format!("{}/{}", key.0, key.1)));
383            }
384        }
385    }
386    let schemas: Vec<_> = schemas.into_values().collect();
387    if let Some(expected) = expected_schemas {
388        let actual = schemas
389            .iter()
390            .flat_map(|schema| schema.files.iter().map(|(name, sha256)| (schema.directory.join(name), sha256.clone())))
391            .collect::<BTreeMap<_, _>>();
392        if actual != expected {
393            return Err(Error::Settings("generated schema set differs from the archive producer snapshot".into()));
394        }
395    }
396    cancellation.check()?;
397    for (path, bytes) in components {
398        publish(&path, &bytes)?;
399    }
400    Ok(Build { backend, profile, target, artifacts, schemas })
401}
402
403/* === Private Functions === */
404
405pub(super) fn validate_backend(settings: &BuildSettings) -> Result<(), Error> {
406    let enabled = match settings.backend {
407        BuildBackend::Sidecar => cfg!(feature = "sidecar"),
408        BuildBackend::Guest => cfg!(feature = "guest"),
409    };
410    if !enabled {
411        return Err(Error::Settings("selected packaging backend is disabled".into()));
412    }
413    Ok(())
414}
415
416// Package Only The Exact Bytes Stamped By An Earlier Build. This Path Never Runs Cargo.
417pub fn package(build: &Build, output: &Path) -> Result<Vec<PackageOutput>, Error> {
418    package_with_cancellation(build, output, &BuildCancellation::default())
419}
420
421pub fn package_with_cancellation(build: &Build, output: &Path, cancellation: &BuildCancellation) -> Result<Vec<PackageOutput>, Error> {
422    if build.artifacts.is_empty() || build.target.is_empty() {
423        return Err(Error::Settings("a stamped build with artifacts and a target is required".into()));
424    }
425    let artifacts = &build.artifacts;
426    let schemas = &build.schemas;
427    let backend = build.backend;
428    let target = &build.target;
429    use crate::bundle::{Bundle, Executable, Facility, Identity, Limits, Source};
430    let facility = match backend {
431        BuildBackend::Sidecar => Facility::Sidecar,
432        BuildBackend::Guest => Facility::Guest,
433    };
434    let inspected = match backend {
435        BuildBackend::Sidecar => super::Facility::Sidecar,
436        BuildBackend::Guest => super::Facility::Guest,
437    };
438    let mut prepared = BTreeMap::new();
439    for artifact in artifacts {
440        cancellation.check()?;
441        safe_name(&artifact.producer)?;
442        safe_name(&artifact.name)?;
443        let content = read_artifact(&artifact.path)?;
444        if digest(&content) != artifact.sha256 {
445            return Err(Error::ArtifactChanged(artifact.path.clone()));
446        }
447        let declaration = super::Declaration::inspect(&content, inspected)?;
448        let executable = artifact
449            .path
450            .file_name()
451            .and_then(|name| name.to_str())
452            .ok_or_else(|| Error::Settings("artifact filename is not UTF-8".into()))?
453            .to_owned();
454        let mut files = BTreeMap::from([(executable.clone(), content)]);
455        for schema in schemas.iter().filter(|schema| schema.producer == artifact.producer) {
456            safe_name(&schema.catalog)?;
457            let contents = schema_files(&schema.directory)?;
458            let hashes: BTreeMap<_, _> = contents.iter().map(|(name, bytes)| (name.clone(), digest(bytes))).collect();
459            if hashes != schema.files {
460                return Err(Error::ArtifactChanged(schema.directory.clone()));
461            }
462            for (name, bytes) in contents {
463                let name = format!("grpc/{}/{name}", schema.catalog);
464                if files.insert(name.clone(), bytes).is_some() {
465                    return Err(Error::Collision(name));
466                }
467            }
468        }
469        let sources: Vec<_> = files
470            .iter()
471            .map(|(path, bytes)| Source {
472                path,
473                bytes,
474                executable: (path == &executable).then(|| Executable { facility, target: target.to_owned(), registrations: Vec::new() }),
475            })
476            .collect();
477        let identity = Identity {
478            name: declaration.id,
479            version: declaration.version,
480            description: declaration.description,
481            package: Default::default(),
482        };
483        let bundle = Bundle::build(identity, &sources, Limits::default())?;
484        let destination = output.join(&artifact.producer).join(format!("{}.harmos", artifact.name));
485        if prepared.insert(destination.clone(), (artifact.producer.clone(), bundle)).is_some() {
486            return Err(Error::Collision(destination.display().to_string()));
487        }
488    }
489    cancellation.check()?;
490    // Publish Only After Every Artifact And Schema Has Passed Preparation.
491    let mut packages = Vec::new();
492    for (path, (producer, bundle)) in prepared {
493        let sha256 = digest(bundle.bytes());
494        publish(&path, bundle.bytes())?;
495        packages.push(PackageOutput { producer, path, sha256 });
496    }
497    Ok(packages)
498}
499
500fn safe_name(name: &str) -> Result<(), Error> {
501    if name.is_empty()
502        || name.len() > 255
503        || !name.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.'))
504        || matches!(name, "." | "..")
505    {
506        return Err(Error::Settings("artifact names must be single path components".into()));
507    }
508    Ok(())
509}
510
511fn read_artifact(path: &Path) -> Result<Vec<u8>, Error> {
512    use std::io::Read as _;
513    let limit = crate::bundle::Limits::default().file_bytes;
514    if path.is_symlink() {
515        return Err(Error::Settings("artifact must be a regular file".into()));
516    }
517    let file = std::fs::File::open(path)?;
518    if !file.metadata()?.is_file() {
519        return Err(Error::Settings("artifact must be a regular file".into()));
520    }
521    let mut bytes = Vec::new();
522    file.take(limit as u64 + 1).read_to_end(&mut bytes)?;
523    if bytes.len() > limit {
524        return Err(Error::Settings("artifact exceeds bundle byte limit".into()));
525    }
526    Ok(bytes)
527}
528
529fn schema_files(directory: &Path) -> Result<BTreeMap<String, Vec<u8>>, Error> {
530    let manifest = read_artifact(&directory.join("manifest.txt"))?;
531    let text = std::str::from_utf8(&manifest).map_err(|_| Error::Settings("schema manifest must be UTF-8".into()))?;
532    let names: Vec<_> = text.lines().collect();
533    if names.first() != Some(&"descriptors.bin")
534        || names.len() > crate::bundle::Limits::default().files
535        || names.iter().copied().collect::<BTreeSet<_>>().len() != names.len()
536        || names.contains(&"manifest.txt")
537    {
538        return Err(Error::Settings("schema manifest is incomplete or duplicated".into()));
539    }
540    let mut files = BTreeMap::new();
541    for name in names {
542        let relative = Path::new(name);
543        if relative.components().any(|part| !matches!(part, std::path::Component::Normal(_))) || name.contains(['\\', ':']) {
544            return Err(Error::Settings("schema manifest contains an unsafe path".into()));
545        }
546        files.insert(name.to_owned(), read_artifact(&directory.join(relative))?);
547    }
548    files.insert("manifest.txt".into(), manifest);
549    Ok(files)
550}
551
552fn publish(path: &std::path::Path, bytes: &[u8]) -> Result<(), Error> {
553    use std::io::Write;
554    let parent = path.parent().ok_or_else(|| Error::Settings("output has no parent directory".into()))?;
555    std::fs::create_dir_all(parent)?;
556    let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
557    temporary.write_all(bytes)?;
558    temporary.persist(path).map_err(|error| Error::Io(error.error))?;
559    Ok(())
560}
561
562fn read_messages(reader: impl BufRead, selected: &BTreeMap<String, String>) -> Result<BuildMessages, Error> {
563    let mut artifacts = Vec::new();
564    let mut outputs = BTreeSet::new();
565    let mut finished = false;
566    for line in reader.lines() {
567        let message: Message = serde_json::from_str(&line?)?;
568        if finished {
569            return Err(Error::Cargo("messages follow the completed Cargo build".into()));
570        }
571        if message.reason == "build-finished" {
572            if message.success != Some(true) {
573                return Err(Error::Cargo("captured Cargo build did not succeed".into()));
574            }
575            finished = true;
576            continue;
577        }
578        if let Some(rendered) = message.message.as_ref().and_then(|diagnostic| diagnostic.rendered.as_ref()) {
579            eprint!("{rendered}");
580        }
581        let Some(producer) = message.package_id.as_ref().and_then(|id| selected.get(id)) else {
582            continue;
583        };
584        if message.reason == "build-script-executed" {
585            if let Some(directory) = message.out_dir {
586                outputs.insert((producer.clone(), directory));
587            }
588            continue;
589        }
590        if message.reason != "compiler-artifact" || message.profile.is_some_and(|profile| profile.test) {
591            continue;
592        }
593        let Some(target) = message.target else {
594            continue;
595        };
596        if target.kind.iter().any(|kind| kind == "custom-build" || kind == "example" || kind == "test" || kind == "bench") {
597            continue;
598        }
599        let files = message
600            .executable
601            .into_iter()
602            .chain(message.filenames.into_iter().filter(|path| path.extension().is_some_and(|extension| extension == "wasm")));
603        for path in files {
604            artifacts.push(Artifact::read(producer.clone(), target.name.clone(), path)?);
605        }
606    }
607    if !finished {
608        return Err(Error::Cargo("captured Cargo stream has no successful build-finished event".into()));
609    }
610    artifacts.sort_by(|left, right| (&left.producer, &left.name, &left.path).cmp(&(&right.producer, &right.name, &right.path)));
611    artifacts.dedup_by(|left, right| left.producer == right.producer && left.name == right.name && left.path == right.path);
612    Ok((artifacts, outputs))
613}
614
615/* === Tests === */