use std::fs::File;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use crate::error::{Error, Result};
use crate::oid::Oid;
use crate::store::{LfsObject, LfsStore, PendingWrite};
pub const LFS_DIR: &str = "lfs";
#[derive(Debug, Clone)]
pub struct FsLfsStore {
root: PathBuf,
}
static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
impl FsLfsStore {
pub fn open_or_init(git_dir: impl AsRef<Path>) -> Result<Self> {
let root = git_dir.as_ref().join(LFS_DIR);
for dir in ["objects", "incomplete"] {
std::fs::create_dir_all(root.join(dir)).map_err(|err| {
Error::store_io(
format!("could not create {}", root.join(dir).display()),
err,
)
})?;
}
Ok(FsLfsStore { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn path_of(&self, oid: &Oid) -> PathBuf {
let hex = oid.to_hex();
self.root
.join("objects")
.join(oid.hex_prefix(2))
.join(&hex[2..4])
.join(&hex)
}
}
impl LfsStore for FsLfsStore {
fn delete(&self, oid: &Oid) -> Result<bool> {
let path = self.path_of(oid);
match std::fs::remove_file(&path) {
Ok(()) => {
if let Some(parent) = path.parent() {
sync_dir(parent)?;
}
Ok(true)
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(err) => Err(Error::store_io(format!("remove {oid}"), err)),
}
}
fn size_of(&self, oid: &Oid) -> Result<Option<u64>> {
match std::fs::metadata(self.path_of(oid)) {
Ok(meta) => Ok(Some(meta.len())),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(Error::store_io(format!("stat {oid}"), err)),
}
}
fn open(&self, oid: &Oid) -> Result<Option<LfsObject>> {
let path = self.path_of(oid);
let file = match File::open(&path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(Error::store_io(format!("open {oid}"), err)),
};
let size = file
.metadata()
.map_err(|err| Error::store_io(format!("stat {oid}"), err))?
.len();
Ok(Some(LfsObject {
size,
reader: Box::new(file),
}))
}
fn open_at(&self, oid: &Oid, offset: u64) -> Result<Option<LfsObject>> {
use std::io::Seek as _;
let path = self.path_of(oid);
let mut file = match File::open(&path) {
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(Error::store_io(format!("open {oid}"), err)),
};
let size = file
.metadata()
.map_err(|err| Error::store_io(format!("stat {oid}"), err))?
.len();
file.seek(std::io::SeekFrom::Start(offset.min(size)))
.map_err(|err| Error::store_io(format!("seek to {offset} in {oid}"), err))?;
Ok(Some(LfsObject {
size,
reader: Box::new(file),
}))
}
fn begin_write(&self, oid: &Oid, _declared_size: u64) -> Result<Box<dyn PendingWrite>> {
let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let temp = self
.root
.join("incomplete")
.join(format!("{}-{seq}.tmp", std::process::id()));
let file = File::create(&temp)
.map_err(|err| Error::store_io(format!("create {}", temp.display()), err))?;
Ok(Box::new(FsPendingWrite {
final_path: self.path_of(oid),
temp: Some(temp),
file: Some(file),
}))
}
}
struct FsPendingWrite {
final_path: PathBuf,
temp: Option<PathBuf>,
file: Option<File>,
}
impl Write for FsPendingWrite {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
match self.file.as_mut() {
Some(file) => file.write(data),
None => Err(std::io::Error::other("write after commit")),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self.file.as_mut() {
Some(file) => file.flush(),
None => Ok(()),
}
}
}
impl PendingWrite for FsPendingWrite {
fn commit(mut self: Box<Self>) -> Result<()> {
let mut file = self
.file
.take()
.ok_or_else(|| Error::Store("commit called twice".to_string()))?;
file.flush().map_err(|err| Error::store_io("flush", err))?;
file.sync_all()
.map_err(|err| Error::store_io("fsync", err))?;
drop(file);
let temp = self
.temp
.take()
.ok_or_else(|| Error::Store("commit called twice".to_string()))?;
let mut to_sync: Vec<PathBuf> = Vec::new();
if let Some(parent) = self.final_path.parent() {
to_sync.push(parent.to_path_buf());
let mut dir = parent;
while !dir.exists() {
match dir.parent() {
Some(up) => {
to_sync.push(up.to_path_buf());
dir = up;
}
None => break,
}
}
std::fs::create_dir_all(parent)
.map_err(|err| Error::store_io(format!("create {}", parent.display()), err))?;
}
std::fs::rename(&temp, &self.final_path).map_err(|err| {
Error::store_io(
format!("rename {} → {}", temp.display(), self.final_path.display()),
err,
)
})?;
for dir in &to_sync {
sync_dir(dir)?;
}
Ok(())
}
}
impl Drop for FsPendingWrite {
fn drop(&mut self) {
self.file.take();
if let Some(temp) = self.temp.take() {
drop_incomplete(&temp);
}
}
}
fn drop_incomplete(temp: &Path) {
match std::fs::remove_file(temp) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => tracing::error!(
target: crate::LFS_TARGET,
path = %temp.display(),
%err,
"an abandoned LFS upload could not be removed; it is still occupying its full size \
under a name no oid resolves to, and nothing else sweeps this directory"
),
}
}
fn sync_dir(dir: &Path) -> Result<()> {
#[cfg(test)]
if let Some(err) = dir_sync_seam::on_sync(dir) {
return Err(Error::store_io(format!("fsync {}", dir.display()), err));
}
File::open(dir)
.and_then(|d| d.sync_all())
.map_err(|err| Error::store_io(format!("fsync {}", dir.display()), err))
}
#[cfg(test)]
pub(crate) mod dir_sync_seam {
use std::cell::RefCell;
use std::path::{Path, PathBuf};
thread_local! {
static FAIL: RefCell<bool> = const { RefCell::new(false) };
static SYNCED: RefCell<Vec<PathBuf>> = const { RefCell::new(Vec::new()) };
}
pub(crate) fn fail_from_now() {
FAIL.with(|f| *f.borrow_mut() = true);
}
pub(crate) fn synced() -> Vec<PathBuf> {
SYNCED.with(|s| s.borrow().clone())
}
pub(crate) fn on_sync(dir: &Path) -> Option<std::io::Error> {
SYNCED.with(|s| s.borrow_mut().push(dir.to_path_buf()));
FAIL.with(|f| *f.borrow())
.then(|| std::io::Error::other("injected directory fsync failure"))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (tempfile::TempDir, FsLfsStore) {
let dir = tempfile::tempdir().expect("tempdir");
let store = FsLfsStore::open_or_init(dir.path().join("repo.git")).expect("store");
(dir, store)
}
#[test]
fn an_object_lands_under_the_git_dir_in_the_fan_out_layout() {
let (dir, store) = store();
let oid =
Oid::parse("e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855").unwrap();
let path = store.path_of(&oid);
assert!(path.starts_with(dir.path().join("repo.git").join("lfs").join("objects")));
assert!(path.to_string_lossy().contains("/e3/b0/e3b0c442"));
}
#[test]
fn bytes_are_stored_raw_and_uncompressed() {
let (_dir, store) = store();
let payload = b"the quick brown fox".repeat(100);
let oid = {
let mut hasher = crate::oid::Sha256Stream::new();
hasher.update(&payload);
hasher.finish()
};
let mut pending = store.begin_write(&oid, payload.len() as u64).unwrap();
pending.write_all(&payload).unwrap();
pending.commit().unwrap();
let on_disk = std::fs::read(store.path_of(&oid)).unwrap();
assert_eq!(on_disk, payload);
assert_eq!(store.size_of(&oid).unwrap(), Some(payload.len() as u64));
}
#[test]
fn a_dropped_upload_leaves_nothing_visible_and_nothing_behind() {
let (_dir, store) = store();
let oid = Oid::parse(&"d".repeat(64)).unwrap();
{
let mut pending = store.begin_write(&oid, 5).unwrap();
pending.write_all(b"partl").unwrap();
}
assert!(store.open(&oid).unwrap().is_none());
let incomplete: Vec<_> = std::fs::read_dir(store.root().join("incomplete"))
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert!(
incomplete.is_empty(),
"a dropped upload left {incomplete:?} behind"
);
}
#[test]
fn two_concurrent_uploads_of_the_same_oid_do_not_collide() {
let (_dir, store) = store();
let payload = b"same bytes".to_vec();
let oid = {
let mut hasher = crate::oid::Sha256Stream::new();
hasher.update(&payload);
hasher.finish()
};
let mut a = store.begin_write(&oid, payload.len() as u64).unwrap();
let mut b = store.begin_write(&oid, payload.len() as u64).unwrap();
a.write_all(&payload).unwrap();
b.write_all(&payload).unwrap();
a.commit().unwrap();
b.commit().unwrap();
assert_eq!(std::fs::read(store.path_of(&oid)).unwrap(), payload);
}
fn committed(store: &FsLfsStore, payload: &[u8]) -> Oid {
let oid = {
let mut hasher = crate::oid::Sha256Stream::new();
hasher.update(payload);
hasher.finish()
};
let mut pending = store.begin_write(&oid, payload.len() as u64).unwrap();
pending.write_all(payload).unwrap();
pending.commit().unwrap();
oid
}
#[test]
fn a_commit_makes_the_new_name_durable() {
let (_dir, store) = store();
let oid = committed(&store, b"durable bytes");
let parent = store.path_of(&oid).parent().unwrap().to_path_buf();
let synced = dir_sync_seam::synced();
assert!(
synced.contains(&parent),
"the object's directory was never fsynced: {synced:?}"
);
}
#[test]
fn a_failed_directory_fsync_fails_the_commit() {
let (_dir, store) = store();
let payload = b"not yet durable".to_vec();
let oid = {
let mut hasher = crate::oid::Sha256Stream::new();
hasher.update(&payload);
hasher.finish()
};
let mut pending = store.begin_write(&oid, payload.len() as u64).unwrap();
pending.write_all(&payload).unwrap();
dir_sync_seam::fail_from_now();
assert!(
pending.commit().is_err(),
"a commit whose directory fsync failed reported success"
);
}
#[test]
fn a_delete_makes_the_unlink_durable_and_says_so_when_it_cannot() {
let (_dir, store) = store();
let oid = committed(&store, b"to be deleted");
let parent = store.path_of(&oid).parent().unwrap().to_path_buf();
let before = dir_sync_seam::synced()
.iter()
.filter(|d| **d == parent)
.count();
assert!(store.delete(&oid).unwrap());
let after = dir_sync_seam::synced()
.iter()
.filter(|d| **d == parent)
.count();
assert!(
after > before,
"the delete never fsynced the object's directory"
);
let other = committed(&store, b"also to be deleted");
dir_sync_seam::fail_from_now();
assert!(
store.delete(&other).is_err(),
"a delete whose directory fsync failed reported success"
);
}
}