use std::collections::BTreeMap;
use std::fs::OpenOptions;
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::Path;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::layout::{SUPERBLOCK_FLAG_HISTORY_FOOTER, SUPERBLOCK_V1_LEN, SuperblockV1};
use super::CatalogError;
use super::metadata::{self, MetadataLimitsV1, TetMetadataV1};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct HistoryFooterWireV1 {
pub magic: [u8; 4],
pub version: u32,
pub tail_len: usize,
}
impl HistoryFooterWireV1 {
pub const DEFAULT: Self = Self {
magic: *b"THST",
version: 1,
tail_len: 16,
};
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HistoryEvent {
pub op: String,
pub source: String,
pub at: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub parents: Vec<String>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub params: BTreeMap<String, String>,
}
impl HistoryEvent {
#[must_use]
pub fn new(op: impl Into<String>, source: impl Into<String>) -> Self {
Self {
op: op.into(),
source: source.into(),
at: unix_timestamp_now(),
parents: Vec::new(),
params: BTreeMap::new(),
}
}
#[must_use]
pub fn as_tuple(&self) -> (&str, &str, &str) {
(&self.op, &self.source, &self.at)
}
}
pub type HistoryEventV1 = HistoryEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MetadataBlobRefV1 {
pub offset: u64,
pub len: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FooterBlobV1 {
pub history: Vec<HistoryEvent>,
pub metadata: Option<TetMetadataV1>,
pub metadata_ref: Option<MetadataBlobRefV1>,
}
impl FooterBlobV1 {
pub fn validate(&self) -> Result<(), CatalogError> {
let limits = MetadataLimitsV1::DEFAULT;
if self.history.len() > limits.history_events {
return Err(CatalogError::InvalidWriteSpec(
"history exceeds history_events limit",
));
}
for ev in &self.history {
ev.validate(limits)?;
}
if let Some(meta) = &self.metadata {
meta.validate()?;
}
if self.metadata.is_some() && self.metadata_ref.is_some() {
return Err(CatalogError::InvalidWriteSpec(
"footer metadata and metadata_ref are mutually exclusive",
));
}
Ok(())
}
}
impl HistoryEvent {
fn validate(&self, limits: MetadataLimitsV1) -> Result<(), CatalogError> {
metadata::validate_attr_string(&self.op, "history op", limits.attr_string_bytes)?;
metadata::validate_attr_string(&self.source, "history source", limits.attr_string_bytes)?;
metadata::validate_attr_string(&self.at, "history at", limits.attr_string_bytes)?;
if self.parents.len() > limits.history_parents {
return Err(CatalogError::InvalidWriteSpec(
"history parents exceeds history_parents limit",
));
}
for p in &self.parents {
metadata::validate_attr_string(p, "history parent", limits.attr_string_bytes)?;
}
if self.params.len() > limits.history_params {
return Err(CatalogError::InvalidWriteSpec(
"history params exceeds history_params limit",
));
}
for (k, v) in &self.params {
metadata::validate_attr_string(k, "history param key", limits.attr_string_bytes)?;
metadata::validate_attr_string(v, "history param value", limits.attr_string_bytes)?;
}
Ok(())
}
}
impl Serialize for FooterBlobV1 {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
FooterBlobWire::from(self).serialize(serializer)
}
}
impl<'de> Deserialize<'de> for FooterBlobV1 {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let wire = FooterBlobWire::deserialize(deserializer)?;
Ok(wire.into_footer())
}
}
#[derive(Serialize, Deserialize)]
struct FooterBlobWire {
#[serde(default)]
history: Vec<HistoryEventWire>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata: Option<TetMetadataV1>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metadata_ref: Option<MetadataBlobRefV1>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum HistoryEventWire {
Triple([String; 3]),
Event(HistoryEvent),
}
impl HistoryEventWire {
fn into_event(self) -> HistoryEvent {
match self {
Self::Triple([op, source, at]) => HistoryEvent {
op,
source,
at,
parents: Vec::new(),
params: BTreeMap::new(),
},
Self::Event(ev) => ev,
}
}
}
impl Serialize for HistoryEventWire {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match self {
Self::Event(ev) => ev.serialize(serializer),
Self::Triple(t) => t.serialize(serializer),
}
}
}
impl FooterBlobWire {
fn from(blob: &FooterBlobV1) -> Self {
Self {
history: blob
.history
.iter()
.map(|ev| HistoryEventWire::Event(ev.clone()))
.collect(),
metadata: blob.metadata.clone(),
metadata_ref: blob.metadata_ref,
}
}
fn into_footer(self) -> FooterBlobV1 {
FooterBlobV1 {
history: self
.history
.into_iter()
.map(HistoryEventWire::into_event)
.collect(),
metadata: self.metadata,
metadata_ref: self.metadata_ref,
}
}
}
pub fn append_convert_history(
path: &Path,
source: &str,
) -> Result<Vec<HistoryEvent>, CatalogError> {
let mut blob = read_footer_blob_from_path(path).unwrap_or_default();
let event = HistoryEvent::new("convert", source);
blob.history.push(event.clone());
write_footer_blob(path, &blob)?;
Ok(vec![event])
}
#[must_use]
pub fn unix_timestamp_now() -> String {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or_else(|_| "0".to_owned(), |d| d.as_secs().to_string())
}
pub fn append_history_events(path: &Path, events: &[HistoryEvent]) -> Result<(), CatalogError> {
if events.is_empty() {
return Ok(());
}
let mut blob = read_footer_blob_from_path(path).unwrap_or_default();
blob.history.extend_from_slice(events);
write_footer_blob(path, &blob)
}
pub fn write_footer_blob(path: &Path, blob: &FooterBlobV1) -> Result<(), CatalogError> {
if blob.history.is_empty() && blob.metadata.is_none() && blob.metadata_ref.is_none() {
return Ok(());
}
blob.validate()?;
let inline_json = encode_footer_json(blob)?;
if metadata::validate_footer_json_len(&inline_json).is_ok() {
return rewrite_footer_bytes(path, inline_json);
}
let meta = blob
.metadata
.as_ref()
.ok_or(CatalogError::InvalidWriteSpec(
"footer JSON exceeds limit and there is no metadata to spill",
))?;
let meta_json =
serde_json::to_vec(meta).map_err(|_| CatalogError::InvalidWriteSpec("metadata JSON"))?;
metadata::validate_metadata_blob_len(&meta_json)?;
let spill_blob = FooterBlobV1 {
history: blob.history.clone(),
metadata: None,
metadata_ref: None,
};
let spill_footer_json = encode_footer_json(&spill_blob)?;
if metadata::validate_footer_json_len(&spill_footer_json).is_ok() {
return rewrite_footer_bytes_with_spill(path, spill_footer_json, Some(&meta_json));
}
Err(CatalogError::InvalidWriteSpec(
"footer JSON exceeds footer_json_bytes limit even without inline metadata",
))
}
fn encode_footer_json(blob: &FooterBlobV1) -> Result<Vec<u8>, CatalogError> {
serde_json::to_vec(blob).map_err(|_| CatalogError::InvalidWriteSpec("footer JSON"))
}
fn read_footer_blob_from_path(path: &Path) -> Result<FooterBlobV1, CatalogError> {
let data = std::fs::read(path)?;
let sb = SuperblockV1::from_bytes(
data.get(..SUPERBLOCK_V1_LEN)
.ok_or(CatalogError::TooShort {
need: SUPERBLOCK_V1_LEN,
got: data.len(),
})?
.try_into()
.map_err(|_| CatalogError::InvalidWriteSpec("superblock length"))?,
)
.map_err(CatalogError::Layout)?;
read_footer_blob(&data, sb.flags)
}
pub fn read_footer_blob(data: &[u8], flags: u32) -> Result<FooterBlobV1, CatalogError> {
let Some(footer_len) = history_footer_len(data)? else {
return Ok(FooterBlobV1::default());
};
if flags & SUPERBLOCK_FLAG_HISTORY_FOOTER == 0 {
return Ok(FooterBlobV1::default());
}
let json_start = data
.len()
.checked_sub(footer_len)
.ok_or(CatalogError::TooShort {
need: footer_len,
got: data.len(),
})?;
let wire = HistoryFooterWireV1::DEFAULT;
let mut blob: FooterBlobV1 =
serde_json::from_slice(&data[json_start..data.len() - wire.tail_len])
.map_err(|_| CatalogError::InvalidWriteSpec("footer JSON parse"))?;
if let Some(spill) = blob.metadata_ref {
blob.metadata = Some(read_metadata_spill(data, spill)?);
blob.metadata_ref = None;
}
Ok(blob)
}
fn read_metadata_spill(
data: &[u8],
spill: MetadataBlobRefV1,
) -> Result<TetMetadataV1, CatalogError> {
let offset = usize::try_from(spill.offset).map_err(|_| CatalogError::TooLargeForPlatform {
field: "metadata_ref.offset",
value: spill.offset,
})?;
let len = usize::try_from(spill.len).map_err(|_| CatalogError::TooLargeForPlatform {
field: "metadata_ref.len",
value: spill.len,
})?;
let end = offset
.checked_add(len)
.ok_or(CatalogError::InvalidWriteSpec("metadata_ref overflow"))?;
if end > data.len() {
return Err(CatalogError::InvalidWriteSpec("metadata_ref out of bounds"));
}
let meta: TetMetadataV1 = serde_json::from_slice(&data[offset..end])
.map_err(|_| CatalogError::InvalidWriteSpec("metadata spill JSON parse"))?;
meta.validate()?;
Ok(meta)
}
pub fn read_history(data: &[u8], flags: u32) -> Result<Vec<HistoryEvent>, CatalogError> {
Ok(read_footer_blob(data, flags)?.history)
}
pub fn read_metadata(data: &[u8], flags: u32) -> Result<TetMetadataV1, CatalogError> {
Ok(read_footer_blob(data, flags)?.metadata.unwrap_or_default())
}
fn rewrite_footer_bytes(path: &Path, json: Vec<u8>) -> Result<(), CatalogError> {
metadata::validate_footer_json_len(&json)?;
rewrite_footer_bytes_with_spill(path, json, None)
}
fn rewrite_footer_bytes_with_spill(
path: &Path,
footer_json: Vec<u8>,
metadata_spill: Option<&[u8]>,
) -> Result<(), CatalogError> {
let data = std::fs::read(path)?;
let sb = SuperblockV1::from_bytes(
data.get(..SUPERBLOCK_V1_LEN)
.ok_or(CatalogError::TooShort {
need: SUPERBLOCK_V1_LEN,
got: data.len(),
})?
.try_into()
.map_err(|_| CatalogError::InvalidWriteSpec("superblock length"))?,
)
.map_err(CatalogError::Layout)?;
let payload_end = usize::try_from(payload_file_len(&data, sb.flags)?).map_err(|_| {
CatalogError::TooLargeForPlatform {
field: "payload_end",
value: u64::MAX,
}
})?;
let (footer_bytes, spill_bytes) = if let Some(spill) = metadata_spill {
metadata::validate_metadata_blob_len(spill)?;
let offset = u64::try_from(payload_end).map_err(|_| CatalogError::TooLargeForPlatform {
field: "metadata_spill.offset",
value: u64::MAX,
})?;
let len = u64::try_from(spill.len()).map_err(|_| CatalogError::TooLargeForPlatform {
field: "metadata_spill.len",
value: u64::MAX,
})?;
let stub = FooterBlobV1 {
history: serde_json::from_slice::<FooterBlobWire>(&footer_json)
.map_err(|_| CatalogError::InvalidWriteSpec("footer JSON parse"))?
.history
.into_iter()
.map(HistoryEventWire::into_event)
.collect(),
metadata: None,
metadata_ref: Some(MetadataBlobRefV1 { offset, len }),
};
let encoded = encode_footer_json(&stub)?;
metadata::validate_footer_json_len(&encoded)?;
(encoded, Some(spill.to_vec()))
} else {
(footer_json, None)
};
let json_len =
u64::try_from(footer_bytes.len()).map_err(|_| CatalogError::TooLargeForPlatform {
field: "footer_json_len",
value: u64::MAX,
})?;
let mut f = OpenOptions::new().read(true).write(true).open(path)?;
f.set_len(payload_end as u64)?;
f.seek(SeekFrom::End(0))?;
if let Some(spill) = spill_bytes {
f.write_all(&spill)?;
}
f.write_all(&footer_bytes)?;
f.write_all(&json_len.to_le_bytes())?;
let wire = HistoryFooterWireV1::DEFAULT;
f.write_all(&wire.version.to_le_bytes())?;
f.write_all(&wire.magic)?;
let mut sb_bytes = [0u8; SUPERBLOCK_V1_LEN];
f.seek(SeekFrom::Start(0))?;
f.read_exact(&mut sb_bytes)?;
let mut sb = SuperblockV1::from_bytes(&sb_bytes).map_err(CatalogError::Layout)?;
sb.flags |= SUPERBLOCK_FLAG_HISTORY_FOOTER;
f.seek(SeekFrom::Start(0))?;
f.write_all(&sb.to_bytes())?;
f.sync_all()?;
Ok(())
}
pub fn payload_file_len(data: &[u8], flags: u32) -> Result<u64, CatalogError> {
let len = u64::try_from(data.len()).map_err(|_| CatalogError::TooLargeForPlatform {
field: "file_len",
value: u64::MAX,
})?;
if flags & SUPERBLOCK_FLAG_HISTORY_FOOTER == 0 {
return Ok(len);
}
let footer = history_footer_len(data)?.ok_or(CatalogError::InvalidWriteSpec(
"superblock history flag set but footer missing",
))?;
let json_start = data
.len()
.checked_sub(footer)
.ok_or(CatalogError::InvalidWriteSpec(
"history footer longer than file",
))?;
let wire = HistoryFooterWireV1::DEFAULT;
let json_end = data.len() - wire.tail_len;
let spill_len = serde_json::from_slice::<FooterBlobWire>(&data[json_start..json_end])
.ok()
.and_then(|w| w.metadata_ref)
.map_or(0, |r| usize::try_from(r.len).unwrap_or(usize::MAX));
let suffix = footer
.checked_add(spill_len)
.ok_or(CatalogError::InvalidWriteSpec("footer spill overflow"))?;
len.checked_sub(
u64::try_from(suffix).map_err(|_| CatalogError::TooLargeForPlatform {
field: "footer_suffix",
value: u64::MAX,
})?,
)
.ok_or(CatalogError::InvalidWriteSpec(
"history footer longer than file",
))
}
fn history_footer_len(data: &[u8]) -> Result<Option<usize>, CatalogError> {
let wire = HistoryFooterWireV1::DEFAULT;
if data.len() < wire.tail_len {
return Ok(None);
}
let tail = data.len() - wire.tail_len;
if data[tail + 12..tail + 16] != wire.magic {
return Ok(None);
}
let version = u32::from_le_bytes(data[tail + 8..tail + 12].try_into().unwrap());
if version != wire.version {
return Err(CatalogError::InvalidWriteSpec(
"unsupported history footer version",
));
}
let json_len = u64::from_le_bytes(data[tail..tail + 8].try_into().unwrap());
let json_len_usize =
usize::try_from(json_len).map_err(|_| CatalogError::TooLargeForPlatform {
field: "history_json_len",
value: json_len,
})?;
let total = wire
.tail_len
.checked_add(json_len_usize)
.ok_or(CatalogError::InvalidWriteSpec("history footer overflow"))?;
if total > data.len() {
return Err(CatalogError::InvalidWriteSpec(
"history footer out of bounds",
));
}
Ok(Some(total))
}
#[cfg(test)]
pub fn rewrite_footer_bytes_for_test(path: &Path, json: Vec<u8>) -> Result<(), CatalogError> {
rewrite_footer_bytes(path, json)
}