1use std::collections::{BTreeMap, BTreeSet};
20use std::io::{BufRead, BufReader};
21use std::path::{Path, PathBuf};
22use std::process::{Command, Stdio};
23
24use crate::digest;
26use serde::Deserialize;
27
28use super::{BuildCancellation, Error, metadata::Metadata, process::Process};
30use crate::config::{BuildBackend, BuildProfile, BuildSettings};
31
32type BuildMessages = (Vec<Artifact>, BTreeSet<(String, PathBuf)>);
39
40#[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#[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
133impl Artifact {
140 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 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 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
241pub 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 #[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
403pub(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
416pub 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 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