use std::{
fs::{self, OpenOptions},
io::{self, Write},
process::Command,
sync::atomic::{AtomicU64, Ordering},
};
use candid::{CandidType, decode_one, encode_args};
use serde::de::DeserializeOwned;
use toko_feed::{FeedError, TimeCursor};
use super::{CliError, Target};
const MAX_RAW_REPLY_BYTES: usize = 16 * 1024 * 1024;
static CALL_ARGUMENT_SEQUENCE: AtomicU64 = AtomicU64::new(0);
struct CallArgumentFile {
path: std::path::PathBuf,
}
impl CallArgumentFile {
fn create(arguments: &[u8]) -> Result<Self, CliError> {
for _ in 0..32 {
let sequence = CALL_ARGUMENT_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"toko-feed-candid-{}-{sequence}.bin",
std::process::id()
));
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = match options.open(&path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(CliError::ArgumentFile(error)),
};
if let Err(source) = file.write_all(arguments) {
let _ = fs::remove_file(&path);
return Err(CliError::ArgumentFile(source));
}
return Ok(Self { path });
}
Err(CliError::ArgumentFile(io::Error::new(
io::ErrorKind::AlreadyExists,
"could not allocate a unique temporary path",
)))
}
}
impl Drop for CallArgumentFile {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
pub fn call_empty<T>(target: &Target, method: &'static str, query: bool) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments = encode_args(()).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
pub fn call_one<A, T>(
target: &Target,
method: &'static str,
argument: A,
query: bool,
) -> Result<T, CliError>
where
A: CandidType,
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((argument,)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, query)
}
pub fn call_page<T>(
target: &Target,
method: &'static str,
after: Option<String>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((after, limit)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
pub fn call_history_page<T>(
target: &Target,
method: &'static str,
before: Option<TimeCursor>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments =
encode_args((before, limit)).map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
pub fn call_item_history_page<T>(
target: &Target,
method: &'static str,
item_id: String,
before: Option<TimeCursor>,
limit: u16,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let arguments = encode_args((item_id, before, limit))
.map_err(|source| CliError::Encode { method, source })?;
call_and_decode(target, method, &arguments, true)
}
pub fn call_and_decode<T>(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let reply = call_raw(target, method, arguments, query)?;
decode_result(method, &reply)
}
fn decode_result<T>(method: &'static str, reply: &[u8]) -> Result<T, CliError>
where
T: CandidType + DeserializeOwned,
{
let result = decode_one::<Result<T, FeedError>>(reply)
.map_err(|source| CliError::Decode { method, source })?;
result.map_err(|error| CliError::Canister {
method,
error: format!("{error:?}"),
})
}
fn call_raw(
target: &Target,
method: &'static str,
arguments: &[u8],
query: bool,
) -> Result<Vec<u8>, CliError> {
let argument_file = CallArgumentFile::create(arguments)?;
let mut command = Command::new(&target.icp);
if let Some(project_root) = &target.project_root {
command.arg("--project-root-override").arg(project_root);
}
if let Some(password_file) = &target.identity_password_file {
command.arg("--identity-password-file").arg(password_file);
}
command
.args(["canister", "call", "--environment"])
.arg(&target.environment)
.args(["--args-format", "bin", "--args-file"])
.arg(&argument_file.path)
.args(["--output", "hex"]);
command.args(["--identity", &target.identity]);
if query {
command.arg("--query");
}
command.args([&target.canister, method]);
let output = command.output().map_err(CliError::StartIcp)?;
if !output.status.success() {
let status = output
.status
.code()
.map_or_else(String::new, |code| format!(" (exit {code})"));
let message = bounded_diagnostic(&output.stderr);
return Err(CliError::Icp { status, message });
}
if output.stdout.len() > MAX_RAW_REPLY_BYTES * 2 + 2 {
return Err(CliError::RawReply("hexadecimal response exceeded 16 MiB"));
}
decode_hex(&output.stdout)
}
fn bounded_diagnostic(bytes: &[u8]) -> String {
const MAX_DIAGNOSTIC_BYTES: usize = 8 * 1024;
let visible = &bytes[..bytes.len().min(MAX_DIAGNOSTIC_BYTES)];
let mut message = String::from_utf8_lossy(visible).trim().to_owned();
if bytes.len() > MAX_DIAGNOSTIC_BYTES {
message.push_str("…[truncated]");
}
if message.is_empty() {
"no diagnostic was written to stderr".to_owned()
} else {
message
}
}
#[cfg(test)]
fn encode_hex(bytes: &[u8]) -> String {
const DIGITS: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(char::from(DIGITS[usize::from(byte >> 4)]));
output.push(char::from(DIGITS[usize::from(byte & 0x0f)]));
}
output
}
fn decode_hex(input: &[u8]) -> Result<Vec<u8>, CliError> {
let input = std::str::from_utf8(input)
.map_err(|_| CliError::RawReply("response was not UTF-8 hexadecimal text"))?
.trim();
let input = input.strip_prefix("0x").unwrap_or(input);
if input.len() % 2 != 0 {
return Err(CliError::RawReply(
"hexadecimal response had an odd number of digits",
));
}
input
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let high = hex_digit(pair[0])?;
let low = hex_digit(pair[1])?;
Ok((high << 4) | low)
})
.collect()
}
const fn hex_digit(byte: u8) -> Result<u8, CliError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(CliError::RawReply(
"response contained a non-hexadecimal character",
)),
}
}
#[cfg(test)]
mod tests {
use std::fs;
use candid::{encode_args, encode_one};
use toko_feed::{FeedError, FeedStatus};
use super::*;
#[test]
fn hexadecimal_transport_round_trips_raw_candid() {
let bytes = encode_args((None::<String>, 20_u16)).expect("encode arguments");
assert_eq!(
decode_hex(format!("0x{}\n", encode_hex(&bytes)).as_bytes()).expect("decode hex"),
bytes
);
}
#[test]
fn binary_call_arguments_are_removed_after_use() {
let arguments = b"bounded candid payload";
let argument_file =
CallArgumentFile::create(arguments).expect("argument file should be created");
let path = argument_file.path.clone();
assert_eq!(
fs::read(&path).expect("argument file should be readable"),
arguments
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = fs::metadata(&path)
.expect("argument metadata should be readable")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600);
}
drop(argument_file);
assert!(!path.exists());
}
#[test]
fn decodes_a_typed_canister_result() {
let status = FeedStatus {
configured: true,
scrydex_configured: true,
next_offset: 50,
sets_next_offset: 20,
ingesting: false,
last_error_code: None,
updated_at_ns: 123,
};
let reply = encode_one(Ok::<_, FeedError>(status.clone())).expect("encode reply");
assert_eq!(
decode_result::<FeedStatus>("toko_feed_status", &reply).expect("decode result"),
status
);
}
#[test]
fn rejects_non_hexadecimal_transport_output() {
assert!(decode_hex(b"not-hex").is_err());
assert!(decode_hex(b"abc").is_err());
}
}