toko-feed-cli 0.3.3

Operator CLI for Toko Feed canister ingestion and catalog queries
Documentation
//! Typed Candid transport across the external `icp` process boundary.

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());
    }
}