revault_lockbox_api 0.0.2

reVault lockbox API to create and manage lockboxes
Documentation
use crate::constants::DEFAULT_METADATA_MAX_PAGE_BODY_BYTES;
use crate::page_tree::{
    decode_page_tree_children, encode_page_tree_children, group_by_encoded_size,
    page_tree_child_encoded_len, PageTreeChild,
};
use crate::toc_codec::{
    decode_toc_entries, encode_toc_entries, encoded_toc_entries_len, TocEntriesLenEstimator,
};
use crate::toc_entry::TocEntry;
use crate::{Error, LockboxPath, Result};

const TOC_NODE_VERSION: u8 = 1;
const TOC_LEAF: u8 = 1;
const TOC_INTERNAL: u8 = 2;
pub(crate) const TOC_MIN_FILL_PERCENT: usize = 30;
const TOC_NODE_PREFIX_BYTES: usize = 2;

#[derive(Debug)]
pub(crate) enum TocNode {
    Leaf(Vec<TocEntry>),
    Internal(Vec<TocChild>),
}

#[derive(Debug, Clone)]
pub(crate) struct TocChild {
    pub(crate) first_path: LockboxPath,
    pub(crate) offset: u64,
}

#[derive(Debug, Clone)]
pub(crate) struct TocLeaf {
    pub(crate) offset: u64,
    pub(crate) entries: Vec<TocEntry>,
}

#[derive(Debug, Clone)]
pub(crate) struct TocInternal {
    pub(crate) offset: u64,
    pub(crate) children: Vec<TocTreeNode>,
}

#[derive(Debug, Clone)]
pub(crate) enum TocTreeNode {
    Leaf(TocLeaf),
    Internal(TocInternal),
}

impl TocTreeNode {
    pub(crate) fn offset(&self) -> u64 {
        match self {
            Self::Leaf(leaf) => leaf.offset,
            Self::Internal(internal) => internal.offset,
        }
    }

    pub(crate) fn first_path(&self) -> &str {
        match self {
            Self::Leaf(leaf) => leaf
                .entries
                .first()
                .map(|entry| entry.path.as_str())
                .unwrap_or(""),
            Self::Internal(internal) => internal
                .children
                .first()
                .map(TocTreeNode::first_path)
                .unwrap_or(""),
        }
    }

    pub(crate) fn collect_leaves(&self, leaves: &mut Vec<TocLeaf>) {
        match self {
            Self::Leaf(leaf) => leaves.push(leaf.clone()),
            Self::Internal(internal) => {
                for child in &internal.children {
                    child.collect_leaves(leaves);
                }
            }
        }
    }
}

pub(crate) fn encode_toc_leaf(entries: &[TocEntry]) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    out.push(TOC_NODE_VERSION);
    out.push(TOC_LEAF);
    out.extend_from_slice(&encode_toc_entries(entries));
    if out.len() > DEFAULT_METADATA_MAX_PAGE_BODY_BYTES {
        return Err(Error::SecurityLimitExceeded(
            "TOC leaf exceeds maximum page size".to_string(),
        ));
    }
    Ok(out)
}

pub(crate) fn encode_toc_internal(children: &[TocChild]) -> Result<Vec<u8>> {
    let mut out = Vec::new();
    out.push(TOC_NODE_VERSION);
    out.push(TOC_INTERNAL);
    let routing_children = children
        .iter()
        .map(|child| PageTreeChild {
            first_key: child.first_path.as_str().to_string(),
            offset: child.offset,
        })
        .collect::<Vec<_>>();
    out.extend_from_slice(&encode_page_tree_children(&routing_children));
    if out.len() > DEFAULT_METADATA_MAX_PAGE_BODY_BYTES {
        return Err(Error::SecurityLimitExceeded(
            "TOC internal node exceeds maximum page size".to_string(),
        ));
    }
    Ok(out)
}

