use time::macros::format_description;
use time::{OffsetDateTime, PrimitiveDateTime};
use crate::error::{Error, Result};
use crate::parse::{ATOM_SIZE, read_u32, slice};
use crate::schema::TableSchema;
const RECORD_FIXED_FIELDS: usize = 6;
const TIME_ATTR_LEN: usize = 16;
const KEYCHAIN_TIME_FORMAT: &[time::format_description::BorrowedFormatItem<'_>] =
format_description!("[year][month][day][hour][minute][second]Z");
#[derive(Debug)]
pub(crate) struct Record<'a> {
pub(crate) buf: &'a [u8],
pub(crate) blob_data: &'a [u8],
pub(crate) raw_payload: &'a [u8],
attr_offsets: Vec<u32>,
schema: &'a TableSchema,
}
pub(crate) fn parse_record<'a>(
buf: &'a [u8],
offset: usize,
schema: &'a TableSchema,
) -> Result<Record<'a>> {
let rec_size = read_u32(buf, offset, "record size")? as usize;
if rec_size == 0 {
return Err(Error::ParseFailed(format!(
"record at offset {offset} has zero size"
)));
}
let rec_buf = slice(buf, offset, rec_size, "record body")?;
let attr_count = schema.attrs.len();
let header_len = (RECORD_FIXED_FIELDS + attr_count) * ATOM_SIZE;
if rec_buf.len() < header_len {
return Err(Error::ParseFailed(format!(
"record too small for header: need {header_len}, got {}",
rec_buf.len()
)));
}
let blob_size = read_u32(rec_buf, 16, "record blobSize")? as usize;
let mut attr_offsets = Vec::with_capacity(attr_count);
for i in 0..attr_count {
let pos = (RECORD_FIXED_FIELDS + i) * ATOM_SIZE;
attr_offsets.push(read_u32(rec_buf, pos, "record attr offset")?);
}
let blob_data = if blob_size > 0 && header_len.saturating_add(blob_size) <= rec_buf.len() {
slice(rec_buf, header_len, blob_size, "record blob area")?
} else {
&[]
};
let payload_len = rec_buf.len().saturating_sub(header_len);
let raw_payload = slice(rec_buf, header_len, payload_len, "record payload")?;
Ok(Record {
buf: rec_buf,
blob_data,
raw_payload,
attr_offsets,
schema,
})
}
impl Record<'_> {
fn attr_offset(&self, name: &str) -> Option<usize> {
let idx = self.schema.attr_index(name)?;
let stored = *self.attr_offsets.get(idx)?;
if stored == 0 {
return None;
}
usize::try_from(stored - 1).ok()
}
pub(crate) fn read_u32_attr(&self, name: &str) -> u32 {
let Some(pos) = self.attr_offset(name) else {
return 0;
};
read_u32(self.buf, pos, "attr u32").unwrap_or(0)
}
pub(crate) fn read_string_attr(&self, name: &str) -> String {
let Some(pos) = self.attr_offset(name) else {
return String::new();
};
let Ok(length_u32) = read_u32(self.buf, pos, "attr string length") else {
return String::new();
};
let length = length_u32 as usize;
let start = pos.saturating_add(ATOM_SIZE);
let Ok(payload) = slice(self.buf, start, length, "attr string payload") else {
return String::new();
};
String::from_utf8_lossy(trim_trailing_nulls(payload)).into_owned()
}
pub(crate) fn read_blob_attr(&self, name: &str) -> Vec<u8> {
let Some(pos) = self.attr_offset(name) else {
return Vec::new();
};
let Ok(length_u32) = read_u32(self.buf, pos, "attr blob length") else {
return Vec::new();
};
let length = length_u32 as usize;
if length == 0 {
return Vec::new();
}
let start = pos.saturating_add(ATOM_SIZE);
let Ok(payload) = slice(self.buf, start, length, "attr blob payload") else {
return Vec::new();
};
payload.to_vec()
}
pub(crate) fn read_time_attr(&self, name: &str) -> Option<OffsetDateTime> {
let pos = self.attr_offset(name)?;
let raw = slice(self.buf, pos, TIME_ATTR_LEN, "attr time").ok()?;
let trimmed = trim_trailing_nulls(raw);
if trimmed.is_empty() {
return None;
}
let text = core::str::from_utf8(trimmed).ok()?;
let pdt = PrimitiveDateTime::parse(text, KEYCHAIN_TIME_FORMAT).ok()?;
Some(pdt.assume_utc())
}
pub(crate) fn read_fourcc_attr(&self, name: &str) -> String {
let Some(pos) = self.attr_offset(name) else {
return String::new();
};
let Ok(bytes) = slice(self.buf, pos, ATOM_SIZE, "attr fourcc") else {
return String::new();
};
String::from_utf8_lossy(trim_trailing_nulls(bytes)).into_owned()
}
}
fn trim_trailing_nulls(s: &[u8]) -> &[u8] {
let trimmed_len = s.iter().rposition(|&b| b != 0).map_or(0, |i| i + 1);
s.split_at(trimmed_len.min(s.len())).0
}
#[cfg(test)]
mod tests {
#![expect(
clippy::expect_used,
reason = "test code uses direct expect assertions"
)]
use super::*;
use crate::schema::{ATTR_FORMAT_STRING, ATTR_FORMAT_UINT32, AttrDef};
fn schema_with(names: &[&str]) -> TableSchema {
let attrs = names
.iter()
.map(|n| AttrDef {
name: (*n).to_owned(),
format: ATTR_FORMAT_STRING,
})
.collect();
TableSchema::build(0, attrs)
}
fn record_with_offsets<'a>(
buf: &'a [u8],
offsets: Vec<u32>,
schema: &'a TableSchema,
) -> Record<'a> {
Record {
buf,
blob_data: &[],
raw_payload: &[],
attr_offsets: offsets,
schema,
}
}
#[test]
fn trim_trailing_nulls_removes_padding() {
assert_eq!(trim_trailing_nulls(b"abc\0\0\0"), b"abc");
assert_eq!(trim_trailing_nulls(b"abc"), b"abc");
assert_eq!(trim_trailing_nulls(b"\0\0\0").len(), 0);
assert_eq!(trim_trailing_nulls(b"").len(), 0);
}
#[test]
fn attr_offset_handles_one_based_and_zero_marker() {
let schema = schema_with(&["first", "second"]);
let rec = record_with_offsets(&[], vec![0, 42], &schema);
assert_eq!(rec.attr_offset("first"), None);
assert_eq!(rec.attr_offset("second"), Some(41));
assert_eq!(rec.attr_offset("missing"), None);
}
#[test]
fn read_u32_attr_decodes_big_endian() {
let schema = TableSchema::build(
0,
vec![AttrDef {
name: "v".to_owned(),
format: ATTR_FORMAT_UINT32,
}],
);
let buf: &[u8] = &[0x12, 0x34, 0x56, 0x78];
let rec = record_with_offsets(buf, vec![1], &schema);
assert_eq!(rec.read_u32_attr("v"), 0x1234_5678);
}
#[test]
fn read_string_attr_trims_nulls() {
let schema = schema_with(&["s"]);
let buf: &[u8] = &[0, 0, 0, 5, b'a', b'b', b'c', 0, 0];
let rec = record_with_offsets(buf, vec![1], &schema);
assert_eq!(rec.read_string_attr("s"), "abc");
}
#[test]
fn read_blob_attr_returns_bytes() {
let schema = schema_with(&["b"]);
let buf: &[u8] = &[0, 0, 0, 3, 0xDE, 0xAD, 0xBE];
let rec = record_with_offsets(buf, vec![1], &schema);
assert_eq!(rec.read_blob_attr("b"), vec![0xDE, 0xAD, 0xBE]);
}
#[test]
fn read_time_attr_parses_keychain_format() {
let schema = schema_with(&["t"]);
let buf: &[u8] = b"20230415120530Z\0";
let rec = record_with_offsets(buf, vec![1], &schema);
let parsed = rec.read_time_attr("t").expect("parse succeeded");
assert_eq!(parsed.year(), 2023);
assert_eq!(u8::from(parsed.month()), 4);
assert_eq!(parsed.day(), 15);
assert_eq!(parsed.hour(), 12);
assert_eq!(parsed.minute(), 5);
assert_eq!(parsed.second(), 30);
}
#[test]
fn read_fourcc_attr_round_trip() {
let schema = schema_with(&["f"]);
let buf: &[u8] = b"abcd";
let rec = record_with_offsets(buf, vec![1], &schema);
assert_eq!(rec.read_fourcc_attr("f"), "abcd");
}
#[test]
fn missing_attr_returns_defaults() {
let schema = schema_with(&["s"]);
let rec = record_with_offsets(&[], vec![0], &schema);
assert_eq!(rec.read_u32_attr("s"), 0);
assert_eq!(rec.read_string_attr("s"), "");
assert!(rec.read_blob_attr("s").is_empty());
assert!(rec.read_time_attr("s").is_none());
assert_eq!(rec.read_fourcc_attr("s"), "");
}
}