408 lines
13 KiB
Rust
408 lines
13 KiB
Rust
//! Generic LeVCS on-disk object framing.
|
|
//!
|
|
//! Per the v1.1 trust-root revision §2.1, every object on disk is laid out as:
|
|
//!
|
|
//! ```text
|
|
//! Offset Size Field
|
|
//! 0 4 Magic: "LVCS"
|
|
//! 4 1 Object type
|
|
//! 5 1 Format version (1)
|
|
//! 6 2 Reserved (zero)
|
|
//! 8 8 Body length (LE uint64)
|
|
//! 16 body_len Body
|
|
//! 16+body_len 1 Signature count (uint8) -- signed objects only
|
|
//! 17+body_len 96*N Signature entries -- signed objects only
|
|
//! ```
|
|
//!
|
|
//! Blobs and trees are *not* signed objects (§2.1 lists Commit, Release, and
|
|
//! Authority as signed object types) and have no trailer. For uniformity the
|
|
//! parser exposes both header and trailer; the trailer is always zero-length
|
|
//! for blobs and trees.
|
|
|
|
use byteorder::{ByteOrder, LittleEndian};
|
|
|
|
use crate::error::Error;
|
|
use crate::hash::{blake3_hash, ObjectId};
|
|
|
|
pub const MAGIC: [u8; 4] = *b"LVCS";
|
|
pub const FORMAT_VERSION: u8 = 1;
|
|
pub const HEADER_SIZE: usize = 16;
|
|
pub const SIGNATURE_ENTRY_SIZE: usize = 96;
|
|
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
#[repr(u8)]
|
|
pub enum ObjectType {
|
|
Blob = 1,
|
|
Tree = 2,
|
|
Commit = 3,
|
|
Release = 4,
|
|
Authority = 5,
|
|
}
|
|
|
|
impl ObjectType {
|
|
pub fn from_u8(b: u8) -> Result<Self, Error> {
|
|
Ok(match b {
|
|
1 => Self::Blob,
|
|
2 => Self::Tree,
|
|
3 => Self::Commit,
|
|
4 => Self::Release,
|
|
5 => Self::Authority,
|
|
n => return Err(Error::UnknownObjectType(n)),
|
|
})
|
|
}
|
|
|
|
pub fn is_signed(self) -> bool {
|
|
matches!(self, Self::Commit | Self::Release | Self::Authority)
|
|
}
|
|
|
|
pub fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Blob => "blob",
|
|
Self::Tree => "tree",
|
|
Self::Commit => "commit",
|
|
Self::Release => "release",
|
|
Self::Authority => "authority",
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Decoded fixed-size object header.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub struct ObjectHeader {
|
|
pub object_type: ObjectType,
|
|
pub format_version: u8,
|
|
pub body_len: u64,
|
|
}
|
|
|
|
impl ObjectHeader {
|
|
pub fn encode(&self) -> [u8; HEADER_SIZE] {
|
|
let mut buf = [0u8; HEADER_SIZE];
|
|
buf[0..4].copy_from_slice(&MAGIC);
|
|
buf[4] = self.object_type as u8;
|
|
buf[5] = self.format_version;
|
|
// bytes 6..8 reserved (zero)
|
|
LittleEndian::write_u64(&mut buf[8..16], self.body_len);
|
|
buf
|
|
}
|
|
|
|
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
|
|
if bytes.len() < HEADER_SIZE {
|
|
return Err(Error::MalformedObject(format!(
|
|
"header truncated: got {} bytes, need {}",
|
|
bytes.len(),
|
|
HEADER_SIZE
|
|
)));
|
|
}
|
|
if &bytes[0..4] != MAGIC.as_ref() {
|
|
return Err(Error::MalformedObject("bad magic".into()));
|
|
}
|
|
let object_type = ObjectType::from_u8(bytes[4])?;
|
|
let format_version = bytes[5];
|
|
if format_version != FORMAT_VERSION {
|
|
return Err(Error::UnsupportedFormatVersion(format_version));
|
|
}
|
|
if bytes[6] != 0 || bytes[7] != 0 {
|
|
return Err(Error::MalformedObject("reserved bytes nonzero".into()));
|
|
}
|
|
let body_len = LittleEndian::read_u64(&bytes[8..16]);
|
|
Ok(Self {
|
|
object_type,
|
|
format_version,
|
|
body_len,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// One entry in a signature trailer.
|
|
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
|
|
pub struct SignatureEntry {
|
|
pub public_key: [u8; 32],
|
|
pub signature: [u8; 64],
|
|
}
|
|
|
|
impl SignatureEntry {
|
|
pub fn encode(&self) -> [u8; SIGNATURE_ENTRY_SIZE] {
|
|
let mut buf = [0u8; SIGNATURE_ENTRY_SIZE];
|
|
buf[0..32].copy_from_slice(&self.public_key);
|
|
buf[32..96].copy_from_slice(&self.signature);
|
|
buf
|
|
}
|
|
|
|
pub fn decode(bytes: &[u8]) -> Result<Self, Error> {
|
|
if bytes.len() < SIGNATURE_ENTRY_SIZE {
|
|
return Err(Error::InvalidSignatureTrailer);
|
|
}
|
|
let mut pk = [0u8; 32];
|
|
let mut sg = [0u8; 64];
|
|
pk.copy_from_slice(&bytes[0..32]);
|
|
sg.copy_from_slice(&bytes[32..96]);
|
|
Ok(Self {
|
|
public_key: pk,
|
|
signature: sg,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A `SignedObject` is an object whose body has been augmented with a
|
|
/// signature trailer. The signature is computed over `BLAKE3(header || body)`
|
|
/// and stored after the body. Object hashes cover the entire signed object.
|
|
#[derive(Clone, Debug)]
|
|
pub struct SignedObject {
|
|
pub object_type: ObjectType,
|
|
pub body: Vec<u8>,
|
|
pub signatures: Vec<SignatureEntry>,
|
|
}
|
|
|
|
impl SignedObject {
|
|
pub fn new(object_type: ObjectType, body: Vec<u8>) -> Self {
|
|
Self {
|
|
object_type,
|
|
body,
|
|
signatures: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// The 32-byte hash that signers sign: BLAKE3(header || body).
|
|
pub fn signing_hash(&self) -> ObjectId {
|
|
let header = ObjectHeader {
|
|
object_type: self.object_type,
|
|
format_version: FORMAT_VERSION,
|
|
body_len: self.body.len() as u64,
|
|
}
|
|
.encode();
|
|
let mut hasher = blake3::Hasher::new();
|
|
hasher.update(&header);
|
|
hasher.update(&self.body);
|
|
ObjectId(*hasher.finalize().as_bytes())
|
|
}
|
|
|
|
/// Serialize to the on-disk representation including signature trailer.
|
|
pub fn serialize(&self) -> Vec<u8> {
|
|
let header = ObjectHeader {
|
|
object_type: self.object_type,
|
|
format_version: FORMAT_VERSION,
|
|
body_len: self.body.len() as u64,
|
|
}
|
|
.encode();
|
|
let n = self.signatures.len();
|
|
assert!(n <= 255, "too many signatures");
|
|
let mut out =
|
|
Vec::with_capacity(HEADER_SIZE + self.body.len() + 1 + n * SIGNATURE_ENTRY_SIZE);
|
|
out.extend_from_slice(&header);
|
|
out.extend_from_slice(&self.body);
|
|
out.push(n as u8);
|
|
for s in &self.signatures {
|
|
out.extend_from_slice(&s.encode());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Content hash (over the entire serialized object).
|
|
pub fn object_id(&self) -> ObjectId {
|
|
blake3_hash(&self.serialize())
|
|
}
|
|
|
|
/// Parse a signed object from its on-disk bytes.
|
|
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
|
|
let header = ObjectHeader::decode(bytes)?;
|
|
if !header.object_type.is_signed() {
|
|
return Err(Error::MalformedObject(format!(
|
|
"object type {} is not a signed object",
|
|
header.object_type.name()
|
|
)));
|
|
}
|
|
// Bounds-check every offset arithmetic step. A hostile peer can
|
|
// set body_len or the trailer count to values that, when cast to
|
|
// usize and summed, overflow — turning what should be a graceful
|
|
// "body truncated" error into a panic. Use `checked_*` throughout.
|
|
let body_start = HEADER_SIZE;
|
|
let body_len = usize::try_from(header.body_len)
|
|
.map_err(|_| Error::MalformedObject("body_len exceeds usize".into()))?;
|
|
let body_end = body_start
|
|
.checked_add(body_len)
|
|
.ok_or_else(|| Error::MalformedObject("body offset overflow".into()))?;
|
|
if bytes.len() < body_end + 1 {
|
|
return Err(Error::MalformedObject("body truncated".into()));
|
|
}
|
|
let body = bytes[body_start..body_end].to_vec();
|
|
let count = bytes[body_end] as usize;
|
|
let trailer_start = body_end
|
|
.checked_add(1)
|
|
.ok_or_else(|| Error::MalformedObject("trailer offset overflow".into()))?;
|
|
let trailer_size = count
|
|
.checked_mul(SIGNATURE_ENTRY_SIZE)
|
|
.ok_or(Error::InvalidSignatureTrailer)?;
|
|
let trailer_end = trailer_start
|
|
.checked_add(trailer_size)
|
|
.ok_or(Error::InvalidSignatureTrailer)?;
|
|
if bytes.len() < trailer_end {
|
|
return Err(Error::InvalidSignatureTrailer);
|
|
}
|
|
let mut signatures = Vec::with_capacity(count);
|
|
for i in 0..count {
|
|
let off = trailer_start + i * SIGNATURE_ENTRY_SIZE;
|
|
signatures.push(SignatureEntry::decode(
|
|
&bytes[off..off + SIGNATURE_ENTRY_SIZE],
|
|
)?);
|
|
}
|
|
if bytes.len() != trailer_end {
|
|
return Err(Error::MalformedObject(format!(
|
|
"trailing garbage after signed object: {} extra byte(s)",
|
|
bytes.len() - trailer_end
|
|
)));
|
|
}
|
|
Ok(Self {
|
|
object_type: header.object_type,
|
|
body,
|
|
signatures,
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A `RawObject` is a parsed but un-typed object. Useful when reading an
|
|
/// object from disk and dispatching by type.
|
|
#[derive(Clone, Debug)]
|
|
pub struct RawObject {
|
|
pub object_type: ObjectType,
|
|
pub body: Vec<u8>,
|
|
pub signatures: Vec<SignatureEntry>,
|
|
}
|
|
|
|
impl RawObject {
|
|
pub fn parse(bytes: &[u8]) -> Result<Self, Error> {
|
|
let header = ObjectHeader::decode(bytes)?;
|
|
// Same overflow concern as SignedObject::parse — see the note
|
|
// there. Same checked-arithmetic discipline applied here.
|
|
let body_start = HEADER_SIZE;
|
|
let body_len = usize::try_from(header.body_len)
|
|
.map_err(|_| Error::MalformedObject("body_len exceeds usize".into()))?;
|
|
let body_end = body_start
|
|
.checked_add(body_len)
|
|
.ok_or_else(|| Error::MalformedObject("body offset overflow".into()))?;
|
|
if bytes.len() < body_end {
|
|
return Err(Error::MalformedObject("body truncated".into()));
|
|
}
|
|
let body = bytes[body_start..body_end].to_vec();
|
|
let signatures = if header.object_type.is_signed() {
|
|
if bytes.len() < body_end + 1 {
|
|
return Err(Error::MalformedObject("missing signature trailer".into()));
|
|
}
|
|
let count = bytes[body_end] as usize;
|
|
let trailer_start = body_end
|
|
.checked_add(1)
|
|
.ok_or_else(|| Error::MalformedObject("trailer offset overflow".into()))?;
|
|
let trailer_size = count
|
|
.checked_mul(SIGNATURE_ENTRY_SIZE)
|
|
.ok_or(Error::InvalidSignatureTrailer)?;
|
|
let trailer_end = trailer_start
|
|
.checked_add(trailer_size)
|
|
.ok_or(Error::InvalidSignatureTrailer)?;
|
|
if bytes.len() < trailer_end {
|
|
return Err(Error::InvalidSignatureTrailer);
|
|
}
|
|
let mut sigs = Vec::with_capacity(count);
|
|
for i in 0..count {
|
|
let off = trailer_start + i * SIGNATURE_ENTRY_SIZE;
|
|
sigs.push(SignatureEntry::decode(
|
|
&bytes[off..off + SIGNATURE_ENTRY_SIZE],
|
|
)?);
|
|
}
|
|
sigs
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
Ok(Self {
|
|
object_type: header.object_type,
|
|
body,
|
|
signatures,
|
|
})
|
|
}
|
|
|
|
/// Serialize a raw object (with empty trailer for unsigned types).
|
|
pub fn serialize(&self) -> Vec<u8> {
|
|
let header = ObjectHeader {
|
|
object_type: self.object_type,
|
|
format_version: FORMAT_VERSION,
|
|
body_len: self.body.len() as u64,
|
|
}
|
|
.encode();
|
|
let signed = self.object_type.is_signed();
|
|
let n = self.signatures.len();
|
|
let trailer_size = if signed {
|
|
1 + n * SIGNATURE_ENTRY_SIZE
|
|
} else {
|
|
0
|
|
};
|
|
let mut out = Vec::with_capacity(HEADER_SIZE + self.body.len() + trailer_size);
|
|
out.extend_from_slice(&header);
|
|
out.extend_from_slice(&self.body);
|
|
if signed {
|
|
out.push(n as u8);
|
|
for s in &self.signatures {
|
|
out.extend_from_slice(&s.encode());
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn object_id(&self) -> ObjectId {
|
|
blake3_hash(&self.serialize())
|
|
}
|
|
}
|
|
|
|
/// Helper used by unsigned object types (Blob, Tree) to wrap a body in the
|
|
/// fixed object framing.
|
|
pub fn frame_unsigned(object_type: ObjectType, body: &[u8]) -> Vec<u8> {
|
|
debug_assert!(!object_type.is_signed());
|
|
let header = ObjectHeader {
|
|
object_type,
|
|
format_version: FORMAT_VERSION,
|
|
body_len: body.len() as u64,
|
|
}
|
|
.encode();
|
|
let mut out = Vec::with_capacity(HEADER_SIZE + body.len());
|
|
out.extend_from_slice(&header);
|
|
out.extend_from_slice(body);
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn header_roundtrip() {
|
|
let h = ObjectHeader {
|
|
object_type: ObjectType::Blob,
|
|
format_version: 1,
|
|
body_len: 42,
|
|
};
|
|
let bytes = h.encode();
|
|
let h2 = ObjectHeader::decode(&bytes).unwrap();
|
|
assert_eq!(h, h2);
|
|
}
|
|
|
|
#[test]
|
|
fn signed_object_roundtrip() {
|
|
let mut so = SignedObject::new(ObjectType::Commit, b"hello".to_vec());
|
|
so.signatures.push(SignatureEntry {
|
|
public_key: [7u8; 32],
|
|
signature: [9u8; 64],
|
|
});
|
|
let bytes = so.serialize();
|
|
let so2 = SignedObject::parse(&bytes).unwrap();
|
|
assert_eq!(so.object_type, so2.object_type);
|
|
assert_eq!(so.body, so2.body);
|
|
assert_eq!(so.signatures, so2.signatures);
|
|
}
|
|
|
|
#[test]
|
|
fn unknown_type_rejected() {
|
|
let mut bytes = vec![0u8; HEADER_SIZE];
|
|
bytes[0..4].copy_from_slice(&MAGIC);
|
|
bytes[4] = 99;
|
|
bytes[5] = 1;
|
|
assert!(ObjectHeader::decode(&bytes).is_err());
|
|
}
|
|
}
|