#![warn(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::indexing_slicing,
reason = "parses untrusted repository or network input (#103)"
)]
use std::fs::{self, File, OpenOptions};
use std::io::{self, Read, Write};
use std::path::{Path, PathBuf};
use thiserror::Error;
use super::{Entry, Error, Index, Limits, Timestamp};
use crate::{ObjectFormat, Repository};
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct IndexCommitOptions {
pub shared_permissions: crate::SharedPermissions,
pub sync: bool,
}
impl IndexCommitOptions {
#[must_use]
pub fn with_shared_permissions(mut self, shared_permissions: crate::SharedPermissions) -> Self {
self.shared_permissions = shared_permissions;
self
}
#[must_use]
pub fn with_sync(mut self, sync: bool) -> Self {
self.sync = sync;
self
}
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
#[non_exhaustive]
pub struct EditOptions {
pub resolve_split: bool,
pub follow_symlink: bool,
}
impl EditOptions {
#[must_use]
pub fn with_resolve_split(mut self, resolve_split: bool) -> Self {
self.resolve_split = resolve_split;
self
}
#[must_use]
pub fn with_follow_symlink(mut self, follow_symlink: bool) -> Self {
self.follow_symlink = follow_symlink;
self
}
}
#[derive(Debug)]
struct LeafSnapshot {
metadata: Option<fs::Metadata>,
target: Option<PathBuf>,
}
impl LeafSnapshot {
fn read(path: &Path) -> Result<Self, StorageError> {
let metadata = match fs::symlink_metadata(path) {
Ok(metadata) => Some(metadata),
Err(error) if error.kind() == io::ErrorKind::NotFound => None,
Err(error) => return Err(io_error("inspect index leaf", path, error)),
};
let target = if metadata.as_ref().is_some_and(|value| value.is_symlink()) {
Some(fs::read_link(path).map_err(|error| io_error("read index symlink", path, error))?)
} else {
None
};
Ok(Self { metadata, target })
}
fn check(&self, path: &Path) -> Result<(), StorageError> {
let current = Self::read(path)?;
let unchanged = match (&self.metadata, ¤t.metadata) {
(None, None) => true,
(Some(before), Some(after)) => {
let same_kind = before.file_type() == after.file_type();
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
same_kind && before.dev() == after.dev() && before.ino() == after.ino()
}
#[cfg(not(unix))]
{
same_kind
}
}
_ => false,
};
if !unchanged || self.target != current.target {
return Err(StorageError::Changed(path.into()));
}
Ok(())
}
}
#[derive(Debug)]
pub struct IndexEdit {
destination: PathBuf,
lock_path: PathBuf,
file: Option<crate::locks::SharedFile>,
original: Option<Vec<u8>>,
leaf: Option<LeafSnapshot>,
shared: Option<(PathBuf, Vec<u8>)>,
lock_identity: fs::Metadata,
cleanup: crate::locks::Guard,
index: Index,
limits: Limits,
published: bool,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum StorageError {
#[error("operation failed and lock cleanup also failed: {cleanup}")]
Cleanup {
#[source]
operation: Box<StorageError>,
cleanup: Box<StorageError>,
},
#[error("cannot {operation} {path}")]
Io {
operation: &'static str,
path: PathBuf,
#[source]
source: io::Error,
},
#[error("index lock already exists: {0}")]
Locked(PathBuf),
#[error("index changed while locked: {0}")]
Changed(PathBuf),
#[error("missing shared index: {0}")]
MissingShared(PathBuf),
#[error("index is not a regular file: {0}")]
NotRegular(PathBuf),
#[error("invalid index at {path}")]
Format {
path: PathBuf,
#[source]
source: Error,
},
#[error("new index published at {path}, but cannot remove owned lock {lock}")]
PublishedCleanup {
path: PathBuf,
lock: PathBuf,
#[source]
source: io::Error,
},
}
impl Repository {
pub fn read_index(&self, limits: Limits) -> Result<Option<Index>, StorageError> {
self.read_index_path(self.git_dir().join("index"), limits, true)
}
pub fn read_index_at(
&self,
path: impl AsRef<Path>,
limits: Limits,
) -> Result<Option<Index>, StorageError> {
let path = absolute_index_path(path.as_ref())?;
self.read_index_path(path, limits, false)
}
fn read_index_path(
&self,
path: PathBuf,
limits: Limits,
resolve_split: bool,
) -> Result<Option<Index>, StorageError> {
#[cfg(feature = "tracing")]
let span = tracing::debug_span!(
target: "girt",
"index.read_index",
outcome = "incomplete",
failure_class = tracing::field::Empty,
effects = tracing::field::Empty,
);
let operation = || {
read_bytes(&path, limits)?
.map(|bytes| {
parse_storage(self.object_format(), &path, &bytes, limits, resolve_split)
.map(|(index, _)| index)
})
.transpose()
};
#[cfg(feature = "tracing")]
let result = span.in_scope(operation);
#[cfg(not(feature = "tracing"))]
let result = { operation }();
#[cfg(feature = "tracing")]
crate::trace::finish(&span, &result, |error| crate::trace::index(error, &span));
result
}
pub fn edit_index(&self, limits: Limits) -> Result<IndexEdit, StorageError> {
self.edit_index_path(
self.git_dir().join("index"),
limits,
EditOptions {
resolve_split: true,
..EditOptions::default()
},
)
}
pub fn edit_index_at(
&self,
path: impl AsRef<Path>,
limits: Limits,
) -> Result<IndexEdit, StorageError> {
self.edit_index_at_with_options(path, limits, EditOptions::default())
}
pub fn edit_index_at_with_options(
&self,
path: impl AsRef<Path>,
limits: Limits,
options: EditOptions,
) -> Result<IndexEdit, StorageError> {
self.edit_index_path(absolute_index_path(path.as_ref())?, limits, options)
}
fn edit_index_path(
&self,
destination: PathBuf,
limits: Limits,
options: EditOptions,
) -> Result<IndexEdit, StorageError> {
IndexEdit::acquire(self.object_format(), destination, limits, options)
}
}
impl IndexEdit {
pub(crate) fn acquire(
format: ObjectFormat,
destination: PathBuf,
limits: Limits,
options: EditOptions,
) -> Result<IndexEdit, StorageError> {
let _lock_operation = crate::locks::operation();
let destination = absolute_index_path(&destination)?;
#[cfg(feature = "tracing")]
let span = tracing::debug_span!(
target: "girt",
"index.edit_index",
outcome = "incomplete",
failure_class = tracing::field::Empty,
effects = tracing::field::Empty,
);
let operation = || {
let mut lock_name = destination.as_os_str().to_os_string();
lock_name.push(".lock");
let lock_path = PathBuf::from(lock_name);
let file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
.map_err(|source| {
if source.kind() == io::ErrorKind::AlreadyExists {
StorageError::Locked(lock_path.clone())
} else {
io_error("create lock", &lock_path, source)
}
})?;
let lock_identity = file.metadata().map_err(|source| StorageError::Cleanup {
operation: Box::new(io_error("inspect acquired lock", &lock_path, source)),
cleanup: Box::new(io_error(
"identify lock for cleanup",
&lock_path,
io::Error::other("cannot safely remove lock without its file identity"),
)),
})?;
let file = crate::locks::SharedFile::from(file);
let cleanup = crate::locks::Guard::new(&lock_path, &file, lock_identity.clone());
let mut edit = IndexEdit {
destination,
lock_path,
lock_identity,
cleanup,
file: Some(file),
original: None,
leaf: None,
shared: None,
index: Index::empty(format),
limits,
published: false,
};
let result = (|| {
if options.follow_symlink {
edit.leaf = Some(LeafSnapshot::read(&edit.destination)?);
}
edit.original =
read_selected_bytes(&edit.destination, limits, options.follow_symlink)?;
if let Some(bytes) = &edit.original {
(edit.index, edit.shared) = parse_storage(
format,
&edit.destination,
bytes,
limits,
options.resolve_split,
)?;
}
if let Some(leaf) = &edit.leaf {
leaf.check(&edit.destination)?;
}
Ok(())
})();
if let Err(operation) = result {
return Err(with_cleanup(operation, edit.abort()));
}
Ok(edit)
};
#[cfg(feature = "tracing")]
let result = span.in_scope(operation);
#[cfg(not(feature = "tracing"))]
let result = { operation }();
#[cfg(feature = "tracing")]
crate::trace::finish(&span, &result, |error| crate::trace::index(error, &span));
result
}
}
impl IndexEdit {
pub fn index(&self) -> &Index {
&self.index
}
pub(crate) fn original_missing(&self) -> bool {
self.original.is_none()
&& self
.leaf
.as_ref()
.is_none_or(|leaf| leaf.metadata.is_none())
}
pub fn replace_entries(&mut self, entries: Vec<Entry>) -> Result<(), Error> {
self.index.replace_entries(entries, self.limits)
}
pub fn replace_index(&mut self, index: Index) -> Result<(), Error> {
crate::ObjectId::null(index.object_format()).require_format(self.index.object_format())?;
if index
.extensions()
.iter()
.any(|extension| extension.signature() == *b"link")
{
return Err(Error::ExtensionPreventsEdit(*b"link"));
}
index.encoded_len(self.limits)?;
self.index = index;
Ok(())
}
pub fn discard_optional_extensions(&mut self, signatures: &[[u8; 4]]) -> Result<(), Error> {
self.index
.discard_optional_extensions(signatures, self.limits)
}
pub fn expand_sparse(
&mut self,
objects: &crate::Objects,
limits: super::SparseLimits,
cancelled: &std::sync::atomic::AtomicBool,
) -> Result<(), super::SparseError> {
self.index
.expand_sparse(objects, self.limits, limits, cancelled)
}
pub fn replace_entries_reusing_stat(&mut self, mut entries: Vec<Entry>) -> Result<(), Error> {
let old = self.index.entries();
for entry in &mut entries {
let found = old.binary_search_by(|candidate| {
candidate
.path
.cmp(&entry.path)
.then(candidate.stage.cmp(&entry.stage))
});
if let Ok(position) = found
&& let Some(previous) = old.get(position)
&& previous.id == entry.id
&& previous.mode == entry.mode
&& previous.assume_valid == entry.assume_valid
&& previous.intent_to_add == entry.intent_to_add
&& previous.skip_worktree == entry.skip_worktree
{
entry.stat = previous.stat;
}
}
self.replace_entries(entries)
}
pub fn set_version(&mut self, version: super::Version) -> Result<(), Error> {
self.index.set_version(version, self.limits)
}
pub fn make_standalone(&mut self) -> Result<(), Error> {
self.index.make_standalone(self.limits)
}
pub fn invalidate_entry_offsets(&mut self) -> Result<(), Error> {
self.index.invalidate_entry_offsets(self.limits)
}
pub fn discard_resolve_undo(&mut self) -> Result<(), Error> {
self.index.discard_resolve_undo(self.limits)
}
pub fn invalidate_tree_cache(&mut self) -> Result<(), Error> {
self.index.invalidate_tree_cache(self.limits)
}
pub fn commit(self) -> Result<(), StorageError> {
self.commit_with_options(IndexCommitOptions::default())
}
pub fn commit_with_options(mut self, options: IndexCommitOptions) -> Result<(), StorageError> {
#[cfg(feature = "tracing")]
let span = tracing::debug_span!(
target: "girt",
"index.commit",
outcome = "incomplete",
failure_class = tracing::field::Empty,
effects = tracing::field::Empty,
);
let operation = || {
if let Err(operation) = self.publish_with_options(options) {
return Err(with_cleanup(operation, self.abort()));
}
Ok(())
};
#[cfg(feature = "tracing")]
let result = span.in_scope(operation);
#[cfg(not(feature = "tracing"))]
let result = { operation }();
#[cfg(feature = "tracing")]
crate::trace::finish(&span, &result, |error| crate::trace::index(error, &span));
result
}
pub(crate) fn commit_new_with_options(
self,
options: IndexCommitOptions,
) -> Result<(), StorageError> {
self.commit_new_with_cleanup(options, |path| fs::remove_file(path))
}
fn commit_new_with_cleanup(
mut self,
options: IndexCommitOptions,
remove_lock: impl FnOnce(&Path) -> io::Result<()>,
) -> Result<(), StorageError> {
let _lock_operation = crate::locks::operation();
let operation = (|| {
if !self.original_missing() {
return Err(StorageError::Changed(self.destination.clone()));
}
self.publish_with_policy(options, crate::file_policy::sync_file, |from, to| {
fs::hard_link(from, to)
})?;
remove_lock(&self.lock_path).map_err(|source| StorageError::PublishedCleanup {
path: self.destination.clone(),
lock: self.lock_path.clone(),
source,
})
})();
match operation {
Ok(()) => Ok(()),
Err(operation) if self.published => Err(operation),
Err(operation) => Err(with_cleanup(operation, self.abort())),
}
}
pub fn abort(mut self) -> Result<(), StorageError> {
let _lock_operation = crate::locks::operation();
#[cfg(feature = "tracing")]
let span = tracing::debug_span!(
target: "girt",
"index.abort",
outcome = "incomplete",
failure_class = tracing::field::Empty,
effects = tracing::field::Empty,
);
let operation = || {
self.published = true; self.check_lock_identity()?;
drop(self.file.take());
fs::remove_file(&self.lock_path)
.map_err(|source| io_error("remove owned index lock", &self.lock_path, source))
};
#[cfg(feature = "tracing")]
let result = span.in_scope(operation);
#[cfg(not(feature = "tracing"))]
let result = { operation }();
#[cfg(feature = "tracing")]
crate::trace::finish(&span, &result, |error| crate::trace::index(error, &span));
result
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn discard_checkout_caches(&mut self) {
self.index.discard_checkout_caches();
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn publish(&mut self) -> Result<(), StorageError> {
self.publish_with_options(IndexCommitOptions::default())
}
fn publish_with_options(&mut self, options: IndexCommitOptions) -> Result<(), StorageError> {
self.publish_with_policy(options, crate::file_policy::sync_file, |from, to| {
fs::rename(from, to)
})
}
#[cfg(test)]
fn publish_with_rename(
&mut self,
rename: impl FnOnce(&Path, &Path) -> io::Result<()>,
) -> Result<(), StorageError> {
self.publish_with_policy(
IndexCommitOptions::default(),
crate::file_policy::sync_file,
rename,
)
}
fn publish_with_policy(
&mut self,
options: IndexCommitOptions,
sync: impl FnOnce(&File) -> io::Result<()>,
rename: impl FnOnce(&Path, &Path) -> io::Result<()>,
) -> Result<(), StorageError> {
let _lock_operation = crate::locks::operation();
options
.shared_permissions
.validate()
.map_err(|source| io_error("validate index permissions", &self.lock_path, source))?;
let now = std::time::SystemTime::now();
let previous = fs::metadata(&self.destination)
.and_then(|metadata| metadata.modified())
.ok();
let threshold = previous.map_or(now, |previous| previous.min(now));
let threshold = threshold.duration_since(std::time::UNIX_EPOCH).map_or(
Timestamp::default(),
|duration| Timestamp {
seconds: u32::try_from(duration.as_secs()).unwrap_or(u32::MAX),
nanoseconds: duration.subsec_nanos(),
},
);
self.index.smudge_racy_entries(threshold);
let bytes = self
.index
.encode(self.limits)
.map_err(|source| StorageError::Format {
path: self.destination.clone(),
source,
})?;
self.check_original()?;
self.write_lock(&bytes)?;
#[expect(
clippy::expect_used,
reason = "the guard holds its file until publication"
)]
let file = self.file.as_ref().expect("unpublished guard owns its file");
options
.shared_permissions
.apply_file(file)
.map_err(|source| io_error("set index permissions", &self.lock_path, source))?;
if options.sync {
sync(file)
.map_err(|source| io_error("synchronize index lock", &self.lock_path, source))?;
}
self.check_original()?;
self.check_lock_identity()?;
drop(self.file.take());
rename(&self.lock_path, &self.destination)
.map_err(|source| io_error("replace index", &self.destination, source))?;
self.published = true;
Ok(())
}
fn check_lock_identity(&self) -> Result<(), StorageError> {
self.cleanup
.check()
.map_err(|source| io_error("check lock registration", &self.lock_path, source))?;
let current = fs::symlink_metadata(&self.lock_path)
.map_err(|source| io_error("inspect owned index lock", &self.lock_path, source))?;
if !current.is_file() {
return Err(StorageError::Changed(self.lock_path.clone()));
}
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let owned = &self.lock_identity;
if current.dev() != owned.dev() || current.ino() != owned.ino() {
return Err(StorageError::Changed(self.lock_path.clone()));
}
}
#[cfg(not(unix))]
let _ = &self.lock_identity;
Ok(())
}
fn check_original(&self) -> Result<(), StorageError> {
if let Some(leaf) = &self.leaf {
leaf.check(&self.destination)?;
}
if read_selected_bytes(&self.destination, self.limits, self.leaf.is_some())?
!= self.original
{
return Err(StorageError::Changed(self.destination.clone()));
}
if let Some((path, bytes)) = &self.shared
&& read_bytes(path, self.limits)?.as_ref() != Some(bytes)
{
return Err(StorageError::Changed(path.clone()));
}
Ok(())
}
fn write_lock(&mut self, bytes: &[u8]) -> Result<(), StorageError> {
#[expect(
clippy::expect_used,
reason = "the guard holds its file until publication"
)]
let file = self.file.as_mut().expect("unpublished guard owns its file");
file.write_all(bytes)
.map_err(|source| io_error("write lock", &self.lock_path, source))?;
file.flush()
.map_err(|source| io_error("flush lock", &self.lock_path, source))?;
Ok(())
}
}
impl Drop for IndexEdit {
fn drop(&mut self) {
let _lock_operation = crate::locks::operation();
if !self.published && self.check_lock_identity().is_ok() {
drop(self.file.take());
let _ = fs::remove_file(&self.lock_path);
}
}
}
fn absolute_index_path(path: &Path) -> Result<PathBuf, StorageError> {
std::path::absolute(path).map_err(|source| io_error("resolve index path", path, source))
}
fn read_bytes(path: &Path, limits: Limits) -> Result<Option<Vec<u8>>, StorageError> {
read_selected_bytes(path, limits, false)
}
fn read_selected_bytes(
path: &Path,
limits: Limits,
follow_symlink: bool,
) -> Result<Option<Vec<u8>>, StorageError> {
let metadata = match if follow_symlink {
fs::metadata(path)
} else {
fs::symlink_metadata(path)
} {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => return Err(io_error("inspect index", path, source)),
};
if !metadata.is_file() {
return Err(StorageError::NotRegular(path.into()));
}
let file = File::open(path).map_err(|source| io_error("open index", path, source))?;
let mut bytes = Vec::new();
let limit = (limits.max_bytes as u64).saturating_add(1);
file.take(limit)
.read_to_end(&mut bytes)
.map_err(|source| io_error("read index", path, source))?;
if bytes.len() > limits.max_bytes {
return Err(StorageError::Format {
path: path.into(),
source: Error::Limit("bytes"),
});
}
Ok(Some(bytes))
}
type SharedFile = Option<(PathBuf, Vec<u8>)>;
fn parse_storage(
format: crate::ObjectFormat,
path: &Path,
bytes: &[u8],
limits: Limits,
resolve_split: bool,
) -> Result<(Index, SharedFile), StorageError> {
let contextual = |source| StorageError::Format {
path: path.into(),
source,
};
let index = Index::parse_file(format, bytes, limits).map_err(contextual)?;
if !resolve_split
&& index
.extensions()
.iter()
.any(|extension| extension.signature() == *b"link")
{
return Err(contextual(Error::MandatoryExtension(*b"link")));
}
let shared = if let Some(id) = index.shared_index_id() {
let shared_path = path.with_file_name(format!("sharedindex.{id}"));
let remaining = Limits {
max_bytes: limits.max_bytes.saturating_sub(bytes.len()),
..limits
};
let shared_bytes = read_bytes(&shared_path, remaining)?
.ok_or_else(|| StorageError::MissingShared(shared_path.clone()))?;
Some((shared_path, shared_bytes))
} else {
None
};
let index = index
.resolve_shared(shared.as_ref().map(|(_, bytes)| bytes.as_slice()), limits)
.map_err(contextual)?;
Ok((index, shared))
}
fn io_error(operation: &'static str, path: &Path, source: io::Error) -> StorageError {
StorageError::Io {
operation,
path: path.into(),
source,
}
}
#[cfg(test)]
#[allow(clippy::unreachable, reason = "tests assert on unexpected variants")]
mod tests;
fn with_cleanup(operation: StorageError, cleanup: Result<(), StorageError>) -> StorageError {
match cleanup {
Ok(()) => operation,
Err(cleanup) => StorageError::Cleanup {
operation: Box::new(operation),
cleanup: Box::new(cleanup),
},
}
}
#[cfg(test)]
#[allow(clippy::unreachable, reason = "tests assert on unexpected variants")]
mod options_tests;