use std::fs::File;
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use crate::fetch::{Bytes, FetchError, FetchLimits, index_self_contained};
use crate::pack::index::{Index, verify_hash};
use crate::{ObjectFormat, ObjectId, ObjectReadError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct IndexRebuildLimits {
pub max_pack_bytes: usize,
pub max_objects: usize,
pub max_object_bytes: usize,
pub max_delta_bytes: usize,
pub max_decode_bytes: usize,
pub max_delta_depth: usize,
pub max_resolution_steps: usize,
}
impl IndexRebuildLimits {
#[must_use]
pub fn with_max_pack_bytes(mut self, max_pack_bytes: usize) -> Self {
self.max_pack_bytes = max_pack_bytes;
self
}
#[must_use]
pub fn with_max_objects(mut self, max_objects: usize) -> Self {
self.max_objects = max_objects;
self
}
#[must_use]
pub fn with_max_object_bytes(mut self, max_object_bytes: usize) -> Self {
self.max_object_bytes = max_object_bytes;
self
}
#[must_use]
pub fn with_max_delta_bytes(mut self, max_delta_bytes: usize) -> Self {
self.max_delta_bytes = max_delta_bytes;
self
}
#[must_use]
pub fn with_max_decode_bytes(mut self, max_decode_bytes: usize) -> Self {
self.max_decode_bytes = max_decode_bytes;
self
}
#[must_use]
pub fn with_max_delta_depth(mut self, max_delta_depth: usize) -> Self {
self.max_delta_depth = max_delta_depth;
self
}
#[must_use]
pub fn with_max_resolution_steps(mut self, max_resolution_steps: usize) -> Self {
self.max_resolution_steps = max_resolution_steps;
self
}
}
impl Default for IndexRebuildLimits {
fn default() -> Self {
let fetch = FetchLimits::default();
Self {
max_pack_bytes: fetch.max_pack_bytes,
max_objects: fetch.max_objects,
max_object_bytes: fetch.max_object_bytes,
max_delta_bytes: fetch.max_delta_bytes,
max_decode_bytes: fetch.max_decode_bytes,
max_delta_depth: fetch.max_delta_depth,
max_resolution_steps: fetch.max_resolution_steps,
}
}
}
impl IndexRebuildLimits {
fn fetch(self) -> FetchLimits {
FetchLimits {
max_pack_bytes: self.max_pack_bytes,
max_objects: self.max_objects,
max_object_bytes: self.max_object_bytes,
max_delta_bytes: self.max_delta_bytes,
max_decode_bytes: self.max_decode_bytes,
max_delta_depth: self.max_delta_depth,
max_resolution_steps: self.max_resolution_steps,
..FetchLimits::default()
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum IndexRebuildOutcome {
Created,
Replaced,
Unchanged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct IndexRebuilt {
pub checksum: ObjectId,
pub objects: usize,
pub index: PathBuf,
pub outcome: IndexRebuildOutcome,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum IndexRebuildError {
#[error("not a .pack path: {0}")]
NotPack(PathBuf),
#[error("unsupported pack version {0}")]
PackVersion(u32),
#[error("pack verification")]
Corrupt(#[source] ObjectReadError),
#[error("index rebuild limit exceeded: {0}")]
Limit(&'static str),
#[error("index rebuild cancelled")]
Cancelled,
#[error("index rebuild I/O")]
Io(#[source] io::Error),
#[error("rebuilt index published, but the pack directory sync failed")]
Unsynced {
rebuilt: Box<IndexRebuilt>,
#[source]
source: io::Error,
},
}
impl From<io::Error> for IndexRebuildError {
fn from(error: io::Error) -> Self {
Self::Io(error)
}
}
impl From<FetchError> for IndexRebuildError {
fn from(error: FetchError) -> Self {
match error {
FetchError::Pack(ObjectReadError::Limit(limit)) | FetchError::Limit(limit) => {
Self::Limit(limit)
}
FetchError::Pack(ObjectReadError::Cancelled) | FetchError::Cancelled => Self::Cancelled,
FetchError::Pack(ObjectReadError::PackVersion(version)) => Self::PackVersion(version),
FetchError::Pack(error) => Self::Corrupt(error),
FetchError::Io(error) => Self::Io(error),
FetchError::Index(_) => Self::Limit("index encoding"),
other => Self::Io(io::Error::other(other)),
}
}
}
pub fn rebuild_index(
pack: &Path,
format: ObjectFormat,
limits: IndexRebuildLimits,
cancel: &AtomicBool,
) -> Result<IndexRebuilt, IndexRebuildError> {
rebuild_with(pack, format, limits, cancel, &Publish::default())
}
struct Publish {
rename: fn(tempfile::NamedTempFile, &Path) -> io::Result<()>,
sync_directory: fn(&Path) -> io::Result<()>,
}
impl Default for Publish {
fn default() -> Self {
Self {
rename: |temporary, path| {
#[cfg(windows)]
if let Ok(metadata) = std::fs::metadata(path) {
let mut permissions = metadata.permissions();
if permissions.readonly() {
#[allow(clippy::permissions_set_readonly_false)]
permissions.set_readonly(false);
std::fs::set_permissions(path, permissions)?;
}
}
let temporary = temporary
.into_temp_path()
.keep()
.map_err(|error| error.error)?;
std::fs::rename(&temporary, path).inspect_err(|_| {
let _ = std::fs::remove_file(&temporary);
})
},
sync_directory: |directory| {
#[cfg(unix)]
File::open(directory)?.sync_all()?;
#[cfg(not(unix))]
let _ = directory;
Ok(())
},
}
}
}
const CHUNK: usize = 64 * 1024;
fn rebuild_with(
pack: &Path,
format: ObjectFormat,
limits: IndexRebuildLimits,
cancel: &AtomicBool,
publish: &Publish,
) -> Result<IndexRebuilt, IndexRebuildError> {
if pack.extension().is_none_or(|extension| extension != "pack") {
return Err(IndexRebuildError::NotPack(pack.into()));
}
check_cancelled(cancel)?;
let directory = pack
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let index_path = pack.with_extension("idx");
let mut source = File::open(pack)?;
let mode = source.metadata()?.permissions();
let mut copy = tempfile::Builder::new()
.prefix("tmp_pack_")
.tempfile_in(directory)?;
let mut copied = 0usize;
let mut buffer = vec![0; CHUNK];
loop {
check_cancelled(cancel)?;
let n = source.read(&mut buffer)?;
if n == 0 {
break;
}
copied += n;
if copied > limits.max_pack_bytes {
return Err(IndexRebuildError::Limit("pack bytes"));
}
copy.write_all(&buffer[..n])?;
}
let (index, checksum) =
index_self_contained(format, Bytes::File(copy, copied), limits.fetch(), cancel)?;
let parsed = Index::parse(format, &index, copied - format.digest_len())
.map_err(IndexRebuildError::Corrupt)?;
verify_hash(format, &index, "index checksum").map_err(IndexRebuildError::Corrupt)?;
debug_assert_eq!(parsed.pack_hash, checksum);
let mut rebuilt = IndexRebuilt {
checksum,
objects: parsed.len(),
index: index_path.clone(),
outcome: IndexRebuildOutcome::Unchanged,
};
rebuilt.outcome = match same_bytes(&index_path, &index, cancel)? {
Some(true) => return Ok(rebuilt),
Some(false) => IndexRebuildOutcome::Replaced,
None => IndexRebuildOutcome::Created,
};
let mut temporary = tempfile::Builder::new()
.prefix("tmp_idx_")
.tempfile_in(directory)?;
temporary.write_all(&index)?;
temporary.as_file().set_permissions(mode)?;
temporary.as_file().sync_all()?;
check_cancelled(cancel)?;
(publish.rename)(temporary, &index_path)?;
(publish.sync_directory)(directory).map_err(|source| IndexRebuildError::Unsynced {
rebuilt: Box::new(rebuilt.clone()),
source,
})?;
Ok(rebuilt)
}
fn check_cancelled(cancel: &AtomicBool) -> Result<(), IndexRebuildError> {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
Err(IndexRebuildError::Cancelled)
} else {
Ok(())
}
}
fn same_bytes(
path: &Path,
expected: &[u8],
cancel: &AtomicBool,
) -> Result<Option<bool>, IndexRebuildError> {
let mut file = match File::open(path) {
Ok(file) => file,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
if file.metadata()?.len() != expected.len() as u64 {
return Ok(Some(false));
}
let mut buffer = vec![0; CHUNK];
for chunk in expected.chunks(CHUNK) {
check_cancelled(cancel)?;
let actual = &mut buffer[..chunk.len()];
match file.read_exact(actual) {
Ok(()) if actual == chunk => {}
Ok(()) => return Ok(Some(false)),
Err(error) if error.kind() == io::ErrorKind::UnexpectedEof => return Ok(Some(false)),
Err(error) => return Err(error.into()),
}
}
Ok(Some(file.read(&mut [0])? == 0))
}
#[cfg(test)]
mod tests {
use std::fs;
use super::*;
use crate::{ObjectKind, PackObject, PackWriteLimits, write_pack};
fn damaged() -> (tempfile::TempDir, PathBuf, Vec<u8>, Vec<u8>) {
let directory = tempfile::tempdir().unwrap();
let data = b"rebuild me";
let object = PackObject {
id: ObjectId::for_blob(ObjectFormat::Sha1, data),
kind: ObjectKind::Blob,
data,
};
let (mut pack, mut index) = (Vec::new(), Vec::new());
write_pack(
ObjectFormat::Sha1,
&[object],
&mut pack,
&mut index,
PackWriteLimits::default(),
)
.unwrap();
let path = directory.path().join("pack-test.pack");
fs::write(&path, &pack).unwrap();
let mut bad = index.clone();
*bad.last_mut().unwrap() ^= 1;
fs::write(path.with_extension("idx"), &bad).unwrap();
(directory, path, index, bad)
}
fn names(directory: &Path) -> Vec<String> {
let mut names: Vec<_> = fs::read_dir(directory)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn rebuild(
path: &Path,
limits: IndexRebuildLimits,
publish: &Publish,
) -> Result<IndexRebuilt, IndexRebuildError> {
rebuild_with(
path,
ObjectFormat::Sha1,
limits,
&AtomicBool::new(false),
publish,
)
}
#[test]
fn failed_rename_leaves_the_old_index_and_no_temporary_files() {
let (directory, path, _, bad) = damaged();
let before = names(directory.path());
let publish = Publish {
rename: |_, _| Err(io::Error::other("injected rename failure")),
..Publish::default()
};
let result = rebuild(&path, IndexRebuildLimits::default(), &publish);
assert!(matches!(result, Err(IndexRebuildError::Io(_))));
assert_eq!(fs::read(path.with_extension("idx")).unwrap(), bad);
assert_eq!(names(directory.path()), before);
}
#[test]
fn failed_directory_sync_reports_the_published_index() {
let (_directory, path, good, _) = damaged();
let publish = Publish {
sync_directory: |_| Err(io::Error::other("injected sync failure")),
..Publish::default()
};
let result = rebuild(&path, IndexRebuildLimits::default(), &publish);
let Err(IndexRebuildError::Unsynced { rebuilt, .. }) = result else {
panic!("expected Unsynced, got {result:?}");
};
assert_eq!(rebuilt.outcome, IndexRebuildOutcome::Replaced);
assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
}
#[test]
fn pack_byte_limit_is_exact_and_stops_the_copy() {
let (directory, path, good, bad) = damaged();
let before = names(directory.path());
let len = fs::metadata(&path).unwrap().len() as usize;
let limits = |max_pack_bytes| IndexRebuildLimits {
max_pack_bytes,
..IndexRebuildLimits::default()
};
let refused = rebuild(&path, limits(len - 1), &Publish::default());
assert!(matches!(
refused,
Err(IndexRebuildError::Limit("pack bytes"))
));
assert_eq!(fs::read(path.with_extension("idx")).unwrap(), bad);
assert_eq!(names(directory.path()), before);
rebuild(&path, limits(len), &Publish::default()).unwrap();
assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
}
#[test]
fn oversized_damaged_index_is_replaced_without_reading_it() {
let (_directory, path, good, _) = damaged();
File::options()
.write(true)
.open(path.with_extension("idx"))
.unwrap()
.set_len(1 << 32)
.unwrap();
let rebuilt = rebuild(&path, IndexRebuildLimits::default(), &Publish::default()).unwrap();
assert_eq!(rebuilt.outcome, IndexRebuildOutcome::Replaced);
assert_eq!(fs::read(path.with_extension("idx")).unwrap(), good);
}
#[test]
fn index_comparison_is_bounded_by_the_expected_length() {
let (directory, _, good, _) = damaged();
let index = directory.path().join("compare.idx");
let longer = [good.as_slice(), b"x"].concat();
let cancel = AtomicBool::new(false);
fs::write(&index, &good).unwrap();
assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(true));
fs::write(&index, &longer).unwrap();
assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(false));
fs::write(&index, &good[..good.len() - 1]).unwrap();
assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), Some(false));
fs::remove_file(&index).unwrap();
assert_eq!(same_bytes(&index, &good, &cancel).unwrap(), None);
}
#[test]
fn version_3_pack_is_unsupported_not_corrupt() {
let directory = tempfile::tempdir().unwrap();
let mut pack = b"PACK\0\0\0\x03\0\0\0\0".to_vec();
let mut hasher = crate::object::Hasher::new(ObjectFormat::Sha1);
hasher.update(&pack);
pack.extend_from_slice(hasher.finalize().as_bytes());
let path = directory.path().join("pack-v3.pack");
fs::write(&path, &pack).unwrap();
let result = rebuild(&path, IndexRebuildLimits::default(), &Publish::default());
assert!(matches!(result, Err(IndexRebuildError::PackVersion(3))));
assert!(!path.with_extension("idx").exists());
}
}