use std::{
convert::TryInto,
fmt::{Display, Formatter, Write},
io::Write as IoWrite,
};
use bytes::{BufMut, Bytes, BytesMut};
use flate2::{write::ZlibEncoder, Compression};
use sha1::Digest;
use crate::{util::ArcOrCowStr, Error};
pub type HashOutput = [u8; 20];
pub struct PackFile<'a> {
entries: &'a [PackFileEntry],
}
impl<'a> PackFile<'a> {
#[must_use]
pub fn new(entries: &'a [PackFileEntry]) -> Self {
Self { entries }
}
#[must_use]
pub const fn header_size() -> usize {
"PACK".len() + std::mem::size_of::<u32>() + std::mem::size_of::<u32>()
}
#[must_use]
pub const fn footer_size() -> usize {
20
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(skip(self, original_buf), err)
)]
pub fn encode_to(&self, original_buf: &mut BytesMut) -> Result<(), Error> {
let mut buf = original_buf.split_off(original_buf.len());
buf.reserve(Self::header_size() + Self::footer_size());
buf.extend_from_slice(b"PACK");
buf.put_u32(2);
buf.put_u32(
self.entries
.len()
.try_into()
.map_err(Error::EntriesExceedsU32)?,
);
for entry in self.entries {
entry.encode_to(&mut buf)?;
}
buf.extend_from_slice(&sha1::Sha1::digest(&buf[..]));
original_buf.unsplit(buf);
Ok(())
}
}
#[derive(Debug, Clone)]
pub struct Commit {
pub tree: HashOutput,
pub author: CommitUserInfo,
pub committer: CommitUserInfo,
pub message: &'static str,
}
impl Commit {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, out), err))]
fn encode_to(&self, out: &mut BytesMut) -> Result<(), Error> {
let mut tree_hex = [0_u8; 20 * 2];
hex::encode_to_slice(self.tree, &mut tree_hex).map_err(Error::EncodeTreeHash)?;
out.write_str("tree ")?;
out.extend_from_slice(&tree_hex);
out.write_char('\n')?;
writeln!(out, "author {}", self.author)?;
writeln!(out, "committer {}", self.committer)?;
write!(out, "\n{}", self.message)?;
Ok(())
}
#[must_use]
pub fn size(&self) -> usize {
let mut len = 0;
len += "tree ".len() + (self.tree.len() * 2) + "\n".len();
len += "author ".len() + self.author.size() + "\n".len();
len += "committer ".len() + self.committer.size() + "\n".len();
len += "\n".len() + self.message.len();
len
}
}
#[derive(Clone, Copy, Debug)]
pub struct CommitUserInfo {
pub name: &'static str,
pub email: &'static str,
pub time: time::OffsetDateTime,
}
impl Display for CommitUserInfo {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{} <{}> {} +0000",
self.name,
self.email,
self.time.unix_timestamp()
)
}
}
impl CommitUserInfo {
#[must_use]
pub fn size(&self) -> usize {
let timestamp_len = itoa::Buffer::new().format(self.time.unix_timestamp()).len();
self.name.len()
+ "< ".len()
+ self.email.len()
+ "> ".len()
+ timestamp_len
+ " +0000".len()
}
}
#[derive(Debug, Copy, Clone)]
pub enum TreeItemKind {
File,
Directory,
}
impl TreeItemKind {
#[must_use]
pub const fn mode(&self) -> &'static str {
match self {
Self::File => "100644",
Self::Directory => "40000",
}
}
}
#[derive(Debug)]
pub struct TreeItem {
pub kind: TreeItemKind,
pub name: ArcOrCowStr,
pub hash: HashOutput,
pub sort_name: String,
}
impl TreeItem {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, out), err))]
fn encode_to(&self, out: &mut BytesMut) -> Result<(), Error> {
out.write_str(self.kind.mode())?;
write!(out, " {}\0", self.name)?;
out.extend_from_slice(&self.hash);
Ok(())
}
#[must_use]
pub fn size(&self) -> usize {
self.kind.mode().len() + " ".len() + self.name.len() + "\0".len() + self.hash.len()
}
}
#[derive(Debug)]
pub enum PackFileEntry {
Commit(Commit),
Tree(Vec<TreeItem>),
Blob(Bytes),
}
impl PackFileEntry {
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self, buf)))]
fn write_header(&self, buf: &mut BytesMut) {
let mut size = self.uncompressed_size();
{
let mut val = 0b1000_0000_u8;
val |= match self {
Self::Commit(_) => 0b001,
Self::Tree(_) => 0b010,
Self::Blob(_) => 0b011,
} << 4;
#[allow(clippy::cast_possible_truncation)]
{
val |= (size & 0b1111) as u8;
}
size >>= 4;
buf.put_u8(val);
}
loop {
#[allow(clippy::cast_possible_truncation)]
let mut val = (size & 0b111_1111) as u8;
size >>= 7;
if size != 0 {
val |= 1 << 7;
}
buf.put_u8(val);
if size == 0 {
break;
}
}
}
#[cfg_attr(
feature = "tracing",
tracing::instrument(skip(self, original_out), err)
)]
pub fn encode_to(&self, original_out: &mut BytesMut) -> Result<(), Error> {
self.write_header(original_out);
let mut out = BytesMut::new();
let size = self.uncompressed_size();
original_out.reserve(size);
out.reserve(size);
match self {
Self::Commit(commit) => {
commit.encode_to(&mut out)?;
}
Self::Tree(items) => {
for item in items {
item.encode_to(&mut out)?;
}
}
Self::Blob(data) => {
out.extend_from_slice(data);
}
}
debug_assert_eq!(out.len(), size);
let mut e = ZlibEncoder::new(Vec::new(), Compression::default());
e.write_all(&out).map_err(Error::CompressWrite)?;
let compressed_data = e.finish().map_err(Error::Compress)?;
original_out.extend_from_slice(&compressed_data);
Ok(())
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self)))]
#[must_use]
pub fn uncompressed_size(&self) -> usize {
match self {
Self::Commit(commit) => commit.size(),
Self::Tree(items) => items.iter().map(TreeItem::size).sum(),
Self::Blob(data) => data.len(),
}
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip(self), err))]
pub fn hash(&self) -> Result<HashOutput, Error> {
let size = self.uncompressed_size();
let file_prefix = match self {
Self::Commit(_) => "commit",
Self::Tree(_) => "tree",
Self::Blob(_) => "blob",
};
let size_len = itoa::Buffer::new().format(size).len();
let mut out =
BytesMut::with_capacity(file_prefix.len() + " ".len() + size_len + "\n".len() + size);
write!(out, "{file_prefix} {size}\0")?;
match self {
Self::Commit(commit) => {
commit.encode_to(&mut out)?;
}
Self::Tree(items) => {
for item in items {
item.encode_to(&mut out)?;
}
}
Self::Blob(blob) => {
out.extend_from_slice(blob);
}
}
Ok(sha1::Sha1::digest(&out).into())
}
}
#[cfg(test)]
mod test {
mod packfile {
use crate::low_level::{
Commit, CommitUserInfo, PackFile, PackFileEntry, TreeItem, TreeItemKind,
};
use bytes::{Bytes, BytesMut};
fn example() -> Bytes {
let blob = PackFileEntry::Blob(Bytes::from("hello world"));
let tree = PackFileEntry::Tree(vec![TreeItem {
kind: TreeItemKind::File,
name: "helloworld.txt".into(),
hash: blob.hash().unwrap(),
sort_name: "helloworld.txt".to_string(),
}]);
let commit = PackFileEntry::Commit(Commit {
tree: tree.hash().unwrap(),
author: CommitUserInfo {
name: "example",
email: "example@me.com",
time: time::OffsetDateTime::UNIX_EPOCH,
},
committer: CommitUserInfo {
name: "example",
email: "example@me.com",
time: time::OffsetDateTime::UNIX_EPOCH,
},
message: "initial commit",
});
let mut out = BytesMut::new();
PackFile::new(&[blob, tree, commit])
.encode_to(&mut out)
.unwrap();
out.freeze()
}
#[test]
fn snapshot() {
let actual = example();
insta::assert_debug_snapshot!(actual);
}
#[test]
fn is_readable_by_git() {
let stdout = crate::test::verify_pack_file(example());
insta::with_settings!({filters => vec![
(r"/(.*)/example.pack", "/path/to/example.pack")
]}, {
insta::assert_snapshot!(stdout);
});
}
}
mod packfile_entry {
use crate::low_level::PackFileEntry;
use bytes::{Bytes, BytesMut};
#[test]
fn header_size_bytes_large() {
let entry = PackFileEntry::Blob(Bytes::from(vec![0u8; 16]));
let mut header = BytesMut::new();
entry.write_header(&mut header);
assert_eq!(header.to_vec(), &[0xb0, 0x01]);
}
#[test]
fn header_size_bytes_small() {
let entry = PackFileEntry::Blob(Bytes::from(vec![0u8; 15]));
let mut header = BytesMut::new();
entry.write_header(&mut header);
assert_eq!(header.to_vec(), &[0xbf, 0x00]);
}
mod commit {
use crate::low_level::{Commit, CommitUserInfo, PackFileEntry};
use bytes::BytesMut;
fn example() -> PackFileEntry {
PackFileEntry::Commit(Commit {
tree: [0; 20],
author: CommitUserInfo {
name: "author",
email: "author@example.com",
time: time::OffsetDateTime::from_unix_timestamp(1_688_494_158).unwrap(),
},
committer: CommitUserInfo {
name: "committer",
email: "committer@example.com",
time: time::OffsetDateTime::from_unix_timestamp(1_687_494_158).unwrap(),
},
message: "hello world!",
})
}
#[test]
fn hash() {
let commit = example();
let actual = hex::encode(commit.hash().unwrap());
let expected = "0cc33510a70f7e9ad5f35738385d7ace25d0bbf4";
assert_eq!(actual, expected);
}
#[test]
fn uncompressed_size() {
let commit = example();
let actual = commit.uncompressed_size();
let expected = 172;
assert_eq!(actual, expected);
}
#[test]
fn headers() {
let commit = example();
let mut actual = BytesMut::new();
commit.write_header(&mut actual);
let expected = &[0x9c, 0x0a];
assert_eq!(actual.to_vec(), expected);
}
#[test]
fn full() {
let commit = example();
let mut actual = BytesMut::new();
commit.encode_to(&mut actual).unwrap();
insta::assert_debug_snapshot!(actual);
}
}
mod tree {
use crate::low_level::{PackFileEntry, TreeItem, TreeItemKind};
use bytes::BytesMut;
fn example() -> PackFileEntry {
PackFileEntry::Tree(vec![TreeItem {
kind: TreeItemKind::File,
name: "hello".into(),
hash: [0u8; 20],
sort_name: "/hello".to_string(),
}])
}
#[test]
fn hash() {
let commit = example();
let actual = hex::encode(commit.hash().unwrap());
let expected = "9fc911650c548e4aa7b6dfd085a9347df8743e17";
assert_eq!(actual, expected);
}
#[test]
fn uncompressed_size() {
let commit = example();
let actual = commit.uncompressed_size();
let expected = 33;
assert_eq!(actual, expected);
}
#[test]
fn headers() {
let commit = example();
let mut actual = BytesMut::new();
commit.write_header(&mut actual);
let expected = &[0xa1, 0x02];
assert_eq!(actual.to_vec(), expected);
}
#[test]
fn full() {
let commit = example();
let mut actual = BytesMut::new();
commit.encode_to(&mut actual).unwrap();
insta::assert_debug_snapshot!(actual);
}
}
mod blob {
use crate::low_level::PackFileEntry;
use bytes::{Bytes, BytesMut};
fn example() -> PackFileEntry {
PackFileEntry::Blob(Bytes::from("hello world"))
}
#[test]
fn hash() {
let commit = example();
let actual = hex::encode(commit.hash().unwrap());
let expected = "95d09f2b10159347eece71399a7e2e907ea3df4f";
assert_eq!(actual, expected);
}
#[test]
fn uncompressed_size() {
let commit = example();
let actual = commit.uncompressed_size();
let expected = 11;
assert_eq!(actual, expected);
}
#[test]
fn headers() {
let commit = example();
let mut actual = BytesMut::new();
commit.write_header(&mut actual);
let expected = &[0xbb, 0x00];
assert_eq!(actual.to_vec(), expected);
}
#[test]
fn full() {
let commit = example();
let mut actual = BytesMut::new();
commit.encode_to(&mut actual).unwrap();
insta::assert_debug_snapshot!(actual);
}
}
}
}