Skip to main content

audit_trail/
codec.rs

1//! Stable binary codec for serialising audit records to bytes.
2//!
3//! Requires the `alloc` feature. `std`-gated readers and sinks
4//! ([`crate::FileSink`], [`crate::FileReader`]) use this codec under the
5//! hood.
6//!
7//! # Stability promise
8//!
9//! The byte layout defined here is **stable**. Changing it is a breaking
10//! change to any on-disk audit log. The format embeds a one-byte version
11//! ([`FORMAT_VERSION`]) so future incompatible formats can coexist by
12//! bumping it.
13//!
14//! # File layout
15//!
16//! ```text
17//! ┌────────────────────────────────────────────────────────────────┐
18//! │ FILE HEADER (16 bytes)                                         │
19//! ├────────────────────────────────────────────────────────────────┤
20//! │ 0..8   "AUDTRAIL" magic                                        │
21//! │ 8      format version (currently 0x01)                         │
22//! │ 9..16  reserved, zero                                          │
23//! ├────────────────────────────────────────────────────────────────┤
24//! │ RECORD FRAME (one per record, repeated)                        │
25//! ├────────────────────────────────────────────────────────────────┤
26//! │ 0..4   record body length (u32 big-endian)                     │
27//! │ 4..    record body                                             │
28//! └────────────────────────────────────────────────────────────────┘
29//! ```
30//!
31//! # Record body layout
32//!
33//! ```text
34//! 0..8    id           u64 big-endian
35//! 8..16   timestamp    u64 big-endian (nanoseconds since Unix epoch)
36//! 16      outcome      u8
37//! 17..49  prev_hash    32 bytes
38//! 49..81  hash         32 bytes
39//! 81..85  actor_len    u32 big-endian
40//! 85..    actor        UTF-8 bytes
41//! ...     action_len   u32 big-endian
42//! ...     action       UTF-8 bytes
43//! ...     target_len   u32 big-endian
44//! ...     target       UTF-8 bytes
45//! ```
46
47use alloc::string::String;
48use alloc::vec::Vec;
49
50use crate::clock::Timestamp;
51use crate::error::{Error, Result};
52use crate::hash::{Digest, HASH_LEN};
53use crate::owned::OwnedRecord;
54use crate::record::{Outcome, Record, RecordId};
55
56/// File-format magic bytes. Appear at the start of every chain file.
57pub const FORMAT_MAGIC: &[u8; 8] = b"AUDTRAIL";
58
59/// Current file-format version.
60pub const FORMAT_VERSION: u8 = 0x01;
61
62/// Length of the file header in bytes.
63pub const FILE_HEADER_LEN: usize = 16;
64
65/// Length of a record's fixed prefix in bytes
66/// (`id || timestamp || outcome || prev_hash || hash`).
67const RECORD_FIXED_LEN: usize = 8 + 8 + 1 + HASH_LEN + HASH_LEN;
68
69/// Write the file header into `out`.
70///
71/// Always writes exactly [`FILE_HEADER_LEN`] bytes.
72pub fn write_file_header(out: &mut Vec<u8>) {
73    out.extend_from_slice(FORMAT_MAGIC);
74    out.push(FORMAT_VERSION);
75    out.extend_from_slice(&[0u8; 7]);
76}
77
78/// Verify that `bytes` begins with a valid file header.
79///
80/// # Errors
81///
82/// * [`Error::Truncated`] — `bytes.len() < FILE_HEADER_LEN`.
83/// * [`Error::InvalidFormat`] — bad magic or unknown version.
84pub fn verify_file_header(bytes: &[u8]) -> Result<()> {
85    if bytes.len() < FILE_HEADER_LEN {
86        return Err(Error::Truncated);
87    }
88    if &bytes[0..8] != FORMAT_MAGIC {
89        return Err(Error::InvalidFormat);
90    }
91    if bytes[8] != FORMAT_VERSION {
92        return Err(Error::InvalidFormat);
93    }
94    Ok(())
95}
96
97/// Encode `record` into a length-prefixed frame appended to `out`.
98///
99/// Writes `4 + body_len` bytes. The 4-byte prefix is the body length as a
100/// big-endian `u32`; the body follows the layout documented in the module
101/// rustdoc.
102///
103/// # Errors
104///
105/// * [`Error::InvalidFormat`] — any string field's UTF-8 byte length, or
106///   the resulting body length, would not fit in a `u32`. In practice
107///   audit fields are tiny; the check is here so that absurd inputs
108///   produce a typed error rather than silent truncation.
109pub fn encode_record(record: &Record<'_>, out: &mut Vec<u8>) -> Result<()> {
110    let actor_bytes = record.actor().as_str().as_bytes();
111    let action_bytes = record.action().as_str().as_bytes();
112    let target_bytes = record.target().as_str().as_bytes();
113
114    if actor_bytes.len() > u32::MAX as usize
115        || action_bytes.len() > u32::MAX as usize
116        || target_bytes.len() > u32::MAX as usize
117    {
118        return Err(Error::InvalidFormat);
119    }
120
121    let body_len =
122        RECORD_FIXED_LEN + 4 + actor_bytes.len() + 4 + action_bytes.len() + 4 + target_bytes.len();
123    if body_len > u32::MAX as usize {
124        return Err(Error::InvalidFormat);
125    }
126
127    out.reserve(4 + body_len);
128
129    out.extend_from_slice(&(body_len as u32).to_be_bytes());
130    out.extend_from_slice(&record.id().as_u64().to_be_bytes());
131    out.extend_from_slice(&record.timestamp().as_nanos().to_be_bytes());
132    out.push(record.outcome().as_u8());
133    out.extend_from_slice(record.prev_hash().as_bytes());
134    out.extend_from_slice(record.hash().as_bytes());
135
136    out.extend_from_slice(&(actor_bytes.len() as u32).to_be_bytes());
137    out.extend_from_slice(actor_bytes);
138    out.extend_from_slice(&(action_bytes.len() as u32).to_be_bytes());
139    out.extend_from_slice(action_bytes);
140    out.extend_from_slice(&(target_bytes.len() as u32).to_be_bytes());
141    out.extend_from_slice(target_bytes);
142
143    Ok(())
144}
145
146/// Decode a single length-prefixed record frame from the front of
147/// `bytes`. Returns the decoded record plus the number of bytes consumed.
148///
149/// # Errors
150///
151/// * [`Error::Truncated`] — input ended before a full frame could be read.
152/// * [`Error::InvalidFormat`] — length prefix would overflow, fixed
153///   fields are short, UTF-8 fields are invalid, or the body length does
154///   not match the sum of its parts.
155pub fn decode_record(bytes: &[u8]) -> Result<(OwnedRecord, usize)> {
156    if bytes.len() < 4 {
157        return Err(Error::Truncated);
158    }
159    let body_len = read_u32(&bytes[0..4]) as usize;
160    let frame_end = 4usize.checked_add(body_len).ok_or(Error::InvalidFormat)?;
161    if bytes.len() < frame_end {
162        return Err(Error::Truncated);
163    }
164
165    let body = &bytes[4..frame_end];
166    if body.len() < RECORD_FIXED_LEN {
167        return Err(Error::InvalidFormat);
168    }
169
170    let id = RecordId::from_u64(read_u64(&body[0..8]));
171    let timestamp = Timestamp::from_nanos(read_u64(&body[8..16]));
172    let outcome = decode_outcome(body[16])?;
173    let mut prev_hash = [0u8; HASH_LEN];
174    prev_hash.copy_from_slice(&body[17..17 + HASH_LEN]);
175    let mut hash = [0u8; HASH_LEN];
176    hash.copy_from_slice(&body[17 + HASH_LEN..17 + HASH_LEN + HASH_LEN]);
177
178    let mut cursor = RECORD_FIXED_LEN;
179    let actor = read_string_field(body, &mut cursor)?;
180    let action = read_string_field(body, &mut cursor)?;
181    let target = read_string_field(body, &mut cursor)?;
182
183    if cursor != body.len() {
184        return Err(Error::InvalidFormat);
185    }
186
187    let record = OwnedRecord {
188        id,
189        timestamp,
190        actor,
191        action,
192        target,
193        outcome,
194        prev_hash: Digest::from_bytes(prev_hash),
195        hash: Digest::from_bytes(hash),
196    };
197    Ok((record, frame_end))
198}
199
200fn decode_outcome(byte: u8) -> Result<Outcome> {
201    match byte {
202        0 => Ok(Outcome::Success),
203        1 => Ok(Outcome::Failure),
204        2 => Ok(Outcome::Denied),
205        3 => Ok(Outcome::Error),
206        _ => Err(Error::InvalidFormat),
207    }
208}
209
210fn read_string_field(body: &[u8], cursor: &mut usize) -> Result<String> {
211    if body.len() < cursor.checked_add(4).ok_or(Error::InvalidFormat)? {
212        return Err(Error::InvalidFormat);
213    }
214    let len = read_u32(&body[*cursor..*cursor + 4]) as usize;
215    *cursor += 4;
216    let end = cursor.checked_add(len).ok_or(Error::InvalidFormat)?;
217    if body.len() < end {
218        return Err(Error::InvalidFormat);
219    }
220    let bytes = &body[*cursor..end];
221    let s = core::str::from_utf8(bytes).map_err(|_| Error::InvalidFormat)?;
222    *cursor = end;
223    Ok(String::from(s))
224}
225
226fn read_u32(bytes: &[u8]) -> u32 {
227    let mut buf = [0u8; 4];
228    buf.copy_from_slice(&bytes[0..4]);
229    u32::from_be_bytes(buf)
230}
231
232fn read_u64(bytes: &[u8]) -> u64 {
233    let mut buf = [0u8; 8];
234    buf.copy_from_slice(&bytes[0..8]);
235    u64::from_be_bytes(buf)
236}