revault_lockbox_api 0.0.2

reVault lockbox API to create and manage lockboxes
Documentation
use crate::checked::read_u16_le;
use crate::compression::{
    decode_compression_frame, encode_compression_frame, validate_compression_frame_lengths,
};
use crate::compression_frame_manifest::{
    decode_compression_frame_manifest, encode_compression_frame_manifest, CompressionFrameManifest,
};
#[cfg(test)]
use crate::lockbox_path::validate_stored_path as validate_path;
use crate::lockbox_path::validate_symlink_paths as validate_symlink;
#[cfg(test)]
use crate::security::validate_permissions;
use crate::{Error, LockboxPath, Result};

const COMPRESSION_FRAME_SEGMENT_MAGIC: &[u8; 4] = b"LBCS";
const COMPRESSION_FRAME_SEGMENT_VERSION: u8 = 1;

#[cfg(test)]
fn encode_file_payload(path: &str, permissions: u32, data: &[u8]) -> Vec<u8> {
    let path_bytes = path.as_bytes();
    let mut out = Vec::with_capacity(2 + path_bytes.len() + 4 + 8 + data.len());
    out.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
    out.extend_from_slice(path_bytes);
    out.extend_from_slice(&permissions.to_le_bytes());
    out.extend_from_slice(&(data.len() as u64).to_le_bytes());
    out.extend_from_slice(data);
    out
}

#[cfg(test)]
fn decode_file_payload(payload: &[u8]) -> Result<(String, u32, Vec<u8>)> {
    if payload.len() < 14 {
        return Err(Error::CorruptRecord);
    }
    let path_len = read_u16_le(&payload[0..2])? as usize;
    if payload.len() < 2 + path_len + 4 + 8 {
        return Err(Error::CorruptRecord);
    }
    let path =
        String::from_utf8(payload[2..2 + path_len].to_vec()).map_err(|_| Error::CorruptRecord)?;
    validate_path(&path)?;
    let permissions_start = 2 + path_len;
    let permissions = u32::from_le_bytes(
        payload[permissions_start..permissions_start + 4]
            .try_into()
            .unwrap(),
    );
    let permissions = validate_permissions(permissions)?;
    let data_len_start = permissions_start + 4;
    let data_len_u64 = u64::from_le_bytes(
        payload[data_len_start..data_len_start + 8]
            .try_into()
            .unwrap(),
    );
    let data_len = usize::try_from(data_len_u64).map_err(|_| Error::CorruptRecord)?;
    let data_start = data_len_start + 8;
    if payload.len() != data_start + data_len {
        return Err(Error::CorruptRecord);
    }
    Ok((
        path,
        permissions,
        payload[data_start..data_start + data_len].to_vec(),
    ))
}

pub(crate) fn encode_compression_frame_segment_payload(
    manifest: &CompressionFrameManifest,
    segment_offset: u64,
    segment: &[u8],
) -> Result<Vec<u8>> {
    let manifest_bytes = if segment_offset == 0 {
        encode_compression_frame_manifest(manifest)?
    } else {
        Vec::new()
    };
    let (manifest_compression, stored_manifest) = if manifest_bytes.is_empty() {
        (crate::compression::COMPRESSION_NONE, Vec::new())
    } else {
        encode_compression_frame(&manifest_bytes)
    };
    let mut out = Vec::with_capacity(72 + stored_manifest.len() + segment.len());
    out.extend_from_slice(COMPRESSION_FRAME_SEGMENT_MAGIC);
    out.push(COMPRESSION_FRAME_SEGMENT_VERSION);
    put_varint(manifest.compression_frame_id, &mut out);
    put_varint(manifest.compression as u64, &mut out);
    put_varint(manifest.compression_frame_len, &mut out);
    put_varint(manifest.compressed_len, &mut out);
    out.extend_from_slice(&manifest.compression_frame_digest);
    put_varint(manifest_bytes.len() as u64, &mut out);
    put_varint(manifest_compression as u64, &mut out);
    put_varint(stored_manifest.len() as u64, &mut out);
    out.extend_from_slice(&stored_manifest);
    put_varint(segment_offset, &mut out);
    put_varint(segment.len() as u64, &mut out);
    out.extend_from_slice(segment);
    Ok(out)
}

