LeVCS/crates/levcs-core/src/commit.rs

211 lines
6.9 KiB
Rust

//! Commit object body, per the v1.1 trust-root revision §2.2.
//!
//! Field Type Description
//! tree 32 bytes Hash of the root tree
//! parent_count 1 byte
//! parents 32*N bytes
//! authority 32 bytes Hash of the authority object in effect
//! author_key 32 bytes Public key (Ed25519) of the author
//! timestamp 8 bytes Unix microseconds, LE int64
//! flags 1 byte Bit 0: modifies authority
//! Bit 1: fork commit
//! message_len 4 bytes
//! message N bytes UTF-8
use byteorder::{ByteOrder, LittleEndian};
use crate::error::Error;
use crate::hash::ObjectId;
use crate::object::{ObjectType, SignatureEntry, SignedObject};
#[derive(Copy, Clone, Debug, Default, PartialEq, Eq)]
pub struct CommitFlags(pub u8);
impl CommitFlags {
pub const NONE: CommitFlags = CommitFlags(0);
pub const MODIFIES_AUTHORITY: CommitFlags = CommitFlags(0b01);
pub const FORK: CommitFlags = CommitFlags(0b10);
pub fn modifies_authority(self) -> bool {
self.0 & 0b01 != 0
}
pub fn is_fork(self) -> bool {
self.0 & 0b10 != 0
}
pub fn raw(self) -> u8 {
self.0
}
pub fn validate(self) -> Result<(), Error> {
if self.0 & !0b11 != 0 {
return Err(Error::MalformedObject(format!(
"commit flags has reserved bits set: {:#x}",
self.0
)));
}
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct Commit {
pub tree: ObjectId,
pub parents: Vec<ObjectId>,
pub authority: ObjectId,
pub author_key: [u8; 32],
pub timestamp_micros: i64,
pub flags: CommitFlags,
pub message: String,
}
impl Commit {
pub fn body(&self) -> Result<Vec<u8>, Error> {
if self.parents.len() > 255 {
return Err(Error::MalformedObject("too many parents".into()));
}
if self.message.len() > u32::MAX as usize {
return Err(Error::MalformedObject("message too large".into()));
}
self.flags.validate()?;
let mut out = Vec::with_capacity(
32 + 1 + self.parents.len() * 32 + 32 + 32 + 8 + 1 + 4 + self.message.len(),
);
out.extend_from_slice(self.tree.as_bytes());
out.push(self.parents.len() as u8);
for p in &self.parents {
out.extend_from_slice(p.as_bytes());
}
out.extend_from_slice(self.authority.as_bytes());
out.extend_from_slice(&self.author_key);
let mut ts = [0u8; 8];
LittleEndian::write_i64(&mut ts, self.timestamp_micros);
out.extend_from_slice(&ts);
out.push(self.flags.0);
let mut len = [0u8; 4];
LittleEndian::write_u32(&mut len, self.message.len() as u32);
out.extend_from_slice(&len);
out.extend_from_slice(self.message.as_bytes());
Ok(out)
}
pub fn parse_body(body: &[u8]) -> Result<Self, Error> {
let need = 32 + 1;
if body.len() < need {
return Err(Error::MalformedObject(
"commit body too short for tree+parent_count".into(),
));
}
let mut tree = [0u8; 32];
tree.copy_from_slice(&body[0..32]);
let parent_count = body[32] as usize;
let parents_end = 33 + parent_count * 32;
if body.len() < parents_end + 32 + 32 + 8 + 1 + 4 {
return Err(Error::MalformedObject("commit body truncated".into()));
}
let mut parents = Vec::with_capacity(parent_count);
for i in 0..parent_count {
let off = 33 + i * 32;
let mut p = [0u8; 32];
p.copy_from_slice(&body[off..off + 32]);
parents.push(ObjectId(p));
}
let mut p = parents_end;
let mut authority = [0u8; 32];
authority.copy_from_slice(&body[p..p + 32]);
p += 32;
let mut author_key = [0u8; 32];
author_key.copy_from_slice(&body[p..p + 32]);
p += 32;
let timestamp_micros = LittleEndian::read_i64(&body[p..p + 8]);
p += 8;
let flags = CommitFlags(body[p]);
flags.validate()?;
p += 1;
let msg_len = LittleEndian::read_u32(&body[p..p + 4]) as usize;
p += 4;
if body.len() < p + msg_len {
return Err(Error::MalformedObject("commit message truncated".into()));
}
let message = std::str::from_utf8(&body[p..p + msg_len])
.map_err(|_| Error::MalformedObject("commit message not UTF-8".into()))?
.to_string();
p += msg_len;
if p != body.len() {
return Err(Error::MalformedObject(format!(
"trailing {} byte(s) after commit message",
body.len() - p
)));
}
Ok(Self {
tree: ObjectId(tree),
parents,
authority: ObjectId(authority),
author_key,
timestamp_micros,
flags,
message,
})
}
pub fn into_signed(self) -> Result<SignedObject, Error> {
Ok(SignedObject::new(ObjectType::Commit, self.body()?))
}
/// Parse from a complete signed-object's components.
pub fn from_signed(s: &SignedObject) -> Result<Self, Error> {
if s.object_type != ObjectType::Commit {
return Err(Error::MalformedObject(format!(
"expected commit, got {}",
s.object_type.name()
)));
}
if s.signatures.len() != 1 {
return Err(Error::MalformedObject(format!(
"commit must have exactly 1 signature, got {}",
s.signatures.len()
)));
}
let c = Commit::parse_body(&s.body)?;
if c.author_key != s.signatures[0].public_key {
return Err(Error::MalformedObject(
"commit author_key disagrees with trailer signature key".into(),
));
}
Ok(c)
}
/// Convenience: produce a partial signature entry with just the key set;
/// callers fill in `signature` after computing the Ed25519 signature.
pub fn signature_template(&self) -> SignatureEntry {
SignatureEntry {
public_key: self.author_key,
signature: [0u8; 64],
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_body_roundtrip() {
let c = Commit {
tree: ObjectId([1; 32]),
parents: vec![ObjectId([2; 32]), ObjectId([3; 32])],
authority: ObjectId([4; 32]),
author_key: [5; 32],
timestamp_micros: 1_700_000_000_000_000,
flags: CommitFlags::NONE,
message: "hello world".into(),
};
let body = c.body().unwrap();
let c2 = Commit::parse_body(&body).unwrap();
assert_eq!(c.tree, c2.tree);
assert_eq!(c.parents, c2.parents);
assert_eq!(c.message, c2.message);
assert_eq!(c.flags.0, c2.flags.0);
}
}