kcode-k1-objects 0.2.2

Stateless canonical object storage for Kennedy K1
Documentation
use kcode_k1_peering::K1Peering;
use kcode_k1_transaction::{REGISTER_AT_TIP, SubsystemId, Transaction};
pub use kcode_k1_transaction_id::TxId;
use kcode_k1_txn_ordering::{K1TxnOrdering, Subsystem};
use std::sync::Arc;

const PAYLOAD_VERSION: u8 = 1;
const PAYLOAD_HEADER_BYTES: usize = 25;
const OBJECT_SUBSYSTEM_BYTES: [u8; 20] = *b"k1-objects-subsystem";

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Object {
    pub filename: String,
    pub file_type: String,
    pub description: String,
    pub data: Vec<u8>,
}

pub trait ObjectSaveObserver: Send + Sync + 'static {
    fn saved(&self, id: TxId);
}

pub struct K1Objects {
    ordering: Arc<K1TxnOrdering>,
    peering: Arc<K1Peering>,
    observer: Arc<dyn ObjectSaveObserver>,
}

impl K1Objects {
    pub fn open(ordering: Arc<K1TxnOrdering>, peering: Arc<K1Peering>) -> Result<Self, String> {
        Self::open_with_save_observer(ordering, peering, Arc::new(NoopSaveObserver))
    }

    pub fn open_with_save_observer(
        ordering: Arc<K1TxnOrdering>,
        peering: Arc<K1Peering>,
        observer: Arc<dyn ObjectSaveObserver>,
    ) -> Result<Self, String> {
        ordering.register_subsystem(
            object_subsystem(),
            Some(REGISTER_AT_TIP),
            Arc::new(StatelessObjects),
        )?;
        Ok(Self {
            ordering,
            peering,
            observer,
        })
    }

    pub fn save(
        &self,
        filename: &str,
        file_type: &str,
        description: &str,
        data: &[u8],
    ) -> Result<TxId, String> {
        let payload = encode_payload(filename, file_type, description, data)?;
        let id = self.peering.submit_txn(object_subsystem(), &payload)?;
        self.observer.saved(id);
        Ok(id)
    }

    pub fn load(&self, id: TxId) -> Result<Option<Object>, String> {
        let Some(bytes) = self.ordering.get_txn(id)? else {
            return Ok(None);
        };
        let transaction = Transaction::parse(&bytes)
            .map_err(|message| format!("canonical transaction is invalid: {message}"))?;
        if transaction.subsystem() != object_subsystem() {
            return Ok(None);
        }
        decode_payload(transaction.payload()).map(Some)
    }
}

struct NoopSaveObserver;

impl ObjectSaveObserver for NoopSaveObserver {
    fn saved(&self, _id: TxId) {}
}

struct StatelessObjects;

impl Subsystem for StatelessObjects {
    fn submit_txn(&self, _id: TxId, _payload: &[u8]) -> Result<(), String> {
        Ok(())
    }

    fn reorg(&self) -> Result<(), String> {
        Ok(())
    }
}

fn object_subsystem() -> SubsystemId {
    SubsystemId::from_bytes(OBJECT_SUBSYSTEM_BYTES)
        .expect("Objects subsystem ID is valid fixed-width UTF-8")
}

fn encode_payload(
    filename: &str,
    file_type: &str,
    description: &str,
    data: &[u8],
) -> Result<Vec<u8>, String> {
    let filename_len = u64::try_from(filename.len())
        .map_err(|_| "filename length exceeds the object wire format".to_owned())?;
    let file_type_len = u64::try_from(file_type.len())
        .map_err(|_| "file type length exceeds the object wire format".to_owned())?;
    let description_len = u64::try_from(description.len())
        .map_err(|_| "description length exceeds the object wire format".to_owned())?;
    let total_len = PAYLOAD_HEADER_BYTES
        .checked_add(filename.len())
        .and_then(|length| length.checked_add(file_type.len()))
        .and_then(|length| length.checked_add(description.len()))
        .and_then(|length| length.checked_add(data.len()))
        .ok_or_else(|| "object payload length exceeds usize".to_owned())?;

    let mut payload = Vec::new();
    payload
        .try_reserve_exact(total_len)
        .map_err(|error| format!("cannot allocate object payload: {error}"))?;
    payload.push(PAYLOAD_VERSION);
    payload.extend_from_slice(&filename_len.to_le_bytes());
    payload.extend_from_slice(&file_type_len.to_le_bytes());
    payload.extend_from_slice(&description_len.to_le_bytes());
    payload.extend_from_slice(filename.as_bytes());
    payload.extend_from_slice(file_type.as_bytes());
    payload.extend_from_slice(description.as_bytes());
    payload.extend_from_slice(data);

    debug_assert_eq!(payload.len(), total_len);
    Ok(payload)
}

fn decode_payload(payload: &[u8]) -> Result<Object, String> {
    if payload.len() < PAYLOAD_HEADER_BYTES {
        return Err(format!(
            "object payload must contain at least {PAYLOAD_HEADER_BYTES} bytes"
        ));
    }
    if payload[0] != PAYLOAD_VERSION {
        return Err(format!("unsupported object payload version {}", payload[0]));
    }

    let filename_len = decode_length(payload, 1, "filename")?;
    let file_type_len = decode_length(payload, 9, "file type")?;
    let description_len = decode_length(payload, 17, "description")?;
    let filename_end = PAYLOAD_HEADER_BYTES
        .checked_add(filename_len)
        .ok_or_else(|| "object metadata length overflow".to_owned())?;
    let file_type_end = filename_end
        .checked_add(file_type_len)
        .ok_or_else(|| "object metadata length overflow".to_owned())?;
    let description_end = file_type_end
        .checked_add(description_len)
        .ok_or_else(|| "object metadata length overflow".to_owned())?;

    if description_end > payload.len() {
        return Err("object metadata extends past the payload".to_owned());
    }

    let filename = std::str::from_utf8(&payload[PAYLOAD_HEADER_BYTES..filename_end])
        .map_err(|error| format!("object filename is not valid UTF-8: {error}"))?
        .to_owned();
    let file_type = std::str::from_utf8(&payload[filename_end..file_type_end])
        .map_err(|error| format!("object file type is not valid UTF-8: {error}"))?
        .to_owned();
    let description = std::str::from_utf8(&payload[file_type_end..description_end])
        .map_err(|error| format!("object description is not valid UTF-8: {error}"))?
        .to_owned();

    Ok(Object {
        filename,
        file_type,
        description,
        data: payload[description_end..].to_vec(),
    })
}

fn decode_length(payload: &[u8], offset: usize, field: &str) -> Result<usize, String> {
    let bytes: [u8; 8] = payload[offset..offset + 8]
        .try_into()
        .expect("object length fields are inside the fixed header");
    usize::try_from(u64::from_le_bytes(bytes))
        .map_err(|_| format!("object {field} length exceeds usize"))
}