Skip to main content

kcode_k1_objects/
lib.rs

1use kcode_k1_peering::K1Peering;
2use kcode_k1_transaction::{REGISTER_AT_TIP, SubsystemId, Transaction};
3pub use kcode_k1_transaction_id::TxId;
4use kcode_k1_txn_ordering::{K1TxnOrdering, Subsystem};
5use std::sync::Arc;
6
7const PAYLOAD_VERSION: u8 = 1;
8const PAYLOAD_HEADER_BYTES: usize = 25;
9const OBJECT_SUBSYSTEM_BYTES: [u8; 20] = *b"k1-objects-subsystem";
10
11#[derive(Clone, Debug, Eq, PartialEq)]
12pub struct Object {
13    pub filename: String,
14    pub file_type: String,
15    pub description: String,
16    pub data: Vec<u8>,
17}
18
19pub trait ObjectSaveObserver: Send + Sync + 'static {
20    fn saved(&self, id: TxId);
21}
22
23pub struct K1Objects {
24    ordering: Arc<K1TxnOrdering>,
25    peering: Arc<K1Peering>,
26    observer: Arc<dyn ObjectSaveObserver>,
27}
28
29impl K1Objects {
30    pub fn open(ordering: Arc<K1TxnOrdering>, peering: Arc<K1Peering>) -> Result<Self, String> {
31        Self::open_with_save_observer(ordering, peering, Arc::new(NoopSaveObserver))
32    }
33
34    pub fn open_with_save_observer(
35        ordering: Arc<K1TxnOrdering>,
36        peering: Arc<K1Peering>,
37        observer: Arc<dyn ObjectSaveObserver>,
38    ) -> Result<Self, String> {
39        ordering.register_subsystem(
40            object_subsystem(),
41            Some(REGISTER_AT_TIP),
42            Arc::new(StatelessObjects),
43        )?;
44        Ok(Self {
45            ordering,
46            peering,
47            observer,
48        })
49    }
50
51    pub fn save(
52        &self,
53        filename: &str,
54        file_type: &str,
55        description: &str,
56        data: &[u8],
57    ) -> Result<TxId, String> {
58        let payload = encode_payload(filename, file_type, description, data)?;
59        let id = self.peering.submit_txn(object_subsystem(), &payload)?;
60        self.observer.saved(id);
61        Ok(id)
62    }
63
64    pub fn load(&self, id: TxId) -> Result<Option<Object>, String> {
65        let Some(bytes) = self.ordering.get_txn(id)? else {
66            return Ok(None);
67        };
68        let transaction = Transaction::parse(&bytes)
69            .map_err(|message| format!("canonical transaction is invalid: {message}"))?;
70        if transaction.subsystem() != object_subsystem() {
71            return Ok(None);
72        }
73        decode_payload(transaction.payload()).map(Some)
74    }
75}
76
77struct NoopSaveObserver;
78
79impl ObjectSaveObserver for NoopSaveObserver {
80    fn saved(&self, _id: TxId) {}
81}
82
83struct StatelessObjects;
84
85impl Subsystem for StatelessObjects {
86    fn submit_txn(&self, _id: TxId, _payload: &[u8]) -> Result<(), String> {
87        Ok(())
88    }
89
90    fn reorg(&self) -> Result<(), String> {
91        Ok(())
92    }
93}
94
95fn object_subsystem() -> SubsystemId {
96    SubsystemId::from_bytes(OBJECT_SUBSYSTEM_BYTES)
97        .expect("Objects subsystem ID is valid fixed-width UTF-8")
98}
99
100fn encode_payload(
101    filename: &str,
102    file_type: &str,
103    description: &str,
104    data: &[u8],
105) -> Result<Vec<u8>, String> {
106    let filename_len = u64::try_from(filename.len())
107        .map_err(|_| "filename length exceeds the object wire format".to_owned())?;
108    let file_type_len = u64::try_from(file_type.len())
109        .map_err(|_| "file type length exceeds the object wire format".to_owned())?;
110    let description_len = u64::try_from(description.len())
111        .map_err(|_| "description length exceeds the object wire format".to_owned())?;
112    let total_len = PAYLOAD_HEADER_BYTES
113        .checked_add(filename.len())
114        .and_then(|length| length.checked_add(file_type.len()))
115        .and_then(|length| length.checked_add(description.len()))
116        .and_then(|length| length.checked_add(data.len()))
117        .ok_or_else(|| "object payload length exceeds usize".to_owned())?;
118
119    let mut payload = Vec::new();
120    payload
121        .try_reserve_exact(total_len)
122        .map_err(|error| format!("cannot allocate object payload: {error}"))?;
123    payload.push(PAYLOAD_VERSION);
124    payload.extend_from_slice(&filename_len.to_le_bytes());
125    payload.extend_from_slice(&file_type_len.to_le_bytes());
126    payload.extend_from_slice(&description_len.to_le_bytes());
127    payload.extend_from_slice(filename.as_bytes());
128    payload.extend_from_slice(file_type.as_bytes());
129    payload.extend_from_slice(description.as_bytes());
130    payload.extend_from_slice(data);
131
132    debug_assert_eq!(payload.len(), total_len);
133    Ok(payload)
134}
135
136fn decode_payload(payload: &[u8]) -> Result<Object, String> {
137    if payload.len() < PAYLOAD_HEADER_BYTES {
138        return Err(format!(
139            "object payload must contain at least {PAYLOAD_HEADER_BYTES} bytes"
140        ));
141    }
142    if payload[0] != PAYLOAD_VERSION {
143        return Err(format!("unsupported object payload version {}", payload[0]));
144    }
145
146    let filename_len = decode_length(payload, 1, "filename")?;
147    let file_type_len = decode_length(payload, 9, "file type")?;
148    let description_len = decode_length(payload, 17, "description")?;
149    let filename_end = PAYLOAD_HEADER_BYTES
150        .checked_add(filename_len)
151        .ok_or_else(|| "object metadata length overflow".to_owned())?;
152    let file_type_end = filename_end
153        .checked_add(file_type_len)
154        .ok_or_else(|| "object metadata length overflow".to_owned())?;
155    let description_end = file_type_end
156        .checked_add(description_len)
157        .ok_or_else(|| "object metadata length overflow".to_owned())?;
158
159    if description_end > payload.len() {
160        return Err("object metadata extends past the payload".to_owned());
161    }
162
163    let filename = std::str::from_utf8(&payload[PAYLOAD_HEADER_BYTES..filename_end])
164        .map_err(|error| format!("object filename is not valid UTF-8: {error}"))?
165        .to_owned();
166    let file_type = std::str::from_utf8(&payload[filename_end..file_type_end])
167        .map_err(|error| format!("object file type is not valid UTF-8: {error}"))?
168        .to_owned();
169    let description = std::str::from_utf8(&payload[file_type_end..description_end])
170        .map_err(|error| format!("object description is not valid UTF-8: {error}"))?
171        .to_owned();
172
173    Ok(Object {
174        filename,
175        file_type,
176        description,
177        data: payload[description_end..].to_vec(),
178    })
179}
180
181fn decode_length(payload: &[u8], offset: usize, field: &str) -> Result<usize, String> {
182    let bytes: [u8; 8] = payload[offset..offset + 8]
183        .try_into()
184        .expect("object length fields are inside the fixed header");
185    usize::try_from(u64::from_le_bytes(bytes))
186        .map_err(|_| format!("object {field} length exceeds usize"))
187}