use crate::budget::{Budget, BudgetError};
use crate::{ContractValidationError, Limits, RuntimeContext};
use sha2::{Digest, Sha256};
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ArtifactKind {
ContractJsonV1,
ContractEnvelopeJsonV1,
CompilationJsonV1,
}
impl ArtifactKind {
const fn domain(self) -> &'static str {
match self {
Self::ContractJsonV1 => "candid-core:artifact:contract-json:v1",
Self::ContractEnvelopeJsonV1 => "candid-core:artifact:contract-envelope-json:v1",
Self::CompilationJsonV1 => "candid-core:artifact:compilation-json:v1",
}
}
}
const RESOURCE: &str = "artifact_identity_work";
const HASH_CHUNK_BYTES: usize = 64 * 1024;
pub fn artifact_id_with_limits(
kind: ArtifactKind,
bytes: &[u8],
limits: &Limits,
) -> Result<String, ContractValidationError> {
artifact_id_with_context(kind, bytes, &RuntimeContext::new(limits.clone()))
}
pub fn artifact_id_with_context(
kind: ArtifactKind,
bytes: &[u8],
context: &RuntimeContext,
) -> Result<String, ContractValidationError> {
let mut budget = context.budget();
artifact_id_with_budget(kind, bytes, &mut budget)
}
fn artifact_id_with_budget(
kind: ArtifactKind,
bytes: &[u8],
budget: &mut Budget<'_>,
) -> Result<String, ContractValidationError> {
crate::budget::observe_input_bytes(budget, bytes.len())?;
let domain = kind.domain();
let limit = budget.limits().max_artifact_identity_work;
let mut hasher = Sha256::new();
charge(budget, limit, domain.len().saturating_add(1))?;
hasher.update(domain.as_bytes());
hasher.update([0]);
for chunk in bytes.chunks(HASH_CHUNK_BYTES) {
charge(budget, limit, chunk.len())?;
hasher.update(chunk);
observe_chunk_boundary();
}
budget
.checkpoint()
.map_err(BudgetError::into_contract_error)?;
Ok(format!(
"{domain}:sha256:{}",
hex::encode(hasher.finalize())
))
}
fn charge(
budget: &mut Budget<'_>,
limit: usize,
amount: usize,
) -> Result<(), ContractValidationError> {
budget
.charge(RESOURCE, limit, amount)
.map(|_| ())
.map_err(BudgetError::into_contract_error)
}
#[cfg(test)]
thread_local! {
static CANCEL_AT_CHUNK_BOUNDARY: std::cell::Cell<Option<crate::CancellationToken>> =
const { std::cell::Cell::new(None) };
}
#[cfg(test)]
fn observe_chunk_boundary() {
CANCEL_AT_CHUNK_BOUNDARY.with(|slot| {
if let Some(token) = slot.take() {
token.cancel();
}
});
}
#[cfg(not(test))]
fn observe_chunk_boundary() {}
#[cfg(test)]
mod tests {
use super::*;
use crate::CancellationToken;
const ALL_KINDS: &[ArtifactKind] = &[
ArtifactKind::ContractJsonV1,
ArtifactKind::ContractEnvelopeJsonV1,
ArtifactKind::CompilationJsonV1,
];
fn exact_work(kind: ArtifactKind, len: usize) -> usize {
kind.domain().len() + 1 + len
}
fn id(kind: ArtifactKind, bytes: &[u8]) -> String {
artifact_id_with_limits(kind, bytes, &Limits::default()).unwrap()
}
fn resource_failure(error: &ContractValidationError) -> (String, u64, u64) {
let violation = &error.violations[0];
assert_eq!(violation.code, "resource_limit_exceeded", "{error:#?}");
let info = violation.resource_limit.as_ref().unwrap();
(info.resource.clone(), info.limit, info.observed)
}
#[test]
fn empty_input_pins_the_domain_framing() {
assert_eq!(
id(ArtifactKind::ContractJsonV1, b""),
"candid-core:artifact:contract-json:v1:sha256:66c1371d29c896c2b292edc5dc1d344bf39103c5a1011141ed6883ace3e95401"
);
assert_eq!(
id(ArtifactKind::ContractEnvelopeJsonV1, b""),
"candid-core:artifact:contract-envelope-json:v1:sha256:1642aac2ca520b95cc0c31068934081c206f8673bb3779058bb88e331ff21603"
);
assert_eq!(
id(ArtifactKind::CompilationJsonV1, b""),
"candid-core:artifact:compilation-json:v1:sha256:6e716227d7ae7ac930966faafa9812eeac2fa34a85c1f03b91d949ca88b21807"
);
}
#[test]
fn every_domain_is_frozen_distinct_and_well_formed() {
assert_eq!(
ALL_KINDS
.iter()
.map(|kind| kind.domain())
.collect::<Vec<_>>(),
[
"candid-core:artifact:contract-json:v1",
"candid-core:artifact:contract-envelope-json:v1",
"candid-core:artifact:compilation-json:v1",
]
);
for (position, kind) in ALL_KINDS.iter().enumerate() {
let domain = kind.domain();
assert!(domain.is_ascii() && !domain.contains('\0'), "{domain}");
assert!(domain.starts_with("candid-core:artifact:"), "{domain}");
for other in &ALL_KINDS[position + 1..] {
assert_ne!(domain, other.domain());
}
}
}
#[test]
fn the_preimage_is_the_domain_a_nul_byte_and_the_exact_bytes() {
for &kind in ALL_KINDS {
let bytes = b"{\"contract\":{}}";
let mut preimage = kind.domain().as_bytes().to_vec();
preimage.push(0);
preimage.extend_from_slice(bytes);
assert_eq!(
id(kind, bytes),
format!(
"{}:sha256:{}",
kind.domain(),
hex::encode(Sha256::digest(&preimage))
)
);
}
}
#[test]
fn identical_bytes_under_different_kinds_differ() {
let bytes = b"{}";
let rendered: Vec<String> = ALL_KINDS.iter().map(|&kind| id(kind, bytes)).collect();
for (position, one) in rendered.iter().enumerate() {
for other in &rendered[position + 1..] {
assert_ne!(one, other, "the domain must separate the digest space");
}
}
}
#[test]
fn input_bytes_is_enforced_before_any_hashing_work() {
let limits = Limits::default()
.with_max_input_bytes(3)
.with_max_artifact_identity_work(0);
let error =
artifact_id_with_limits(ArtifactKind::CompilationJsonV1, b"abcd", &limits).unwrap_err();
assert_eq!(
resource_failure(&error),
("input_bytes".to_string(), 3, 4),
"the byte gate must precede identity work"
);
}
#[test]
fn work_succeeds_at_the_exact_limit_and_fails_one_unit_below() {
let kind = ArtifactKind::ContractEnvelopeJsonV1;
let bytes = b"{\"contract\":{},\"extensions\":{}}";
let work = exact_work(kind, bytes.len());
artifact_id_with_limits(
kind,
bytes,
&Limits::default().with_max_artifact_identity_work(work),
)
.expect("the exact work bound must succeed");
let error = artifact_id_with_limits(
kind,
bytes,
&Limits::default().with_max_artifact_identity_work(work - 1),
)
.unwrap_err();
assert_eq!(
resource_failure(&error),
(RESOURCE.to_string(), (work - 1) as u64, work as u64)
);
}
#[test]
fn no_other_identity_counter_is_consumed() {
let limits = Limits::default();
let context = RuntimeContext::new(limits);
let mut budget = context.budget();
artifact_id_with_budget(
ArtifactKind::CompilationJsonV1,
&vec![b'x'; HASH_CHUNK_BYTES * 2 + 7],
&mut budget,
)
.unwrap();
assert_eq!(budget.consumed("canonicalization_work"), 0);
assert_eq!(budget.consumed("source_identity_work"), 0);
assert_eq!(
budget.consumed(RESOURCE),
exact_work(ArtifactKind::CompilationJsonV1, HASH_CHUNK_BYTES * 2 + 7)
);
}
#[test]
fn work_is_charged_incrementally_across_chunks() {
let kind = ArtifactKind::CompilationJsonV1;
let bytes = vec![b'x'; HASH_CHUNK_BYTES * 3];
let framing = kind.domain().len() + 1;
let limit = framing + HASH_CHUNK_BYTES + 1;
let error = artifact_id_with_limits(
kind,
&bytes,
&Limits::default().with_max_artifact_identity_work(limit),
)
.unwrap_err();
let (resource, reported_limit, observed) = resource_failure(&error);
assert_eq!(resource, RESOURCE);
assert_eq!(reported_limit, limit as u64);
assert!(
observed > framing as u64 && observed < exact_work(kind, bytes.len()) as u64,
"a chunked charge must fail part-way, not at the total: {observed}"
);
}
#[test]
fn a_cancelled_token_fails_closed_before_hashing() {
let cancellation = CancellationToken::new();
cancellation.cancel();
let context = RuntimeContext::new(Limits::default()).with_cancellation(cancellation);
let error =
artifact_id_with_context(ArtifactKind::CompilationJsonV1, b"{}", &context).unwrap_err();
assert_eq!(error.violations[0].code, "operation_cancelled");
}
#[test]
fn cancellation_is_observed_between_chunks() {
let kind = ArtifactKind::ContractEnvelopeJsonV1;
let cancellation = CancellationToken::new();
let context =
RuntimeContext::new(Limits::default()).with_cancellation(cancellation.clone());
let mut budget = context.budget();
CANCEL_AT_CHUNK_BOUNDARY.with(|slot| slot.set(Some(cancellation)));
let error = artifact_id_with_budget(kind, &vec![b'x'; HASH_CHUNK_BYTES * 3], &mut budget)
.unwrap_err();
assert_eq!(error.violations[0].code, "operation_cancelled");
assert_eq!(
budget.consumed(RESOURCE),
kind.domain().len() + 1 + HASH_CHUNK_BYTES,
"exactly one chunk may be charged before cancellation is observed"
);
}
#[cfg(not(target_os = "unknown"))]
#[test]
fn an_elapsed_deadline_fails_closed() {
let context = RuntimeContext::new(Limits::default().with_deadline_unix_ms(Some(1)));
let error = artifact_id_with_context(
ArtifactKind::CompilationJsonV1,
&vec![b'x'; HASH_CHUNK_BYTES * 2],
&context,
)
.unwrap_err();
assert_eq!(error.violations[0].code, "operation_deadline_exceeded");
}
#[test]
fn the_default_work_limit_covers_the_default_byte_gate() {
let limits = Limits::default();
let longest = ALL_KINDS
.iter()
.map(|kind| kind.domain().len())
.max()
.expect("at least one kind exists");
assert_eq!(
longest,
ArtifactKind::ContractEnvelopeJsonV1.domain().len(),
"the documented worst-case domain must still be the longest"
);
for &kind in ALL_KINDS {
assert!(
exact_work(kind, limits.max_input_bytes()) <= limits.max_artifact_identity_work(),
"{kind:?} at the byte gate must fit the default work limit"
);
}
}
}