pub(crate) fn decode_compression_frame_segment_payload_view(
    payload: &[u8],
) -> Result<DecodedFileChunkView<'_>> {
    if payload.len() < COMPRESSION_FRAME_SEGMENT_MAGIC.len() + 1 {
        return Err(Error::CorruptRecord);
    }
    if &payload[..COMPRESSION_FRAME_SEGMENT_MAGIC.len()] != COMPRESSION_FRAME_SEGMENT_MAGIC {
        return Err(Error::CorruptRecord);
    }
    let mut offset = COMPRESSION_FRAME_SEGMENT_MAGIC.len();
    if payload[offset] != COMPRESSION_FRAME_SEGMENT_VERSION {
        return Err(Error::CorruptRecord);
    }
    offset += 1;
    let compression_frame_id = take_varint(payload, &mut offset)?;
    let compression =
        u8::try_from(take_varint(payload, &mut offset)?).map_err(|_| Error::CorruptRecord)?;
    let compression_frame_len = take_varint(payload, &mut offset)?;
    let compressed_len = take_varint(payload, &mut offset)?;
    validate_compression_frame_lengths(compression_frame_len, compressed_len)?;
    if offset + 32 > payload.len() {
        return Err(Error::CorruptRecord);
    }
    let mut compression_frame_digest = [0u8; 32];
    compression_frame_digest.copy_from_slice(&payload[offset..offset + 32]);
    offset += 32;
    let manifest_len =
        usize::try_from(take_varint(payload, &mut offset)?).map_err(|_| Error::CorruptRecord)?;
    let manifest_compression =
        u8::try_from(take_varint(payload, &mut offset)?).map_err(|_| Error::CorruptRecord)?;
    let stored_manifest_len =
        usize::try_from(take_varint(payload, &mut offset)?).map_err(|_| Error::CorruptRecord)?;
    if stored_manifest_len > payload.len().saturating_sub(offset) {
        return Err(Error::CorruptRecord);
    }
    let manifest = if manifest_len == 0 {
        if stored_manifest_len != 0 {
            return Err(Error::CorruptRecord);
        }
        None
    } else {
        let manifest_bytes = decode_compression_frame(
            manifest_compression,
            &payload[offset..offset + stored_manifest_len],
            manifest_len as u64,
        )?;
        let manifest = decode_compression_frame_manifest(&manifest_bytes)?;
        if manifest.compression_frame_id != compression_frame_id
            || manifest.compression != compression
            || manifest.compression_frame_len != compression_frame_len
            || manifest.compressed_len != compressed_len
            || manifest.compression_frame_digest != compression_frame_digest
        {
            return Err(Error::CorruptRecord);
        }
        Some(manifest)
    };
    offset += stored_manifest_len;
    let segment_offset = take_varint(payload, &mut offset)?;
    let data_len =
        usize::try_from(take_varint(payload, &mut offset)?).map_err(|_| Error::CorruptRecord)?;
    if data_len > payload.len().saturating_sub(offset) || offset + data_len != payload.len() {
        return Err(Error::CorruptRecord);
    }
    if segment_offset
        .checked_add(data_len as u64)
        .is_none_or(|end| end > compressed_len)
    {
        return Err(Error::CorruptRecord);
    }
    Ok(DecodedFileChunkView {
        compression_frame_id,
        compression,
        compression_frame_len,
        compressed_len,
        compression_frame_digest,
        manifest,
        segment_offset,
        data: &payload[offset..],
    })
}

pub(crate) struct DecodedFileChunkView<'a> {
    pub(crate) compression_frame_id: u64,
    pub(crate) compression: u8,
    pub(crate) compression_frame_len: u64,
    pub(crate) compressed_len: u64,
    pub(crate) compression_frame_digest: [u8; 32],
    pub(crate) manifest: Option<CompressionFrameManifest>,
    pub(crate) segment_offset: u64,
    pub(crate) data: &'a [u8],
}

fn put_varint(mut value: u64, out: &mut Vec<u8>) {
    while value >= 0x80 {
        out.push((value as u8) | 0x80);
        value >>= 7;
    }
    out.push(value as u8);
}

