pub mod contract;
pub mod inspection;
use crate::{
icp::{
IcpCli, IcpCommandError, IcpJsonResponseError, decode_json_result_response,
write_candid_argument_file,
},
protocol_binding::ResolvedProtocolBinding,
};
use candid::{CandidType, Principal};
use canic_core::diagnostics::RegisteredDiagnosticCode;
use serde::de::DeserializeOwned;
use std::{fs, io};
use thiserror::Error as ThisError;
const ICP_JSON_OUTPUT: &str = "json";
#[derive(Clone, Copy)]
enum ProtocolCallMode {
Query,
Update,
}
#[derive(Debug, ThisError)]
pub enum CanisterProtocolError {
#[error("read-only query {method} on Canister {canister} failed: {source}")]
ReadOnlyQuery {
canister: Principal,
method: &'static str,
#[source]
source: crate::icp::IcpQueryError,
},
#[error("invalid bound inspection contract for Root {caller}: {detail}")]
InspectionContract { caller: Principal, detail: String },
#[error("Root {} inspection preflight for {} observed native={} cycles, liquid={} cycles, required outbound reserve={} cycles. Inspection was not attempted. Review Root funding or freezing reserve and retry with a fresh observation.", .0.caller, .0.canister_id, .0.native_cycles, .0.available_liquid_cycles, .0.required_liquid_cycles)]
InspectionPreflightReserve(Box<canic_core::dto::canister::CanisterInspectionReserveResponse>),
#[error("{}: Root {} cannot inspect {}: native={} cycles, liquid={} cycles, required outbound reserve={} cycles. Review Root funding or freezing reserve before retrying.", canic_core::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES, .0.caller, .0.canister_id, .0.native_cycles, .0.available_liquid_cycles, .0.required_liquid_cycles)]
InspectionReserve(Box<canic_core::dto::canister::CanisterInspectionReserveResponse>),
#[error("Root {caller} returned invalid reserve evidence for inspection of {target}")]
InvalidInspectionReserve {
caller: Principal,
target: Principal,
},
#[error("failed to encode Candid arguments for {method} on Canister {canister}: {source}")]
ArgumentEncoding {
canister: Principal,
method: &'static str,
#[source]
source: candid::Error,
},
#[error(
"failed to manage binary Candid arguments for {method} on Canister {canister}: {source}"
)]
ArgumentFile {
canister: Principal,
method: &'static str,
#[source]
source: io::Error,
},
#[error("failed to invoke {method} on Canister {canister}: {source}")]
Invocation {
canister: Principal,
method: &'static str,
#[source]
source: IcpCommandError,
},
#[error("invalid {method} response from Canister {canister}: {source}")]
Response {
canister: Principal,
method: &'static str,
#[source]
source: IcpJsonResponseError,
},
}
impl CanisterProtocolError {
pub(crate) fn inspection_reserve(
caller: Principal,
target: Principal,
evidence: canic_core::dto::canister::CanisterInspectionReserveResponse,
) -> Self {
if evidence.caller != caller
|| evidence.canister_id != target
|| evidence.available_liquid_cycles >= evidence.required_liquid_cycles
|| evidence.available_liquid_cycles > evidence.native_cycles
{
return Self::InvalidInspectionReserve { caller, target };
}
Self::InspectionReserve(Box::new(evidence))
}
pub(crate) fn is_rejected_with(&self, code: RegisteredDiagnosticCode) -> bool {
if matches!(self, Self::InspectionReserve(_)) {
return code == canic_core::diagnostics::codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES;
}
matches!(
self,
Self::Response {
source: IcpJsonResponseError::Rejected(error),
..
} if error.code() == code.raw_code()
)
}
}
pub fn call_with_arg<I, O>(
icp: &IcpCli,
binding: &ResolvedProtocolBinding,
canister: Principal,
method: &'static str,
input: &I,
) -> Result<O, CanisterProtocolError>
where
I: CandidType,
O: CandidType + DeserializeOwned,
{
invoke_with_candid(
icp,
&binding.candid_path,
canister,
method,
input,
ProtocolCallMode::Update,
)
}
pub fn query_with_arg<I, O>(
icp: &IcpCli,
binding: &ResolvedProtocolBinding,
canister: Principal,
method: &'static str,
input: &I,
) -> Result<O, CanisterProtocolError>
where
I: CandidType,
O: CandidType + DeserializeOwned,
{
invoke_with_candid(
icp,
&binding.candid_path,
canister,
method,
input,
ProtocolCallMode::Query,
)
}
pub fn call_with_candid<I, O>(
icp: &IcpCli,
candid_path: &std::path::Path,
canister: Principal,
method: &'static str,
input: &I,
) -> Result<O, CanisterProtocolError>
where
I: CandidType,
O: CandidType + DeserializeOwned,
{
invoke_with_candid(
icp,
candid_path,
canister,
method,
input,
ProtocolCallMode::Update,
)
}
pub fn query_with_candid<I, O>(
icp: &IcpCli,
candid_path: &std::path::Path,
canister: Principal,
method: &'static str,
input: &I,
) -> Result<O, CanisterProtocolError>
where
I: CandidType,
O: CandidType + DeserializeOwned,
{
invoke_with_candid(
icp,
candid_path,
canister,
method,
input,
ProtocolCallMode::Query,
)
}
pub fn query_authenticated<I: CandidType, O: CandidType + DeserializeOwned>(
icp: &IcpCli,
canister: Principal,
method: &'static str,
input: &I,
) -> Result<O, CanisterProtocolError> {
let response: Result<O, canic_core::dto::error::Error> = icp
.query_candid_readonly(canister, method, input)
.map_err(|source| CanisterProtocolError::ReadOnlyQuery {
canister,
method,
source,
})?;
response.map_err(|error| CanisterProtocolError::Response {
canister,
method,
source: IcpJsonResponseError::Rejected(error),
})
}
fn invoke_with_candid<I, O>(
icp: &IcpCli,
candid_path: &std::path::Path,
canister: Principal,
method: &'static str,
input: &I,
mode: ProtocolCallMode,
) -> Result<O, CanisterProtocolError>
where
I: CandidType,
O: CandidType + DeserializeOwned,
{
let output = invoke_output(icp, Some(candid_path), canister, method, input, mode)?;
decode_response(canister, method, &output)
}
fn invoke_output<I>(
icp: &IcpCli,
candid_path: Option<&std::path::Path>,
canister: Principal,
method: &'static str,
input: &I,
mode: ProtocolCallMode,
) -> Result<String, CanisterProtocolError>
where
I: CandidType,
{
let bytes =
candid::encode_one(input).map_err(|source| CanisterProtocolError::ArgumentEncoding {
canister,
method,
source,
})?;
let args_path = write_candid_argument_file(&bytes).map_err(|source| {
CanisterProtocolError::ArgumentFile {
canister,
method,
source,
}
})?;
let canister_text = canister.to_text();
let output = match mode {
ProtocolCallMode::Query => icp.canister_query_binary_args_output_with_candid(
&canister_text,
method,
&args_path,
Some(ICP_JSON_OUTPUT),
candid_path,
),
ProtocolCallMode::Update => icp.canister_call_binary_args_output_with_candid(
&canister_text,
method,
&args_path,
Some(ICP_JSON_OUTPUT),
candid_path,
),
};
let cleanup = fs::remove_file(&args_path);
let output = output.map_err(|source| CanisterProtocolError::Invocation {
canister,
method,
source,
})?;
cleanup.map_err(|source| CanisterProtocolError::ArgumentFile {
canister,
method,
source,
})?;
Ok(output)
}
fn decode_response<O>(
canister: Principal,
method: &'static str,
output: &str,
) -> Result<O, CanisterProtocolError>
where
O: CandidType + DeserializeOwned,
{
decode_json_result_response(output).map_err(|source| CanisterProtocolError::Response {
canister,
method,
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[derive(CandidType, Debug, Deserialize, Eq, PartialEq)]
struct EmptyVectorArgument {
values: Vec<u64>,
}
#[test]
fn inspection_reserve_requires_exact_authority_and_retains_numeric_cause() {
use canic_core::{diagnostics::codes, dto::canister::CanisterInspectionReserveResponse};
let root = Principal::from_slice(&[1]);
let target = Principal::from_slice(&[2]);
let evidence = CanisterInspectionReserveResponse {
caller: root,
canister_id: target,
native_cycles: 1000,
available_liquid_cycles: 50,
required_liquid_cycles: 100,
};
let error = CanisterProtocolError::inspection_reserve(root, target, evidence.clone());
assert!(error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
assert!(!error.is_rejected_with(codes::PLATFORM_UNAVAILABLE));
let CanisterProtocolError::InspectionReserve(actual) = &error else {
panic!("reserve evidence");
};
assert_eq!(**actual, evidence);
assert!(error.to_string().contains(
"native=1000 cycles, liquid=50 cycles, required outbound reserve=100 cycles"
));
for invalid in [
CanisterInspectionReserveResponse {
caller: target,
..evidence.clone()
},
CanisterInspectionReserveResponse {
canister_id: root,
..evidence.clone()
},
CanisterInspectionReserveResponse {
available_liquid_cycles: 100,
..evidence.clone()
},
CanisterInspectionReserveResponse {
native_cycles: 49,
..evidence
},
] {
let error = CanisterProtocolError::inspection_reserve(root, target, invalid);
assert!(matches!(
error,
CanisterProtocolError::InvalidInspectionReserve { .. }
));
assert!(!error.is_rejected_with(codes::PLATFORM_INSUFFICIENT_LIQUID_CYCLES));
}
}
#[test]
fn typed_arguments_are_written_to_private_binary_files() {
let argument = EmptyVectorArgument { values: Vec::new() };
let path =
write_candid_argument_file(&candid::encode_one(&argument).expect("encode argument"))
.expect("write argument file");
let decoded: EmptyVectorArgument =
candid::decode_one(&fs::read(&path).expect("read argument file"))
.expect("decode argument");
assert_eq!(decoded, argument);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(&path)
.expect("argument metadata")
.permissions()
.mode()
& 0o777,
0o600
);
}
fs::remove_file(path).expect("remove argument file");
}
}