Skip to main content

canic_host/canister_build/reuse/
mod.rs

1//! Module: canister_build::reuse
2//!
3//! Responsibility: reuse an exact finalized complete build after verifying inputs and outputs.
4//! Does not own: compilation, release identity allocation, or Fleet deployment.
5//! Boundary: source bytes and admitted tools key a cache; immutable release records remain authority.
6
7mod 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
43///
44/// CompleteBuildReuse
45///
46/// Host-owned invocation lock and byte-derived inputs retained through finalization.
47///
48
49pub 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
59///
60/// BuildReuseProgress
61///
62/// Host progress while acquiring the complete-build lock, before collecting inputs.
63///
64
65pub enum BuildReuseProgress {
66    WaitingForLock(Duration),
67    LockFinished(Duration),
68}
69
70///
71/// ReusedCompleteBuild
72///
73/// Verified host release returned to the CLI without compilation or transformation.
74///
75
76pub struct ReusedCompleteBuild {
77    pub release_build_id: ReleaseBuildId,
78    pub manifest_path: PathBuf,
79    pub roles: Vec<String>,
80}
81
82///
83/// CompleteBuildReuseRecord
84///
85/// Host cache index; the finalized release and its manifests remain authoritative.
86///
87
88#[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///
99/// BuildReuseError
100///
101/// Host failure to establish reuse; no cache result authorizes deployment effects.
102///
103
104#[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    /// Explain a miss using optional prior observations, without changing hit admission.
196    #[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    /// Return a hit only after checking every recorded output and all release manifest bindings.
203    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    /// Record only a finalized release whose governed inputs remained byte-identical during work.
251    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, &current.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            // Generated infrastructure enables family sources absent from the App's graph.
344            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}