use std::ops::Range;
use super::CommitError;
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct CommitPayload<'a> {
bytes: &'a [u8],
headers: Vec<Range<usize>>,
message_start: usize,
}
impl<'a> CommitPayload<'a> {
pub fn parse(bytes: &'a [u8]) -> Result<Self, CommitError> {
let mut headers: Vec<Range<usize>> = Vec::new();
let mut start = 0;
for line in bytes.split_inclusive(|&byte| byte == b'\n') {
if !line.ends_with(b"\n") {
return Err(CommitError::MissingSeparator);
}
let end = start + line.len();
if line == b"\n" {
return Ok(Self {
bytes,
headers,
message_start: end,
});
}
if line.starts_with(b" ") {
headers.last_mut().ok_or(CommitError::InvalidHeader)?.end = end;
} else {
let space = line
.iter()
.position(|&byte| byte == b' ')
.ok_or(CommitError::InvalidHeader)?;
if space == 0
|| !line[..space]
.iter()
.all(|byte| (b'!'..=b'~').contains(byte))
{
return Err(CommitError::InvalidHeader);
}
headers.push(start..end);
}
start = end;
}
Err(CommitError::MissingSeparator)
}
pub fn from_bytes(bytes: &'a [u8]) -> Self {
let mut headers: Vec<Range<usize>> = Vec::new();
let mut start = 0;
for line in bytes.split_inclusive(|&b| b == b'\n') {
let end = start + line.len();
if line == b"\n" {
return Self {
bytes,
headers,
message_start: end,
};
}
if line.starts_with(b" ") && !headers.is_empty() {
headers.last_mut().expect("nonempty").end = end;
} else {
headers.push(start..end);
}
start = end;
}
Self {
bytes,
headers,
message_start: bytes.len(),
}
}
pub fn as_bytes(&self) -> &'a [u8] {
self.bytes
}
pub fn headers(&self) -> impl ExactSizeIterator<Item = CommitHeaderRef<'a>> + '_ {
self.headers.iter().map(|span| {
let raw = &self.bytes[span.clone()];
let line = raw.strip_suffix(b"\n").unwrap_or(raw);
let first = line.split(|&b| b == b'\n').next().unwrap_or_default();
let space = first.iter().position(|&b| b == b' ');
match space {
Some(space) => CommitHeaderRef {
name: &line[..space],
value: &line[space + 1..],
has_value_separator: true,
},
None => CommitHeaderRef {
name: first,
value: &[],
has_value_separator: false,
},
}
})
}
pub fn message(&self) -> &'a [u8] {
&self.bytes[self.message_start..]
}
pub fn without_headers(&self, indices: &[usize]) -> Option<Vec<u8>> {
let mut indices = indices.to_vec();
indices.sort_unstable();
indices.dedup();
if indices
.last()
.is_some_and(|&index| index >= self.headers.len())
{
return None;
}
let removed: usize = indices.iter().map(|&index| self.headers[index].len()).sum();
let mut output = Vec::with_capacity(self.bytes.len() - removed);
let mut start = 0;
for index in indices {
let span = &self.headers[index];
output.extend_from_slice(&self.bytes[start..span.start]);
start = span.end;
}
output.extend_from_slice(&self.bytes[start..]);
Some(output)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct CommitHeaderRef<'a> {
pub has_value_separator: bool,
pub name: &'a [u8],
pub value: &'a [u8],
}
impl CommitHeaderRef<'_> {
pub fn unfolded_value(&self) -> Vec<u8> {
let mut output = Vec::with_capacity(self.value.len());
for (index, line) in self.value.split(|&byte| byte == b'\n').enumerate() {
if index == 0 {
output.extend_from_slice(line);
} else {
output.push(b'\n');
output.extend_from_slice(line.strip_prefix(b" ").unwrap_or(line));
}
}
output
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
#[rstest]
#[case::first(0, b"gpgsig-sha256 two\ngpgsig three\n\nbody\0")]
#[case::middle(1, b"gpgsig one\n folded\ngpgsig three\n\nbody\0")]
#[case::last(2, b"gpgsig one\n folded\ngpgsig-sha256 two\n\nbody\0")]
fn removes_only_selected_span(#[case] index: usize, #[case] expected: &[u8]) {
let bytes = b"gpgsig one\n folded\ngpgsig-sha256 two\ngpgsig three\n\nbody\0";
let payload = CommitPayload::parse(bytes).unwrap();
assert_eq!(payload.without_headers(&[index]).unwrap(), expected);
assert_eq!(payload.as_bytes(), bytes);
assert_eq!(payload.message(), b"body\0");
assert_eq!(payload.without_headers(&[3]), None);
}
#[test]
fn removes_selected_set_in_payload_order() {
let bytes = b"gpgsig one\nx keep\ngpgsig-sha256 two\n\nbody";
let payload = CommitPayload::parse(bytes).unwrap();
assert_eq!(
payload.without_headers(&[2, 0, 2]).unwrap(),
b"x keep\n\nbody"
);
assert_eq!(payload.without_headers(&[]).unwrap(), bytes);
assert_eq!(payload.without_headers(&[0, usize::MAX]), None);
assert_eq!(payload.as_bytes(), bytes);
}
#[test]
fn unfolds_only_framing_spaces() {
let payload = CommitPayload::parse(b"gpgsig \xff\n \n x\n \n\n").unwrap();
let header = payload.headers().next().unwrap();
assert_eq!(header.value, b"\xff\n \n x\n ");
assert_eq!(header.unfolded_value(), b"\xff\n\n x\n");
assert_eq!(payload.without_headers(&[0]).unwrap(), b"\n");
}
#[rstest]
#[case::empty(b"\nbody")]
#[case::reordered(b"committer anything\nauthor missing date\nauthor duplicate\n\n")]
#[case::opaque(b"author \xff\0\r\n continuation\n\n\xff")]
fn retains_uninterpreted_headers(#[case] bytes: &[u8]) {
assert_eq!(CommitPayload::parse(bytes).unwrap().as_bytes(), bytes);
}
#[rstest]
#[case::orphan(b" continuation\n\n", CommitError::InvalidHeader)]
#[case::no_space(b"gpgsig\n\n", CommitError::InvalidHeader)]
#[case::tab(b"\tcontinued\n\n", CommitError::InvalidHeader)]
#[case::bad_name(b"gpg\0sig x\n\n", CommitError::InvalidHeader)]
#[case::truncated(b"gpgsig x\n continuation", CommitError::MissingSeparator)]
#[case::missing_separator(b"gpgsig x\n", CommitError::MissingSeparator)]
#[case::empty(b"", CommitError::MissingSeparator)]
fn rejects_unframed_payloads(#[case] bytes: &[u8], #[case] error: CommitError) {
assert_eq!(CommitPayload::parse(bytes), Err(error));
}
}