use std::path::{Path, PathBuf};
use gix_error::{ErrorExt, Exn, Message, ResultExt, message};
use crate::{
BloomFilterSettings, File,
file::{
BASE_GRAPHS_LIST_CHUNK_ID, BLOOM_FILTER_DATA_CHUNK_ID, BLOOM_FILTER_HEADER_SIZE,
BLOOM_FILTER_INDEX_CHUNK_ID, COMMIT_DATA_CHUNK_ID, COMMIT_DATA_ENTRY_SIZE_SANS_HASH,
EXTENDED_EDGES_LIST_CHUNK_ID, FAN_LEN, GENERATION_DATA_CHUNK_ID,
GENERATION_DATA_OVERFLOW_CHUNK_ID, HEADER_LEN, OID_FAN_CHUNK_ID, OID_LOOKUP_CHUNK_ID,
SIGNATURE,
},
from_be_u32,
};
const MIN_FILE_SIZE: usize = HEADER_LEN
+ gix_chunk::file::Index::size_for_entries(3 /*OIDF, OIDL, CDAT*/)
+ FAN_LEN * 4 /* FANOUT TABLE CHUNK OIDF */
+ gix_hash::Kind::shortest().len_in_bytes();
impl File {
/// Try to parse the commit graph file at `path`.
pub fn at(path: impl AsRef<Path>) -> Result<File, Exn<Message>> {
Self::try_from(path.as_ref())
}
/// A lower-level constructor which constructs a new instance directly from the mapping in `data`,
/// assuming that it originated from `path`.
///
/// Note that `path` is only used for verification of the hash its basename contains, but otherwise
/// is not of importance.
pub fn new(data: memmap2::Mmap, path: PathBuf) -> Result<File, Exn<Message>> {
let data_size = data.len();
if data_size < MIN_FILE_SIZE {
return Err(message("Commit-graph file too small even for an empty graph").raise());
}
let mut ofs = 0;
if &data[ofs..ofs + SIGNATURE.len()] != SIGNATURE {
return Err(
message("Commit-graph file does not start with expected signature").raise(),
);
}
ofs += SIGNATURE.len();
match data[ofs] {
1 => (),
x => {
return Err(message!("Unsupported commit-graph file version: {x}").raise());
}
}
ofs += 1;
let object_hash = gix_hash::Kind::try_from(data[ofs]).map_err(|v| {
message!("Commit-graph file uses unsupported hash version: {v}").raise()
})?;
ofs += 1;
let chunk_count = data[ofs];
// Can assert chunk_count >= MIN_CHUNKS here, but later OIDF+OIDL+CDAT presence checks make
// it redundant.
ofs += 1;
let base_graph_count = data[ofs];
ofs += 1;
let chunks = gix_chunk::file::Index::from_bytes(&data, ofs, u32::from(chunk_count))
.or_raise(|| {
message!(
"Couldn't read commit-graph file with {chunk_count} chunks at offset {ofs}"
)
})?;
let base_graphs_list_offset = chunks
.validated_usize_offset_by_id(BASE_GRAPHS_LIST_CHUNK_ID, |chunk_range| {
let chunk_size = chunk_range.len();
if chunk_size % object_hash.len_in_bytes() != 0 {
return Err(message!("Commit-graph chunk {BASE_GRAPHS_LIST_CHUNK_ID:?} has invalid size: {msg}",
msg = format!(
"chunk size {} is not a multiple of {}",
chunk_size,
object_hash.len_in_bytes()
),
).raise());
}
let chunk_base_graph_count: u32 = (chunk_size / object_hash.len_in_bytes())
.try_into()
.expect("base graph count to fit in 32-bits");
if chunk_base_graph_count != u32::from(base_graph_count) {
return Err(message!("Commit-graph {BASE_GRAPHS_LIST_CHUNK_ID:?} chunk contains {chunk_base_graph_count} base graphs, but commit-graph file header claims {base_graph_count} base graphs").raise())
}
Ok(chunk_range.start)
})
.ok()
.transpose()?;
let (commit_data_offset, commit_data_count): (_, u32) = chunks
.validated_usize_offset_by_id(COMMIT_DATA_CHUNK_ID, |chunk_range| {
let chunk_size = chunk_range.len();
let entry_size = object_hash.len_in_bytes() + COMMIT_DATA_ENTRY_SIZE_SANS_HASH;
if chunk_size % entry_size != 0 {
return Err(message!("Commit-graph chunk {COMMIT_DATA_CHUNK_ID:?} has invalid size: chunk size {chunk_size} is not a multiple of {entry_size}").raise())
}
Ok((
chunk_range.start,
(chunk_size / entry_size)
.try_into()
.expect("number of commits in CDAT chunk to fit in 32 bits"),
))
})??;
let fan_offset = chunks
.validated_usize_offset_by_id(OID_FAN_CHUNK_ID, |chunk_range| {
let chunk_size = chunk_range.len();
let expected_size = 4 * FAN_LEN;
if chunk_size != expected_size {
return Err(message!("Commit-graph chunk {OID_FAN_CHUNK_ID:?} has invalid size: expected chunk length {expected_size}, got {chunk_size}").raise())
}
Ok(chunk_range.start)
})?
.or_raise(|| message("Error getting offset for OID fan chunk"))?;
let (oid_lookup_offset, oid_lookup_count): (_, u32) = chunks
.validated_usize_offset_by_id(OID_LOOKUP_CHUNK_ID, |chunk_range| {
let chunk_size = chunk_range.len();
if chunk_size % object_hash.len_in_bytes() != 0 {
return Err(message!("Commit-graph chunk {OID_LOOKUP_CHUNK_ID:?} has invalid size: chunk size {chunk_size} is not a multiple of {hash_len}", hash_len = object_hash.len_in_bytes()).raise())
}
Ok((
chunk_range.start,
(chunk_size / object_hash.len_in_bytes())
.try_into()
.expect("number of commits in OIDL chunk to fit in 32 bits"),
))
})?
.or_raise(|| message("Error getting offset for OID lookup chunk"))?;
let extra_edges_list_range = chunks.usize_offset_by_id(EXTENDED_EDGES_LIST_CHUNK_ID).ok();
// A wrongly sized generation data chunk is ignored instead of rejecting the file.
let generation_data_offset = chunks
.usize_offset_by_id(GENERATION_DATA_CHUNK_ID)
.ok()
.filter(|range| range.len() / 4 == commit_data_count as usize)
.map(|range| range.start);
// The overflow chunk is only checked when reading from it.
let generation_data_overflow_range = chunks
.usize_offset_by_id(GENERATION_DATA_OVERFLOW_CHUNK_ID)
.ok();
let bloom_filter_index_range = chunks.usize_offset_by_id(BLOOM_FILTER_INDEX_CHUNK_ID).ok();
let bloom_filter_data_range = chunks.usize_offset_by_id(BLOOM_FILTER_DATA_CHUNK_ID).ok();
let mut bloom_filter_data_len = 0usize;
let mut bloom_filter_data_offset = None;
let mut bloom_filter_index_offset = None;
let mut bloom_filter_settings = None;
if let (Some(index_range), Some(data_range)) = (
bloom_filter_index_range.as_ref(),
bloom_filter_data_range.as_ref(),
) {
let expected_index_chunk_size = commit_data_count as usize * std::mem::size_of::<u32>();
if index_range.len() == expected_index_chunk_size
&& data_range.len() >= BLOOM_FILTER_HEADER_SIZE
{
let settings = BloomFilterSettings {
hash_version: from_be_u32(&data[data_range.start..][..4]),
num_hashes: from_be_u32(&data[data_range.start + 4..][..4]),
bits_per_entry: from_be_u32(&data[data_range.start + 8..][..4]),
};
let bloom_data_payload_len = data_range.len() - BLOOM_FILTER_HEADER_SIZE;
if settings.is_supported() {
bloom_filter_settings = Some(settings);
bloom_filter_data_len = bloom_data_payload_len;
bloom_filter_data_offset = Some(data_range.start + BLOOM_FILTER_HEADER_SIZE);
bloom_filter_index_offset = Some(index_range.start);
}
}
}
let trailer = &data[chunks.highest_offset() as usize..];
if trailer.len() != object_hash.len_in_bytes() {
return Err(message!(
"Expected commit-graph trailer to contain {} bytes, got {}",
object_hash.len_in_bytes(),
trailer.len()
)
.raise());
}
if base_graph_count > 0 && base_graphs_list_offset.is_none() {
return Err(message!(
"Chunk named {BASE_GRAPHS_LIST_CHUNK_ID:?} was not found in chunk file index"
)
.into());
}
let (fan, _) = read_fan(&data[fan_offset..]);
if oid_lookup_count != fan[255] {
return Err(message!("Commit-graph {OID_FAN_CHUNK_ID:?} chunk contains {chunk1_commits} commits, but {OID_LOOKUP_CHUNK_ID:?} chunk contains {chunk2_commits} commits",
chunk1_commits = fan[255],
chunk2_commits = oid_lookup_count,
).raise());
}
if commit_data_count != fan[255] {
return Err(
message!("Commit-graph {OID_FAN_CHUNK_ID:?} chunk contains {chunk1_commits} commits, but {COMMIT_DATA_CHUNK_ID:?} chunk contains {chunk2_commits} commits",
chunk1_commits = fan[255],
chunk2_commits = commit_data_count,
).raise(),
);
}
Ok(File {
base_graph_count,
base_graphs_list_offset,
bloom_filter_data_len,
bloom_filter_data_offset,
bloom_filter_index_offset,
bloom_filter_settings,
commit_data_offset,
data,
extra_edges_list_range,
fan,
generation_data_offset,
generation_data_overflow_range,
read_generation_data: generation_data_offset.is_some(),
oid_lookup_offset,
path,
hash_len: object_hash.len_in_bytes(),
object_hash,
})
}
}
impl TryFrom<&Path> for File {
type Error = Exn<Message>;
fn try_from(path: &Path) -> Result<Self, Self::Error> {
let data = std::fs::File::open(path)
.and_then(|file| {
// SAFETY: we have to take the risk of somebody changing the file underneath. Git never writes into the same file.
#[allow(unsafe_code)]
unsafe {
memmap2::MmapOptions::new().map_copy_read_only(&file)
}
})
.or_raise(|| {
message!(
"Could not open commit-graph file at '{path}'",
path = path.display()
)
})?;
Self::new(data, path.to_owned())
}
}
// Copied from gix-odb/pack/index/init.rs
fn read_fan(d: &[u8]) -> ([u32; FAN_LEN], usize) {
assert!(d.len() >= FAN_LEN * 4);
let mut fan = [0; FAN_LEN];
for (c, f) in d.chunks_exact(4).zip(fan.iter_mut()) {
*f = u32::from_be_bytes(c.try_into().unwrap());
}
(fan, FAN_LEN * 4)
}