fn take_varint(payload: &[u8], cursor: &mut usize) -> Result<u64> {
    let mut value = 0u64;
    let mut shift = 0u32;
    for _ in 0..10 {
        if *cursor >= payload.len() {
            return Err(Error::CorruptRecord);
        }
        let byte = payload[*cursor];
        *cursor += 1;
        value |= u64::from(byte & 0x7f) << shift;
        if byte & 0x80 == 0 {
            return Ok(value);
        }
        shift += 7;
    }
    Err(Error::CorruptRecord)
}

pub(crate) fn encode_symlink_payload(path: &LockboxPath, target: &LockboxPath) -> Vec<u8> {
    let path_bytes = path.as_str().as_bytes();
    let target_bytes = target.as_str().as_bytes();
    let mut out = Vec::with_capacity(2 + path_bytes.len() + 2 + target_bytes.len());
    out.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
    out.extend_from_slice(path_bytes);
    out.extend_from_slice(&(target_bytes.len() as u16).to_le_bytes());
    out.extend_from_slice(target_bytes);
    out
}

pub(crate) fn decode_symlink_payload(payload: &[u8]) -> Result<(LockboxPath, LockboxPath)> {
    if payload.len() < 4 {
        return Err(Error::CorruptRecord);
    }
    let path_len = read_u16_le(&payload[0..2])? as usize;
    if payload.len() < 2 + path_len + 2 {
        return Err(Error::CorruptRecord);
    }
    let path =
        String::from_utf8(payload[2..2 + path_len].to_vec()).map_err(|_| Error::CorruptRecord)?;
    let path = LockboxPath::from_stored(&path, false)?;
    let target_len_start = 2 + path_len;
    let target_len = read_u16_le(&payload[target_len_start..target_len_start + 2])? as usize;
    let target_start = target_len_start + 2;
    if payload.len() != target_start + target_len {
        return Err(Error::CorruptRecord);
    }
    let target = String::from_utf8(payload[target_start..target_start + target_len].to_vec())
        .map_err(|_| Error::CorruptRecord)?;
    let target = LockboxPath::from_stored(&target, false)?;
    validate_symlink(path.as_str(), target.as_str())?;
    Ok((path, target))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::compression_frame_manifest::{CompressionFrameManifest, CompressionFrameSlice};
    use crate::constants::DEFAULT_FILE_PERMISSIONS;

    #[test]
    fn decoded_file_payload_rejects_tampered_traversal_path() {
        let payload = encode_file_payload("/safe/../evil.txt", DEFAULT_FILE_PERMISSIONS, b"evil");
        assert!(matches!(
            decode_file_payload(&payload),
            Err(Error::InvalidPath(_))
        ));
    }

    #[test]
    fn compression_frame_manifest_is_stored_only_on_first_segment() {
        let manifest = CompressionFrameManifest {
            compression_frame_id: 7,
            compression: 1,
            compression_frame_len: 128,
            compressed_len: 12,
            compression_frame_digest: [3; 32],
            slices: vec![CompressionFrameSlice {
                path: LockboxPath::from_api("/a.txt", false).unwrap(),
                permissions: DEFAULT_FILE_PERMISSIONS,
                total_len: 128,
                file_offset: 0,
                compression_frame_offset: 0,
                len: 128,
            }],
        };

        let first = encode_compression_frame_segment_payload(&manifest, 0, b"abcdef").unwrap();
        let second = encode_compression_frame_segment_payload(&manifest, 6, b"ghijkl").unwrap();

        let first = decode_compression_frame_segment_payload_view(&first).unwrap();
        let second = decode_compression_frame_segment_payload_view(&second).unwrap();

        assert!(first.manifest.is_some());
        assert!(second.manifest.is_none());
        assert_eq!(first.compression_frame_id, second.compression_frame_id);
        assert_eq!(
            first.compression_frame_digest,
            second.compression_frame_digest
        );
        assert_eq!(second.segment_offset, 6);
    }

    #[test]
    fn compression_frame_segment_rejects_compressed_len_larger_than_frame_len() {
        let manifest = CompressionFrameManifest {
            compression_frame_id: 7,
            compression: 0,
            compression_frame_len: 4,
            compressed_len: 5,
            compression_frame_digest: [3; 32],
            slices: Vec::new(),
        };

        let encoded = encode_compression_frame_segment_payload(&manifest, 0, b"abcde").unwrap();

        assert!(matches!(
            decode_compression_frame_segment_payload_view(&encoded),
            Err(Error::CorruptRecord)
        ));
    }
}