pub(crate) fn toc_leaf_groups(entries: &[TocEntry]) -> Result<Vec<&[TocEntry]>> {
    if entries.is_empty() {
        return Ok(Vec::new());
    }

    let mut groups = Vec::new();
    let mut start = 0usize;
    let mut group_len = TocEntriesLenEstimator::new();
    for (index, entry) in entries.iter().enumerate() {
        let single_len = leaf_base_len() + encoded_toc_entries_len(std::slice::from_ref(entry));
        if single_len > DEFAULT_METADATA_MAX_PAGE_BODY_BYTES {
            return Err(Error::SecurityLimitExceeded(
                "TOC entry exceeds maximum page size".to_string(),
            ));
        }

        group_len.push(entry);
        let candidate_len = leaf_base_len() + group_len.encoded_len();
        if group_len.count() > 1 && candidate_len > DEFAULT_METADATA_MAX_PAGE_BODY_BYTES {
            groups.push(&entries[start..index]);
            start = index;
            group_len.clear();
            group_len.push(entry);
        }
    }
    groups.push(&entries[start..]);
    Ok(groups)
}

pub(crate) fn toc_child_groups(children: &[TocChild]) -> Result<Vec<&[TocChild]>> {
    group_by_encoded_size(
        children,
        internal_base_len(),
        toc_child_encoded_len,
        "TOC child",
    )
}

pub(crate) fn toc_leaf_fill_percent(entries: &[TocEntry]) -> usize {
    encoded_leaf_len(entries).saturating_mul(100) / DEFAULT_METADATA_MAX_PAGE_BODY_BYTES
}

pub(crate) fn encoded_leaf_len(entries: &[TocEntry]) -> usize {
    leaf_base_len() + encoded_toc_entries_len(entries)
}

fn leaf_base_len() -> usize {
    TOC_NODE_PREFIX_BYTES
}

fn internal_base_len() -> usize {
    TOC_NODE_PREFIX_BYTES + 4
}

fn toc_child_encoded_len(child: &TocChild) -> usize {
    page_tree_child_encoded_len(&PageTreeChild {
        first_key: child.first_path.as_str().to_string(),
        offset: child.offset,
    })
}

pub(crate) fn decode_toc_node(payload: &[u8]) -> Result<TocNode> {
    if payload.len() < 2 || payload[0] != TOC_NODE_VERSION {
        return Err(Error::CorruptRecord);
    }
    match payload[1] {
        TOC_LEAF => {
            let entries = decode_toc_entries(&payload[2..])?;
            validate_leaf_entries(&entries)?;
            Ok(TocNode::Leaf(entries))
        }
        TOC_INTERNAL => decode_toc_internal(&payload[2..]),
        _ => Err(Error::CorruptRecord),
    }
}

fn validate_leaf_entries(entries: &[TocEntry]) -> Result<()> {
    for pair in entries.windows(2) {
        if pair[0].path >= pair[1].path {
            return Err(Error::CorruptRecord);
        }
    }
    Ok(())
}

