1mod dependencies;
8mod diagnostics;
9mod snapshot;
10#[cfg(test)]
11mod tests;
12
13use crate::{
14 build_toolchain::BuildToolchain,
15 canister_build::WorkspaceBuildContext,
16 cargo_metadata::cargo_metadata_catalog_for_manifest,
17 durable_io::{lock_file_with_progress, read_regular_bytes, write_bytes},
18 release_build::validate_finalized_release_build_manifest,
19 release_set::{
20 AppConfigSnapshot, load_persisted_application_artifact_union,
21 load_persisted_canic_infrastructure_artifact_manifest,
22 load_persisted_current_release_set_manifest,
23 },
24};
25use canic_core::ids::ReleaseBuildId;
26use serde::{Deserialize, Serialize};
27use sha2::{Digest, Sha256};
28use std::{
29 collections::{BTreeMap, BTreeSet},
30 env,
31 ffi::OsStr,
32 fs,
33 io::{self, Read},
34 path::{Path, PathBuf},
35 time::{Duration, Instant},
36};
37use thiserror::Error;
38
39use snapshot::BuildInputSnapshot;
40
41const RECORD_LIMIT: usize = 4 * 1024 * 1024;
42
43pub struct CompleteBuildReuse {
50 context: WorkspaceBuildContext,
51 tool_paths: Vec<PathBuf>,
52 inputs: BuildInputSnapshot,
53 record_path: PathBuf,
54 diagnostics: diagnostics::InputDiagnostics,
55 input_locations: diagnostics::InputLocations,
56 _lock: fs::File,
57}
58
59pub enum BuildReuseProgress {
66 WaitingForLock(Duration),
67 LockFinished(Duration),
68}
69
70pub struct ReusedCompleteBuild {
77 pub release_build_id: ReleaseBuildId,
78 pub manifest_path: PathBuf,
79 pub roles: Vec<String>,
80}
81
82#[derive(Debug, Deserialize, Serialize)]
89#[serde(deny_unknown_fields)]
90struct CompleteBuildReuseRecord {
91 schema_version: u8,
92 inputs: String,
93 release_build_id: ReleaseBuildId,
94 files: BTreeMap<String, String>,
95 roles: Vec<String>,
96}
97
98#[derive(Debug, Error)]
105pub enum BuildReuseError {
106 #[error("build inputs changed during compilation; no reusable build was recorded")]
107 Changed,
108
109 #[error("build input changed during compilation: {0}; no reusable build was recorded")]
110 ChangedInput(PathBuf),
111
112 #[error(
113 "build input was first discovered after compilation: {0}; retry to verify it before recording reuse"
114 )]
115 UnobservedInput(PathBuf),
116
117 #[error("build reuse evidence failed: {0}")]
118 Evidence(String),
119
120 #[error("build reuse I/O failed: {0}")]
121 Io(#[from] io::Error),
122
123 #[error("build reuse document failed: {0}")]
124 Json(#[from] serde_json::Error),
125
126 #[error("build input is not a regular file or directory: {0}")]
127 Unsupported(PathBuf),
128
129 #[error("complete-build reuse requires a directly bound native tool: {0}")]
130 UnboundTool(PathBuf),
131}
132
133impl CompleteBuildReuse {
134 pub(crate) fn prepare(
135 context: &WorkspaceBuildContext,
136 tools: &BuildToolchain,
137 mut progress: impl FnMut(BuildReuseProgress),
138 ) -> Result<Self, BuildReuseError> {
139 let lock_started = Instant::now();
140 let lock = lock_file_with_progress(
141 &context
142 .icp_root
143 .join(".canic/locks/complete-build-reuse.lock"),
144 |elapsed| progress(BuildReuseProgress::WaitingForLock(elapsed)),
145 );
146 progress(BuildReuseProgress::LockFinished(lock_started.elapsed()));
147 let lock = lock?;
148 let mut tool_paths = vec![
149 env::current_exe()?,
150 tools.ic_wasm().path().to_path_buf(),
151 resolve_tool(std::ffi::OsStr::new("candid-extractor"))?,
152 ];
153 if let Some(binaryen) = tools.binaryen() {
154 tool_paths.push(binaryen.path().to_path_buf());
155 }
156 for tool in &tool_paths {
157 require_native_tool(tool)?;
158 }
159 for name in ["CARGO", "RUSTC", "RUSTC_WRAPPER", "RUSTC_WORKSPACE_WRAPPER"] {
160 let selected = env::var_os(name).or_else(|| match name {
161 "CARGO" => Some("cargo".into()),
162 "RUSTC" => Some("rustc".into()),
163 _ => None,
164 });
165 if let Some(selected) = selected.filter(|value| !value.is_empty()) {
166 let path = resolve_tool(&selected)?;
167 require_native_tool(&path)?;
168 tool_paths.push(path);
169 }
170 }
171 if env::var_os("RUSTC_WRAPPER").is_none()
172 && let Ok(path) = resolve_tool(std::ffi::OsStr::new("sccache"))
173 {
174 require_native_tool(&path)?;
175 tool_paths.push(path);
176 }
177 let inputs = input_snapshot(context, &tool_paths)?;
178 let diagnostics = diagnostics::InputDiagnostics::capture(context, &tool_paths, &inputs);
179 let input_locations = diagnostics::InputLocations::capture(context);
180 let record_path = context
181 .icp_root
182 .join(".canic/build-reuse")
183 .join(format!("{}.json", inputs.digest()));
184 Ok(Self {
185 context: context.clone(),
186 tool_paths,
187 inputs,
188 record_path,
189 diagnostics,
190 input_locations,
191 _lock: lock,
192 })
193 }
194
195 #[must_use]
197 pub fn miss_reason(&self) -> String {
198 self.diagnostics
199 .explain_miss(&self.context.icp_root.join(".canic/build-reuse"))
200 }
201
202 pub fn load(&self) -> Result<Option<ReusedCompleteBuild>, BuildReuseError> {
204 let bytes = match read_regular_bytes(&self.record_path, RECORD_LIMIT) {
205 Ok(bytes) => bytes,
206 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
207 Err(error) => return Err(error.into()),
208 };
209 let record: CompleteBuildReuseRecord = serde_json::from_slice(&bytes)?;
210 if record.schema_version != 1
211 || record.inputs != self.inputs.digest()
212 || record.files.is_empty()
213 || record.roles.is_empty()
214 {
215 return Err(BuildReuseError::Evidence(
216 "invalid complete-build cache record".into(),
217 ));
218 }
219 let config = AppConfigSnapshot::load(&self.context.config_path)
220 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?;
221 let expected = config
222 .model()
223 .deployable_roles()
224 .into_iter()
225 .map(|role| role.to_string())
226 .chain(["fleet_coordinator".to_string(), "wasm_store".to_string()])
227 .collect::<BTreeSet<_>>();
228 let actual = record.roles.iter().cloned().collect::<BTreeSet<_>>();
229 if actual != expected || actual.len() != record.roles.len() {
230 return Err(BuildReuseError::Evidence(
231 "retained role set differs".into(),
232 ));
233 }
234 let directory = self.release_directory(record.release_build_id);
235 if output_files(&directory)? != record.files {
236 return Err(BuildReuseError::Evidence(
237 "retained release bytes changed".into(),
238 ));
239 }
240 let manifest_path = verify_release(&self.context, record.release_build_id)?;
241 self.diagnostics
242 .retain(&self.context.icp_root.join(".canic/build-reuse"));
243 Ok(Some(ReusedCompleteBuild {
244 release_build_id: record.release_build_id,
245 manifest_path,
246 roles: record.roles,
247 }))
248 }
249
250 pub fn record(
252 &self,
253 release_build_id: ReleaseBuildId,
254 roles: Vec<String>,
255 ) -> Result<(), BuildReuseError> {
256 verify_release(&self.context, release_build_id)?;
257 let after = input_snapshot(&self.context, &self.tool_paths)?;
258 self.inputs.validate_after(&after).inspect_err(|error| {
259 if let Some(path) = diagnostics::retain_rejection(
260 &self.context,
261 release_build_id,
262 &self.input_locations,
263 &self.inputs,
264 &after,
265 error,
266 ) {
267 eprintln!("Build input rejection evidence: {}", path.display());
268 }
269 })?;
270 let inputs = after.digest();
271 let record_path = self.record_path.with_file_name(format!("{inputs}.json"));
272 let record = CompleteBuildReuseRecord {
273 schema_version: 1,
274 inputs,
275 release_build_id,
276 files: output_files(&self.release_directory(release_build_id))?,
277 roles,
278 };
279 write_bytes(&record_path, &serde_json::to_vec(&record)?)?;
280 diagnostics::InputDiagnostics::capture(&self.context, &self.tool_paths, &after)
281 .retain(&self.context.icp_root.join(".canic/build-reuse"));
282 Ok(())
283 }
284
285 fn release_directory(&self, id: ReleaseBuildId) -> PathBuf {
286 self.context
287 .icp_root
288 .join(".canic/release-builds")
289 .join(id.to_string())
290 }
291}
292
293fn verify_release(
294 context: &WorkspaceBuildContext,
295 id: ReleaseBuildId,
296) -> Result<PathBuf, BuildReuseError> {
297 let verify = || -> Result<PathBuf, Box<dyn std::error::Error>> {
298 let current = load_persisted_current_release_set_manifest(&context.icp_root, id)?;
299 let release =
300 validate_finalized_release_build_manifest(&context.icp_root, id, ¤t.path)?;
301 if release.record.build_profile != context.profile
302 || release.record.build_network != context.build_network
303 {
304 return Err("retained build profile/network differs".into());
305 }
306 let config = AppConfigSnapshot::load(&context.config_path)?;
307 let app = load_persisted_application_artifact_union(
308 &context.icp_root,
309 config.component_topology(),
310 id,
311 )?;
312 let infrastructure =
313 load_persisted_canic_infrastructure_artifact_manifest(&context.icp_root, id)?;
314 if app.digest != current.manifest.application_artifact_union_sha256
315 || infrastructure.digest != current.manifest.infrastructure_artifact_manifest_sha256
316 {
317 return Err("retained child manifest digest differs".into());
318 }
319 let fixtures = current
320 .manifest
321 .verify_fixtures(&context.icp_root, config.component_topology())?;
322 crate::release_set::fixture::load_configured_fixture_sources(
323 &context.icp_root,
324 &context.config_path,
325 )?
326 .verify_retained(&fixtures)?;
327 Ok(current.path)
328 };
329 verify().map_err(|error| BuildReuseError::Evidence(error.to_string()))
330}
331
332fn input_snapshot(
333 context: &WorkspaceBuildContext,
334 tools: &[PathBuf],
335) -> Result<BuildInputSnapshot, BuildReuseError> {
336 let metadata =
337 cargo_metadata_catalog_for_manifest(&context.workspace_root.join("Cargo.toml"), true, true)
338 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?;
339 let mut roots = BTreeSet::new();
340 let mut files = BTreeMap::new();
341 for package in metadata.packages {
342 if package.name == "canic" {
343 roots.extend(
345 crate::fleet_package::resolved_family_roots(
346 &package.manifest_path,
347 &package.version,
348 )
349 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?
350 .into_values()
351 .map(|root| root.canonicalize())
352 .collect::<Result<Vec<_>, _>>()?,
353 );
354 }
355 roots.insert(
356 package
357 .manifest_path
358 .parent()
359 .ok_or_else(|| BuildReuseError::Unsupported(package.manifest_path.clone()))?
360 .canonicalize()?,
361 );
362 for target in package.targets {
363 let path = match target.src_path.canonicalize() {
364 Ok(path) => path,
365 Err(error) if error.kind() == io::ErrorKind::NotFound => target.src_path,
366 Err(error) => return Err(error.into()),
367 };
368 add_optional(&path, &mut files)?;
369 }
370 }
371 roots.insert(
372 context
373 .config_path
374 .parent()
375 .ok_or_else(|| BuildReuseError::Unsupported(context.config_path.clone()))?
376 .canonicalize()?,
377 );
378 for root in &roots {
379 collect_files(root, root, &mut files, true)?;
380 }
381 dependencies::append_observed_cargo_inputs(context, &mut files)?;
382 append_fixture_inputs(context, &mut files)?;
383 add_file(&context.config_path, &mut files)?;
384 for root in [&context.workspace_root, &metadata.workspace_root] {
385 for ancestor in root.ancestors() {
386 for name in [
387 "Cargo.toml",
388 "Cargo.lock",
389 "rust-toolchain",
390 "rust-toolchain.toml",
391 ".cargo/config",
392 ".cargo/config.toml",
393 ] {
394 add_optional(&ancestor.join(name), &mut files)?;
395 }
396 }
397 }
398 let cargo_home = env::var_os("CARGO_HOME")
399 .map(PathBuf::from)
400 .or_else(|| env::var_os("HOME").map(|home| PathBuf::from(home).join(".cargo")));
401 if let Some(root) = cargo_home {
402 for name in ["config", "config.toml"] {
403 add_optional(&root.join(name), &mut files)?;
404 }
405 }
406 for tool in tools {
407 add_file(tool, &mut files)?;
408 }
409 append_rust_toolchain_inputs(context, &mut files)?;
410
411 Ok(BuildInputSnapshot {
412 identity: input_identity(context)?,
413 files,
414 })
415}
416
417fn append_fixture_inputs(
418 context: &WorkspaceBuildContext,
419 files: &mut BTreeMap<String, String>,
420) -> Result<(), BuildReuseError> {
421 let fixtures = crate::release_set::fixture::load_configured_fixture_sources(
422 &context.icp_root,
423 &context.config_path,
424 )
425 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?;
426 for (path, hash) in fixtures.source_files {
427 let key = path
428 .to_str()
429 .ok_or_else(|| BuildReuseError::Unsupported(path.clone()))?
430 .to_owned();
431 if files
432 .insert(key, hash.clone())
433 .is_some_and(|before| before != hash)
434 {
435 return Err(BuildReuseError::ChangedInput(path));
436 }
437 }
438 Ok(())
439}
440
441fn input_identity(context: &WorkspaceBuildContext) -> Result<String, BuildReuseError> {
442 let mut digest = Sha256::new();
443 hash_field(&mut digest, b"canic.complete-build-inputs.v1");
444 hash_field(
445 &mut digest,
446 include_bytes!("../../build_environment/mod.rs"),
447 );
448 hash_field(&mut digest, context.profile.target_dir_name().as_bytes());
449 hash_field(&mut digest, context.build_network.as_str().as_bytes());
450 hash_field(
451 &mut digest,
452 context.config_path.as_os_str().as_encoded_bytes(),
453 );
454 let mut environment = crate::build_environment::inputs();
455 environment.sort();
456 for (key, value) in environment {
457 hash_field(&mut digest, key.as_encoded_bytes());
458 hash_field(&mut digest, value.as_encoded_bytes());
459 }
460 for (tool, args) in [("rustc", &["-vV"][..]), ("cargo", &["-V"][..])] {
461 let selected = env::var_os(tool.to_uppercase()).unwrap_or_else(|| tool.into());
462 let output = crate::build_environment::command(selected)
463 .args(args)
464 .current_dir(&context.workspace_root)
465 .output()?;
466 if !output.status.success() {
467 return Err(BuildReuseError::Evidence(format!(
468 "{tool} identity unavailable"
469 )));
470 }
471 hash_field(&mut digest, &output.stdout);
472 }
473 Ok(format!("{:x}", digest.finalize()))
474}
475
476#[cfg(test)]
477fn input_digest(
478 context: &WorkspaceBuildContext,
479 tools: &[PathBuf],
480) -> Result<String, BuildReuseError> {
481 Ok(input_snapshot(context, tools)?.digest())
482}
483
484fn append_rust_toolchain_inputs(
485 context: &WorkspaceBuildContext,
486 files: &mut BTreeMap<String, String>,
487) -> Result<(), BuildReuseError> {
488 let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
489 let sysroot = crate::build_environment::command(&rustc)
490 .args(["--print", "sysroot"])
491 .current_dir(&context.workspace_root)
492 .output()?;
493 if !sysroot.status.success() {
494 return Err(BuildReuseError::Evidence("Rust sysroot unavailable".into()));
495 }
496 let sysroot = PathBuf::from(
497 String::from_utf8(sysroot.stdout)
498 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?
499 .trim(),
500 );
501 for name in ["rustc", "cargo"] {
502 add_file(&sysroot.join("bin").join(name), files)?;
503 }
504 for entry in fs::read_dir(sysroot.join("lib"))? {
505 let path = entry?.path();
506 if fs::symlink_metadata(&path)?.is_file() {
507 add_file(&path, files)?;
508 }
509 }
510 let host = crate::build_environment::command(&rustc)
511 .args(["--print", "host-tuple"])
512 .current_dir(&context.workspace_root)
513 .output()?;
514 if !host.status.success() {
515 return Err(BuildReuseError::Evidence(
516 "Rust host target unavailable".into(),
517 ));
518 }
519 let host = String::from_utf8(host.stdout)
520 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?;
521 for target in [host.trim(), "wasm32-unknown-unknown"] {
522 let root = sysroot.join("lib/rustlib").join(target);
523 collect_files(&root, &root, files, true)?;
524 }
525
526 Ok(())
527}
528
529fn output_files(root: &Path) -> Result<BTreeMap<String, String>, BuildReuseError> {
530 let mut files = BTreeMap::new();
531 collect_files(root, root, &mut files, false)?;
532 Ok(files)
533}
534
535fn collect_files(
536 root: &Path,
537 path: &Path,
538 files: &mut BTreeMap<String, String>,
539 source: bool,
540) -> Result<(), BuildReuseError> {
541 let metadata = fs::symlink_metadata(path)?;
542 if metadata.is_file() {
543 let key = if source {
544 path.to_path_buf()
545 } else {
546 path.strip_prefix(root)
547 .map_err(|error| BuildReuseError::Evidence(error.to_string()))?
548 .to_path_buf()
549 };
550 files.insert(
551 key.to_str()
552 .ok_or_else(|| BuildReuseError::Unsupported(path.to_path_buf()))?
553 .to_string(),
554 file_hash(path)?,
555 );
556 } else if metadata.is_dir() {
557 if source {
558 files.insert(path.to_string_lossy().into_owned(), "directory".into());
559 }
560 for entry in fs::read_dir(path)? {
561 let entry = entry?;
562 if source && source_entry_is_excluded(&entry.file_name()) {
563 continue;
564 }
565 collect_files(root, &entry.path(), files, source)?;
566 }
567 } else {
568 return Err(BuildReuseError::Unsupported(path.to_path_buf()));
569 }
570 Ok(())
571}
572
573fn source_entry_is_excluded(name: &OsStr) -> bool {
574 matches!(
575 name.to_str(),
576 Some("target" | ".git" | ".canic" | ".icp" | ".tmp")
577 )
578}
579
580fn add_optional(path: &Path, files: &mut BTreeMap<String, String>) -> Result<(), BuildReuseError> {
581 match fs::symlink_metadata(path) {
582 Ok(_) => add_file(path, files),
583 Err(error) if error.kind() == io::ErrorKind::NotFound => {
584 files.insert(path.to_string_lossy().into_owned(), "absent".to_string());
585 Ok(())
586 }
587 Err(error) => Err(error.into()),
588 }
589}
590
591fn add_file(path: &Path, files: &mut BTreeMap<String, String>) -> Result<(), BuildReuseError> {
592 files.insert(
593 path.to_str()
594 .ok_or_else(|| BuildReuseError::Unsupported(path.to_path_buf()))?
595 .to_string(),
596 file_hash(path)?,
597 );
598 Ok(())
599}
600
601pub(super) fn file_hash(path: &Path) -> Result<String, BuildReuseError> {
602 if !fs::symlink_metadata(path)?.is_file() {
603 return Err(BuildReuseError::Unsupported(path.to_path_buf()));
604 }
605 let mut options = fs::OpenOptions::new();
606 options.read(true);
607 #[cfg(unix)]
608 {
609 use std::os::unix::fs::OpenOptionsExt as _;
610 options.custom_flags(rustix::fs::OFlags::NOFOLLOW.bits().cast_signed());
611 }
612 let mut file = options.open(path)?;
613 if !file.metadata()?.is_file() {
614 return Err(BuildReuseError::Unsupported(path.to_path_buf()));
615 }
616 let mut digest = Sha256::new();
617 let mut buffer = [0_u8; 8 * 1024];
618 loop {
619 let count = file.read(&mut buffer)?;
620 if count == 0 {
621 break;
622 }
623 digest.update(&buffer[..count]);
624 }
625 Ok(format!("{:x}", digest.finalize()))
626}
627
628fn hash_field(digest: &mut Sha256, value: &[u8]) {
629 digest.update((value.len() as u64).to_be_bytes());
630 digest.update(value);
631}
632
633pub(super) fn require_native_tool(path: &Path) -> Result<(), BuildReuseError> {
634 let mut file = fs::File::open(path)?;
635 let mut magic = [0_u8; 4];
636 file.read_exact(&mut magic)?;
637 if matches!(
638 magic,
639 [0x7f, b'E', b'L', b'F']
640 | [0xcf, 0xfa, 0xed, 0xfe]
641 | [0xfe, 0xed, 0xfa, 0xcf]
642 | [0xca, 0xfe, 0xba, 0xbe | 0xbf]
643 ) {
644 Ok(())
645 } else {
646 Err(BuildReuseError::UnboundTool(path.to_path_buf()))
647 }
648}
649
650pub(super) fn resolve_tool(command: &std::ffi::OsStr) -> Result<PathBuf, BuildReuseError> {
651 let path = Path::new(command);
652 if path.components().count() > 1 {
653 return Ok(path.canonicalize()?);
654 }
655 if let Some(search) = env::var_os("PATH") {
656 for directory in env::split_paths(&search) {
657 let candidate = directory.join(command);
658 if candidate.is_file() {
659 #[cfg(unix)]
660 {
661 use std::os::unix::fs::PermissionsExt as _;
662 if fs::metadata(&candidate)?.permissions().mode() & 0o111 == 0 {
663 continue;
664 }
665 }
666 return Ok(candidate.canonicalize()?);
667 }
668 }
669 }
670 Err(BuildReuseError::UnboundTool(path.to_path_buf()))
671}