#[cfg(test)]
mod tests;
use super::{
WorkspaceBuildContext,
candid::{extract_candid_bytes, extract_candid_with_tool},
reuse::{BuildReuseError, file_hash, require_native_tool, resolve_tool},
};
use crate::durable_io::{read_regular_bytes, write_bytes};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
io,
path::{Path, PathBuf},
};
const CACHE_RECORD_LIMIT: usize = 4 * 1024 * 1024;
const MAX_EXTRACTORS: usize = 4;
pub(super) struct CandidExtractionInput<'a> {
pub role: &'a str,
pub wasm: PathBuf,
}
#[derive(Debug, thiserror::Error)]
pub(super) enum CandidBatchError {
#[error("Candid extraction for {role} failed: {detail}")]
Extraction { role: String, detail: String },
#[error("Candid extraction worker for {role} panicked")]
WorkerPanicked { role: String },
}
pub(super) fn extract_configured_candids(
cache: Option<&CandidExtractionCache>,
inputs: &[CandidExtractionInput<'_>],
) -> Result<Vec<Vec<u8>>, CandidBatchError> {
let mut outputs = Vec::with_capacity(inputs.len());
for batch in inputs.chunks(MAX_EXTRACTORS) {
let results = std::thread::scope(|scope| {
let mut workers = Vec::with_capacity(batch.len());
for input in batch {
workers.push(scope.spawn(move || {
extract_configured_candid(cache, input.role, &input.wasm).map_err(|error| {
CandidBatchError::Extraction {
role: input.role.to_string(),
detail: error.to_string(),
}
})
}));
}
workers
.into_iter()
.zip(batch)
.map(|(worker, input)| {
worker.join().unwrap_or_else(|_| {
Err(CandidBatchError::WorkerPanicked {
role: input.role.to_string(),
})
})
})
.collect::<Vec<_>>()
});
for result in results {
outputs.push(result?);
}
}
Ok(outputs)
}
pub(super) struct CandidExtractionCache {
directory: PathBuf,
extractor: PathBuf,
extractor_sha256: String,
identity: String,
}
#[derive(Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
struct CandidExtractionRecord {
schema_version: u8,
identity: String,
wasm_sha256: String,
candid_sha256: String,
candid: String,
}
impl CandidExtractionCache {
pub(super) fn prepare(context: &WorkspaceBuildContext) -> Option<Self> {
match Self::new(context.icp_root.join(".canic/build-reuse/declarations")) {
Ok(cache) => Some(cache),
Err(error) => {
eprintln!("Declaration reuse unavailable: {error}");
None
}
}
}
fn new(directory: PathBuf) -> Result<Self, BuildReuseError> {
let extractor = resolve_tool("candid-extractor".as_ref())?;
Self::with_extractor(directory, extractor)
}
fn with_extractor(directory: PathBuf, extractor: PathBuf) -> Result<Self, BuildReuseError> {
require_native_tool(&extractor)?;
let extractor_sha256 = file_hash(&extractor)?;
let mut identity = Sha256::new();
identity.update(b"canic.candid-extraction.v1");
identity.update(include_bytes!("../../build_environment/mod.rs"));
identity.update(extractor_sha256.as_bytes());
identity.update(env!("CARGO_PKG_VERSION").as_bytes());
identity.update(include_bytes!("../candid.rs"));
identity.update(include_bytes!("mod.rs"));
let mut environment = crate::build_environment::inputs();
environment.sort();
for pair in environment {
for field in <[_; 2]>::from(pair) {
let bytes = field.as_encoded_bytes();
identity.update((bytes.len() as u64).to_be_bytes());
identity.update(bytes);
}
}
Ok(Self {
directory,
extractor,
extractor_sha256,
identity: format!("{:x}", identity.finalize()),
})
}
fn record_path(&self, wasm_sha256: &str) -> PathBuf {
self.directory
.join(&self.identity)
.join(format!("{wasm_sha256}.json"))
}
fn extract(&self, wasm: &Path) -> Result<(Vec<u8>, bool), Box<dyn std::error::Error>> {
self.require_current_extractor()?;
let wasm_sha256 = file_hash(wasm)?;
let path = self.record_path(&wasm_sha256);
match self.load(&path, &wasm_sha256) {
Ok(Some(candid)) => return Ok((candid, true)),
Ok(None) => {}
Err(error) => eprintln!("Declaration cache rejected for {}: {error}", wasm.display()),
}
let candid = extract_candid_with_tool(wasm, &self.extractor)?;
self.require_current_extractor()?;
if file_hash(wasm)? != wasm_sha256 {
return Err(BuildReuseError::ChangedInput(wasm.into()).into());
}
let record = CandidExtractionRecord {
schema_version: 1,
identity: self.identity.clone(),
wasm_sha256,
candid_sha256: format!("{:x}", Sha256::digest(&candid)),
candid: String::from_utf8(candid.clone())?,
};
let bytes = serde_json::to_vec(&record)?;
if bytes.len() <= CACHE_RECORD_LIMIT
&& let Err(error) = write_bytes(&path, &bytes)
{
eprintln!(
"Declaration cache not recorded for {}: {error}",
wasm.display()
);
}
Ok((candid, false))
}
fn require_current_extractor(&self) -> Result<(), BuildReuseError> {
if file_hash(&self.extractor)? != self.extractor_sha256 {
return Err(BuildReuseError::ChangedInput(self.extractor.clone()));
}
Ok(())
}
fn load(&self, path: &Path, wasm_sha256: &str) -> Result<Option<Vec<u8>>, BuildReuseError> {
let bytes = match read_regular_bytes(path, CACHE_RECORD_LIMIT) {
Ok(bytes) => bytes,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let record: CandidExtractionRecord = serde_json::from_slice(&bytes)?;
if record.schema_version != 1
|| record.identity != self.identity
|| record.wasm_sha256 != wasm_sha256
|| record.candid_sha256 != format!("{:x}", Sha256::digest(record.candid.as_bytes()))
{
return Err(BuildReuseError::Evidence(
"Candid extraction binding differs".into(),
));
}
Ok(Some(record.candid.into_bytes()))
}
}
pub(super) fn extract_configured_candid(
cache: Option<&CandidExtractionCache>,
role: &str,
wasm: &Path,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let Some(cache) = cache else {
return extract_candid_bytes(wasm);
};
let (candid, reused) = cache.extract(wasm)?;
eprintln!(
"Build declaration cache {role}: {}",
if reused {
"hit (verified compiled Wasm)"
} else {
"miss (extracted current Wasm)"
}
);
Ok(candid)
}