#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::path::PathBuf;
use super::store::{malformed, read_optional};
use super::{RefName, ReferenceError, References};
use crate::{ObjectId, Signature};
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReflogEntry {
pub old: ObjectId,
pub new: ObjectId,
pub committer: Signature,
pub message: Vec<u8>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ReflogRecord {
bytes: Vec<u8>,
entry: ReflogEntry,
}
impl ReflogRecord {
pub fn parse(format: crate::ObjectFormat, bytes: &[u8]) -> Result<Vec<Self>, ReferenceError> {
let entries = ReflogEntry::parse(format, bytes)?;
Ok(bytes
.split_inclusive(|b| *b == b'\n')
.zip(entries)
.map(|(bytes, entry)| Self {
bytes: bytes.to_vec(),
entry,
})
.collect())
}
pub fn as_bytes(&self) -> &[u8] {
&self.bytes
}
pub fn entry(&self) -> &ReflogEntry {
&self.entry
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum Reflog {
Preserve,
Delete,
Append {
committer: Signature,
message: Vec<u8>,
},
AppendIfChanged {
committer: Signature,
message: Vec<u8>,
},
AppendExistingIfChanged {
committer: Signature,
message: Vec<u8>,
},
}
impl Reflog {
pub(super) fn append_fields(&self) -> Option<(&Signature, &[u8])> {
match self {
Self::Append { committer, message }
| Self::AppendIfChanged { committer, message }
| Self::AppendExistingIfChanged { committer, message } => Some((committer, message)),
Self::Preserve | Self::Delete => None,
}
}
}
impl ReflogEntry {
pub fn parse(format: crate::ObjectFormat, bytes: &[u8]) -> Result<Vec<Self>, ReferenceError> {
parse(format, bytes, &std::path::PathBuf::from("<reflog>"))
}
pub(crate) fn encode(&self) -> Result<Vec<u8>, ReferenceError> {
self.new.require_format(self.old.format())?;
validate(&self.committer, &self.message)?;
let mut bytes = format!("{} {}", self.old, self.new).into_bytes();
self.committer.encode(b"", &mut bytes);
bytes.pop();
bytes.push(b'\t');
bytes.extend_from_slice(&self.message);
bytes.push(b'\n');
Ok(bytes)
}
}
impl References<'_> {
pub fn has_reflog(&self, name: &RefName) -> Result<bool, ReferenceError> {
if self.reference_backend == super::Backend::Reftable {
return Ok(super::reftable::backend::reflog(self, name)?.is_some());
}
let path = self.reflog_path(name)?;
super::store::check_path(&path)?;
match std::fs::symlink_metadata(&path) {
Ok(_) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(super::store::io_error(&path, error)),
}
}
pub fn reflog(&self, name: &RefName) -> Result<Option<Vec<ReflogEntry>>, ReferenceError> {
if self.reference_backend == super::Backend::Reftable {
return super::reftable::backend::reflog(self, name);
}
let path = self.reflog_path(name)?;
read_optional(&path)?
.map(|bytes| parse(self.object_format, &bytes, &path))
.transpose()
}
pub(crate) fn reflog_path(&self, name: &RefName) -> Result<PathBuf, ReferenceError> {
let path = self.path(name)?;
let root = if name.per_worktree() {
self.git_dir
} else {
self.common_dir
};
#[expect(
clippy::unwrap_used,
reason = "the reference path was joined onto this root"
)]
Ok(root.join("logs").join(path.strip_prefix(root).unwrap()))
}
}
pub(super) fn check_append_tail(path: &std::path::Path) -> Result<(), ReferenceError> {
use std::io::{Read, Seek, SeekFrom};
super::store::check_path(path)?;
let mut file = match std::fs::File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(error) => return Err(super::store::io_error(path, error)),
};
let size = file
.seek(SeekFrom::End(0))
.map_err(|error| super::store::io_error(path, error))?;
if size == 0 {
return Ok(());
}
file.seek(SeekFrom::End(-1))
.map_err(|error| super::store::io_error(path, error))?;
let mut last = [0];
file.read_exact(&mut last)
.map_err(|error| super::store::io_error(path, error))?;
if last[0] != b'\n' {
return Err(malformed(
path,
"unterminated reflog append tail; inspect and repair before retry",
));
}
Ok(())
}
pub(super) fn validate(committer: &Signature, message: &[u8]) -> Result<(), ReferenceError> {
if committer.name.contains(&b'\t') || committer.email.contains(&b'\t') {
return Err(ReferenceError::Unsupported("tab in reflog identity"));
}
if committer.seconds < 0 {
return Err(ReferenceError::Unsupported("negative reflog timestamp"));
}
committer
.validate()
.map_err(|_| ReferenceError::Unsupported("invalid reflog identity or date"))?;
if message.iter().any(|b| matches!(b, 0 | b'\r' | b'\n')) {
return Err(ReferenceError::Unsupported(
"multiline or NUL reflog message",
));
}
Ok(())
}
pub(super) fn parse(
format: crate::ObjectFormat,
bytes: &[u8],
path: &std::path::Path,
) -> Result<Vec<ReflogEntry>, ReferenceError> {
if bytes.is_empty() {
return Ok(Vec::new());
}
let body = bytes
.strip_suffix(b"\n")
.ok_or_else(|| malformed(path, "unterminated reflog record"))?;
body.split(|b| *b == b'\n')
.map(|line| parse_line(format, line, path))
.collect()
}
fn parse_line(
format: crate::ObjectFormat,
line: &[u8],
path: &std::path::Path,
) -> Result<ReflogEntry, ReferenceError> {
let width = format.digest_len() * 2;
if line.get(width) != Some(&b' ') || line.get(2 * width + 1) != Some(&b' ') {
return Err(malformed(path, "expected two repository-format reflog IDs"));
}
let parse_id = |bytes| {
std::str::from_utf8(bytes)
.ok()
.and_then(|s| ObjectId::from_hex(format, s).ok())
.ok_or_else(|| malformed(path, "invalid reflog ID"))
};
let field = |range: std::ops::Range<usize>| {
line.get(range)
.ok_or_else(|| malformed(path, "expected two repository-format reflog IDs"))
};
let old = parse_id(field(0..width)?)?;
let new = parse_id(field(width + 1..2 * width + 1)?)?;
let rest = field(2 * width + 2..line.len())?;
let (identity, message) = crate::bytes::split_once(rest, b'\t').unwrap_or((rest, &[]));
let date = crate::IdentityRef::parse(identity)
.map_err(|_| malformed(path, "invalid reflog identity"))?
.date_bytes;
let zone = date
.trim_ascii_start()
.splitn(2, |&b| b == b' ')
.nth(1)
.unwrap_or_default();
if !date.first().is_some_and(u8::is_ascii_whitespace)
|| !matches!(zone, [b'+' | b'-', digits @ ..]
if digits.len() == 4 && digits.iter().all(u8::is_ascii_digit))
{
return Err(malformed(path, "invalid reflog date framing"));
}
let committer = crate::IdentityRef::parse(identity)
.and_then(crate::IdentityRef::signature)
.map_err(|_| {
malformed(
path,
"reflog identity or numeric date exceeds reader limits",
)
})?;
let message = message.to_vec();
if identity.contains(&0)
|| identity.contains(&b'\r')
|| message.iter().any(|b| matches!(b, 0 | b'\r'))
{
return Err(malformed(path, "NUL or CR in reflog record"));
}
Ok(ReflogEntry {
old,
new,
committer,
message,
})
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn record(tail: &[u8]) -> Vec<u8> {
let mut bytes =
format!("{} {} ", ObjectId::Sha1([0; 20]), ObjectId::Sha1([1; 20])).into_bytes();
bytes.extend_from_slice(tail);
bytes
}
#[rstest]
#[case::truncated(b"A <a@b> 1 +0000\tmessage")]
#[case::cr_name(b"A\r <a@b> 1 +0000\tmessage\n")]
#[case::nul(b"A <a@b> 1 +0000\tx\0y\n")]
#[case::cr(b"A <a@b> 1 +0000\tx\ry\n")]
#[case::missing_separator(b"A <a@b> 1 +0000 message\n")]
#[case::sign_only_zone(b"A <a@b> 1 +\tmessage\n")]
#[case::short_zone(b"A <a@b> 1 +000\tmessage\n")]
#[case::text_zone(b"A <a@b> 1 +00x0\tmessage\n")]
#[case::no_identity(b"")]
fn rejects_malformed_records(#[case] tail: &[u8]) {
assert!(matches!(
ReflogEntry::parse(crate::ObjectFormat::Sha1, &record(tail)),
Err(ReferenceError::Malformed { .. })
));
}
#[test]
fn parses_byte_identity_and_message_without_utf8() {
let bytes = record(b"A\xff <a@b> 123 -0330\tm\xff\tend\n");
let entries = ReflogEntry::parse(crate::ObjectFormat::Sha1, &bytes).unwrap();
assert_eq!(entries[0].committer.name, b"A\xff");
assert_eq!(entries[0].committer.offset_minutes, -210);
assert_eq!(entries[0].encode().unwrap(), bytes);
}
#[test]
fn rejects_bad_ids() {
let mut bytes = record(b"A <a@b> 1 +0000\tm\n");
bytes[0] = b'z';
assert!(ReflogEntry::parse(crate::ObjectFormat::Sha1, &bytes).is_err());
}
}
#[cfg(test)]
mod format_tests {
use rstest::rstest;
use super::*;
use crate::ObjectFormat;
#[rstest]
#[case::sha1(ObjectFormat::Sha1, ObjectFormat::Sha256)]
#[case::sha256(ObjectFormat::Sha256, ObjectFormat::Sha1)]
fn null_records_use_the_selected_format(
#[case] format: ObjectFormat,
#[case] other: ObjectFormat,
) {
let bytes = format!(
"{} {} A <a@b> 1 +0000\tmessage\n",
ObjectId::null(format),
ObjectId::null(format)
);
let records = ReflogEntry::parse(format, bytes.as_bytes()).unwrap();
assert_eq!(records[0].old, ObjectId::null(format));
assert_eq!(records[0].new, ObjectId::null(format));
assert_eq!(records[0].encode().unwrap(), bytes.as_bytes());
assert!(ReflogEntry::parse(other, bytes.as_bytes()).is_err());
assert!(ReflogEntry::parse(format, b"").unwrap().is_empty());
let mixed = ReflogEntry {
new: ObjectId::null(other),
..records[0].clone()
};
assert!(matches!(
mixed.encode(),
Err(ReferenceError::ObjectFormat(_))
));
}
}