use crate::filters::{ContentFilter, ContentFilterProvider, ContentFilterStack};
use crate::osutils::sha::sha_string;
use log::{debug, info};
use std::collections::HashMap;
use std::fs;
use std::fs::{File, Metadata, Permissions};
use std::io;
use std::io::prelude::*;
use std::io::BufReader;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tempfile::NamedTempFile;
const CACHE_HEADER: &[u8] = b"### bzr hashcache v5\n";
enum FileKind {
Regular,
Symlink,
Other,
}
fn file_kind(path: &Path) -> FileKind {
match fs::symlink_metadata(path) {
Ok(meta) => {
if meta.is_symlink() {
FileKind::Symlink
} else if meta.is_file() {
FileKind::Regular
} else {
FileKind::Other
}
}
Err(_) => FileKind::Other,
}
}
#[derive(Debug, PartialEq, Default, Clone)]
pub struct Fingerprint {
pub size: u64,
pub mtime: i64,
pub ctime: i64,
pub ino: u64,
pub dev: u64,
pub mode: u32,
}
#[cfg(unix)]
impl From<Metadata> for Fingerprint {
fn from(meta: Metadata) -> Fingerprint {
Fingerprint {
size: meta.size(),
mtime: meta.mtime(),
ctime: meta.ctime(),
ino: meta.ino(),
dev: meta.dev(),
mode: meta.mode(),
}
}
}
#[cfg(windows)]
impl From<Metadata> for Fingerprint {
fn from(meta: Metadata) -> Fingerprint {
use std::os::windows::fs::MetadataExt;
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let ctime = meta
.created()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
Fingerprint {
size: meta.file_size(),
mtime,
ctime,
ino: 0,
dev: 0,
mode: 0,
}
}
}
const DEFAULT_CUTOFF_OFFSET: i64 = -3;
pub struct HashCache {
root: PathBuf,
hit_count: u32,
miss_count: u32,
stat_count: u32,
danger_count: u32,
removed_count: u32,
update_count: u32,
cache: HashMap<PathBuf, (String, Fingerprint)>,
needs_write: bool,
permissions: Option<Permissions>,
cache_file_name: PathBuf,
filter_provider: Option<Box<ContentFilterProvider>>,
cutoff_offset: i64,
}
impl HashCache {
pub fn new(
root: &Path,
cache_file_name: &Path,
permissions: Option<Permissions>,
content_filter_provider: Option<Box<ContentFilterProvider>>,
) -> Self {
HashCache {
root: root.to_path_buf(),
hit_count: 0,
miss_count: 0,
stat_count: 0,
danger_count: 0,
removed_count: 0,
update_count: 0,
cache: HashMap::new(),
needs_write: false,
permissions,
cache_file_name: cache_file_name.to_path_buf(),
filter_provider: content_filter_provider,
cutoff_offset: DEFAULT_CUTOFF_OFFSET,
}
}
pub fn cache_file_name(&self) -> &Path {
self.cache_file_name.as_path()
}
pub fn hit_count(&self) -> u32 {
self.hit_count
}
pub fn miss_count(&self) -> u32 {
self.miss_count
}
pub fn set_cutoff_offset(&mut self, offset: i64) {
self.cutoff_offset = offset;
}
pub fn clear(&mut self) {
if !self.cache.is_empty() {
self.needs_write = true;
self.cache.clear();
}
}
pub fn scan(&mut self) {
let mut keys_to_remove = Vec::new();
let mut by_inode = self
.cache
.iter()
.map(|(k, v)| (v.1.ino, k, v))
.collect::<Vec<_>>();
by_inode.sort_by_key(|x| x.0);
for (_inode, path, cache_val) in by_inode {
let abspath = self.root.join(path);
let fp = self.fingerprint(abspath.as_ref(), None);
self.stat_count += 1;
if fp.is_none() || cache_val.1 != fp.unwrap() {
self.removed_count += 1;
self.needs_write = true;
keys_to_remove.push(path.clone());
}
}
for path in keys_to_remove {
self.cache.remove(&path);
}
}
pub fn get_sha1_by_fingerprint(
&mut self,
path: &Path,
file_fp: &Fingerprint,
) -> io::Result<String> {
let abspath = self.root.join(path);
let (cache_sha1, cache_fp) = self
.cache
.get(path)
.cloned()
.unwrap_or((Default::default(), Default::default()));
if cache_fp == *file_fp {
self.hit_count += 1;
Ok(cache_sha1)
} else {
self.miss_count += 1;
match file_kind(&abspath) {
FileKind::Regular => {
let filters: Box<dyn ContentFilter> =
if let Some(filter_provider) = self.filter_provider.as_ref() {
filter_provider(path, file_fp.ctime as u64)
} else {
Box::new(ContentFilterStack::new())
};
let digest = filters.sha1_file(&abspath)?;
let cutoff = self.cutoff_time();
if file_fp.mtime >= cutoff || file_fp.ctime >= cutoff {
self.danger_count += 1;
if self.cache.remove(path).is_some() {
self.removed_count += 1;
self.needs_write = true;
}
} else {
self.update_count += 1;
self.needs_write = true;
self.cache
.insert(path.to_owned(), (digest.clone(), file_fp.clone()));
}
Ok(digest)
}
FileKind::Symlink => {
let target = fs::read_link(&abspath)?;
let digest = sha_string(target.to_string_lossy().as_bytes());
self.cache
.insert(path.to_owned(), (digest.clone(), file_fp.clone()));
self.update_count += 1;
self.needs_write = true;
Ok(digest)
}
_ => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("unknown file stat mode: {:o}", file_fp.mode),
)),
}
}
}
pub fn get_sha1(
&mut self,
path: &Path,
stat_value: Option<Metadata>,
) -> io::Result<Option<String>> {
let abspath = self.root.join(path);
self.stat_count += 1;
let file_fp = self.fingerprint(abspath.as_ref(), stat_value);
if file_fp.is_none() {
if self.cache.remove(path).is_some() {
self.removed_count += 1;
self.needs_write = true;
}
Ok(None)
} else {
Ok(Some(self.get_sha1_by_fingerprint(path, &file_fp.unwrap())?))
}
}
pub fn write(&mut self) -> Result<(), std::io::Error> {
let mut outf = NamedTempFile::new_in(self.cache_file_name.parent().unwrap())?;
if let Some(permissions) = self.permissions.clone() {
outf.as_file().set_permissions(permissions)?;
}
outf.write_all(CACHE_HEADER)?;
for (path, c) in &self.cache {
let mut line_info: Vec<u8> = Vec::new();
line_info.extend_from_slice(path.to_str().unwrap().as_bytes());
line_info.extend_from_slice(b"// ");
line_info.extend_from_slice(c.0.as_bytes());
line_info.push(b' ');
let fp = &c.1;
write!(
&mut line_info,
"{} {} {} {} {} {}",
fp.size, fp.mtime, fp.ctime, fp.ino, fp.dev, fp.mode
)?;
line_info.push(b'\n');
outf.write_all(&line_info)?;
}
outf.persist(self.cache_file_name())?;
self.needs_write = false;
debug!(
"write hash cache: {} hits={} misses={} stat={} recent={} updates={}",
self.cache_file_name().display(),
self.hit_count,
self.miss_count,
self.stat_count,
self.danger_count,
self.update_count
);
Ok(())
}
pub fn read(&mut self) -> Result<(), std::io::Error> {
self.cache = HashMap::new();
let file = File::open(self.cache_file_name());
if file.is_err() {
debug!(
"failed to open {}: {}",
self.cache_file_name().display(),
file.err().unwrap()
);
self.needs_write = true;
return Ok(());
}
let file = file.unwrap();
let reader = BufReader::with_capacity(65000, file);
let mut lines = reader.lines();
if let Some(header) = lines.next() {
if header?.as_bytes().eq(CACHE_HEADER) {
self.needs_write = true;
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!(
"cache header marker not found at top of {}; discarding cache",
self.cache_file_name().display()
),
));
}
} else {
self.needs_write = true;
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"error reading cache file header".to_string(),
));
}
for line in lines {
let line = line?;
let pos = line.find("// ").unwrap();
let path = PathBuf::from(&line[..pos]);
if self.cache.contains_key(&path) {
info!("duplicated path {} in cache", path.display());
continue;
}
let pos = pos + 3;
let fields = line[pos..].split(' ').collect::<Vec<_>>();
if fields.len() != 7 {
info!("bad line in hashcache: {}", line);
continue;
}
let sha1 = fields[0].to_owned();
if sha1.len() != 40 {
info!("bad sha1 in hashcache: {}", sha1);
continue;
}
let fp = Fingerprint {
size: fields[1].parse::<u64>().unwrap(),
mtime: fields[2].parse::<i64>().unwrap(),
ctime: fields[3].parse::<i64>().unwrap(),
ino: fields[4].parse::<u64>().unwrap(),
dev: fields[5].parse::<u64>().unwrap(),
mode: fields[6].parse::<u32>().unwrap(),
};
self.cache.insert(path, (sha1, fp));
}
self.needs_write = false;
Ok(())
}
pub fn needs_write(&self) -> bool {
self.needs_write
}
pub fn cutoff_time(&self) -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
+ self.cutoff_offset
}
pub fn fingerprint(&self, abspath: &Path, stat_value: Option<Metadata>) -> Option<Fingerprint> {
let stat_value = match stat_value {
Some(s) => s,
None => match fs::symlink_metadata(abspath) {
Ok(s) => s,
Err(_) => return None,
},
};
if stat_value.is_dir() {
return None;
}
Some(stat_value.into())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osutils::sha::sha_string;
use tempfile::TempDir;
fn empty_sha1(input: &[u8]) -> String {
sha_string(input)
}
fn make_cache(tmp: &TempDir) -> HashCache {
let root = tmp.path().to_path_buf();
std::fs::create_dir(root.join(".bzr")).unwrap();
HashCache::new(&root, &root.join(".bzr/stat-cache"), None, None)
}
fn write_file(root: &Path, name: &str, contents: &[u8]) {
std::fs::write(root.join(name), contents).unwrap();
}
#[test]
fn initial_miss_returns_correct_hash() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
write_file(tmp.path(), "foo", b"hello");
assert_eq!(
hc.get_sha1(Path::new("foo"), None).unwrap(),
Some("aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d".to_string())
);
assert_eq!(hc.miss_count(), 1);
assert_eq!(hc.hit_count(), 0);
}
#[test]
fn new_file_still_hashed_even_if_too_recent_to_cache() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
write_file(tmp.path(), "foo", b"goodbye");
assert_eq!(
hc.get_sha1(Path::new("foo"), None).unwrap(),
Some(empty_sha1(b"goodbye"))
);
}
#[test]
fn nonexistent_file_returns_none() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
assert_eq!(hc.get_sha1(Path::new("no-name-yet"), None).unwrap(), None);
}
#[test]
fn replaced_file_is_rehashed() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
write_file(tmp.path(), "foo", b"goodbye");
assert_eq!(
hc.get_sha1(Path::new("foo"), None).unwrap(),
Some(empty_sha1(b"goodbye"))
);
std::fs::remove_file(tmp.path().join("foo")).unwrap();
assert_eq!(hc.get_sha1(Path::new("foo"), None).unwrap(), None);
write_file(tmp.path(), "foo", b"new content");
assert_eq!(
hc.get_sha1(Path::new("foo"), None).unwrap(),
Some(empty_sha1(b"new content"))
);
}
#[test]
fn directory_returns_none() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
std::fs::create_dir(tmp.path().join("subdir")).unwrap();
assert_eq!(hc.get_sha1(Path::new("subdir"), None).unwrap(), None);
}
#[test]
fn cache_round_trips_through_disk_with_hit_after_reload() {
let tmp = TempDir::new().unwrap();
let mut hc = make_cache(&tmp);
write_file(tmp.path(), "foo", b"contents");
hc.set_cutoff_offset(10);
assert_eq!(
hc.get_sha1(Path::new("foo"), None).unwrap(),
Some(empty_sha1(b"contents"))
);
hc.write().unwrap();
let mut hc2 = HashCache::new(tmp.path(), &tmp.path().join(".bzr/stat-cache"), None, None);
hc2.read().unwrap();
assert_eq!(
hc2.get_sha1(Path::new("foo"), None).unwrap(),
Some(empty_sha1(b"contents"))
);
assert_eq!(hc2.hit_count(), 1);
}
}