fn decode_toc_internal(payload: &[u8]) -> Result<TocNode> {
    let children = decode_page_tree_children(payload, |key| {
        LockboxPath::from_stored(key, false).map(|_| ())
    })?
    .into_iter()
    .map(|child| {
        Ok(TocChild {
            first_path: LockboxPath::from_stored(&child.first_key, false)?,
            offset: child.offset,
        })
    })
    .collect::<Result<Vec<_>>>()?;
    Ok(TocNode::Internal(children))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::constants::DEFAULT_FILE_PERMISSIONS;
    use crate::node_kind::NodeKind;
    use crate::toc_entry::TocEntry;

    #[test]
    fn toc_leaf_decode_rejects_tampered_paths() {
        let entry = TocEntry {
            path: LockboxPath::from_unchecked_for_test("/safe/../evil.txt"),
            len: 1,
            record_offset: 64,
            record_len: 64,
            record_object_id: 1,
            deleted: false,
            node_kind: NodeKind::File,
            permissions: DEFAULT_FILE_PERMISSIONS,
            chunks: Vec::new(),
        };
        let payload = encode_toc_leaf(&[entry]).unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::InvalidPath(_))
        ));
    }

    #[test]
    fn toc_internal_decode_rejects_tampered_separator_paths() {
        let payload = encode_toc_internal(&[TocChild {
            first_path: LockboxPath::from_unchecked_for_test("/C:/Users/evil.txt"),
            offset: 64,
        }])
        .unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::InvalidPath(_))
        ));
    }

    #[test]
    fn toc_leaf_decode_rejects_unsorted_or_duplicate_paths() {
        let mut entries = vec![entry("/b.txt"), entry("/a.txt")];
        let payload = encode_toc_leaf(&entries).unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::CorruptRecord)
        ));

        entries = vec![entry("/a.txt"), entry("/a.txt")];
        let payload = encode_toc_leaf(&entries).unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::CorruptRecord)
        ));
    }

    #[test]
    fn toc_internal_decode_rejects_unsorted_duplicate_or_zero_offsets() {
        let payload = encode_toc_internal(&[
            TocChild {
                first_path: LockboxPath::new("/b.txt").unwrap(),
                offset: 128,
            },
            TocChild {
                first_path: LockboxPath::new("/a.txt").unwrap(),
                offset: 256,
            },
        ])
        .unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::CorruptRecord)
        ));

        let payload = encode_toc_internal(&[
            TocChild {
                first_path: LockboxPath::new("/a.txt").unwrap(),
                offset: 128,
            },
            TocChild {
                first_path: LockboxPath::new("/a.txt").unwrap(),
                offset: 256,
            },
        ])
        .unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::CorruptRecord)
        ));

        let payload = encode_toc_internal(&[TocChild {
            first_path: LockboxPath::new("/a.txt").unwrap(),
            offset: 0,
        }])
        .unwrap();
        assert!(matches!(
            decode_toc_node(&payload),
            Err(Error::CorruptRecord)
        ));
    }

    #[test]
    fn toc_internal_numeric_fields_are_little_endian() {
        let children = vec![TocChild {
            first_path: LockboxPath::new("/a").unwrap(),
            offset: 0x0102_0304_0506_0708,
        }];
        let encoded = encode_toc_internal(&children).unwrap();

        assert_eq!(&encoded[0..2], &[TOC_NODE_VERSION, TOC_INTERNAL]);
        assert_eq!(&encoded[2..6], &[0x01, 0x00, 0x00, 0x00]);
        assert_eq!(&encoded[6..8], &[0x02, 0x00]);
        assert_eq!(&encoded[8..10], b"/a");
        assert_eq!(
            &encoded[10..18],
            &[0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01]
        );

        let TocNode::Internal(decoded) = decode_toc_node(&encoded).unwrap() else {
            panic!("expected internal node");
        };
        assert_eq!(decoded[0].offset, 0x0102_0304_0506_0708);
        assert_eq!(decoded[0].first_path, "/a");
    }

    #[test]
    fn toc_leaf_groups_are_sized_by_encoded_bytes() {
        let entries = (0..6000)
            .map(|i| TocEntry {
                path: LockboxPath::new(format!("/many/file-{i:04}.txt")).unwrap(),
                len: 1,
                record_offset: 64,
                record_len: 64,
                record_object_id: 1,
                deleted: false,
                node_kind: NodeKind::File,
                permissions: DEFAULT_FILE_PERMISSIONS,
                chunks: Vec::new(),
            })
            .collect::<Vec<_>>();

        let groups = toc_leaf_groups(&entries).unwrap();
        assert!(!groups.is_empty());
        for group in groups {
            assert!(encode_toc_leaf(group).unwrap().len() <= DEFAULT_METADATA_MAX_PAGE_BODY_BYTES);
        }
    }

    fn entry(path: &str) -> TocEntry {
        TocEntry {
            path: LockboxPath::new(path).unwrap(),
            len: 1,
            record_offset: 64,
            record_len: 64,
            record_object_id: 1,
            deleted: false,
            node_kind: NodeKind::File,
            permissions: DEFAULT_FILE_PERMISSIONS,
            chunks: Vec::new(),
        }
    }
}