use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use crate::tree::Hash;
use crate::tree::node::HASH_SIZE;
use super::error::VacuumError;
use super::report::{EntryKind, MalformedEntry, UninventoriedEntry};
const NODE_TEMP_PREFIX: &str = ".node-";
const NODE_TEMP_SUFFIX: &str = ".tmp";
const PREFIX_HEX_LEN: usize = 2;
const NAME_HEX_LEN: usize = HASH_SIZE * 2 - PREFIX_HEX_LEN;
#[derive(Debug)]
pub struct NodeFile {
pub path: PathBuf,
pub compressed_len: u64,
}
#[derive(Debug, Default)]
pub struct StoreInventory {
pub nodes: HashMap<Hash, NodeFile>,
pub temp_debris_files: usize,
pub temp_debris_bytes: u64,
pub malformed: Vec<MalformedEntry>,
#[cfg(test)]
counted: bool,
}
#[cfg(test)]
impl Drop for StoreInventory {
fn drop(&mut self) {
if self.counted {
residency::on_drop();
}
}
}
#[cfg(test)]
pub mod residency {
use std::cell::Cell;
thread_local! {
static LIVE: Cell<usize> = const { Cell::new(0) };
static PEAK: Cell<usize> = const { Cell::new(0) };
}
pub(super) fn on_enumerate() {
LIVE.with(|live| {
let now = live.get() + 1;
live.set(now);
PEAK.with(|peak| peak.set(peak.get().max(now)));
});
}
pub(super) fn on_drop() {
LIVE.with(|live| live.set(live.get().saturating_sub(1)));
}
pub fn reset() {
LIVE.with(|live| live.set(0));
PEAK.with(|peak| peak.set(0));
}
pub fn peak() -> usize {
PEAK.with(Cell::get)
}
}
pub fn enumerate_store(store_dir: &Path) -> Result<StoreInventory, VacuumError> {
let mut inventory = StoreInventory::default();
#[cfg(test)]
{
inventory.counted = true;
residency::on_enumerate();
}
for entry in read_dir(store_dir)? {
let entry = entry.map_err(|error| io_at(store_dir, error))?;
let path = entry.path();
let kind = entry_kind(&path)?;
let Some(name) = file_name_str(&path) else {
inventory.malformed.push(malformed(&path, "non-UTF8 name"));
continue;
};
if kind != EntryKind::Directory {
inventory
.malformed
.push(malformed(&path, "top-level store entry is not a directory"));
continue;
}
if !is_lowercase_hex(name) || name.len() != PREFIX_HEX_LEN {
inventory.malformed.push(malformed(
&path,
"directory name is not 2 lowercase hex characters",
));
continue;
}
enumerate_prefix_dir(&path, name, &mut inventory)?;
}
Ok(inventory)
}
fn enumerate_prefix_dir(
prefix_dir: &Path,
prefix: &str,
inventory: &mut StoreInventory,
) -> Result<(), VacuumError> {
for entry in read_dir(prefix_dir)? {
let entry = entry.map_err(|error| io_at(prefix_dir, error))?;
let path = entry.path();
let kind = entry_kind(&path)?;
let Some(name) = file_name_str(&path) else {
inventory.malformed.push(malformed(&path, "non-UTF8 name"));
continue;
};
if kind == EntryKind::File
&& name.starts_with(NODE_TEMP_PREFIX)
&& name.ends_with(NODE_TEMP_SUFFIX)
{
inventory.temp_debris_files += 1;
inventory.temp_debris_bytes += file_len(&path)?;
continue;
}
if kind != EntryKind::File {
inventory
.malformed
.push(malformed(&path, "node entry is not a regular file"));
continue;
}
if name.len() != NAME_HEX_LEN || !is_lowercase_hex(name) {
inventory.malformed.push(malformed(
&path,
"file name is not 62 lowercase hex characters",
));
continue;
}
let Some(hash) = parse_hash(prefix, name) else {
inventory
.malformed
.push(malformed(&path, "path components do not form a valid hash"));
continue;
};
let compressed_len = file_len(&path)?;
inventory.nodes.insert(
hash,
NodeFile {
path,
compressed_len,
},
);
}
Ok(())
}
#[derive(Debug, Default)]
pub struct TopLevelInventory {
pub uninventoried: Vec<UninventoriedEntry>,
pub shards_beyond_count: Vec<usize>,
}
pub fn inventory_top_level(
data_dir: &Path,
shard_count: usize,
expected_names: &[&str],
listed_paths: &[PathBuf],
) -> Result<TopLevelInventory, VacuumError> {
let mut top = TopLevelInventory::default();
for entry in read_dir(data_dir)? {
let entry = entry.map_err(|error| io_at(data_dir, error))?;
let path = entry.path();
let kind = entry_kind(&path)?;
let Some(name) = file_name_str(&path) else {
top.uninventoried.push(UninventoriedEntry {
name: path.file_name().map_or_else(
|| String::from("<unnamed>"),
|os| os.to_string_lossy().into_owned(),
),
kind,
size_bytes: entry_size(&path, kind),
});
continue;
};
if expected_names.contains(&name) {
continue;
}
if listed_paths.iter().any(|listed| listed == &path) {
continue;
}
if let Some(id) = parse_shard_dir_name(name) {
if id < shard_count {
continue;
}
if kind == EntryKind::Directory {
top.shards_beyond_count.push(id);
}
}
top.uninventoried.push(UninventoriedEntry {
name: name.to_owned(),
kind,
size_bytes: entry_size(&path, kind),
});
}
top.shards_beyond_count.sort_unstable();
Ok(top)
}
fn parse_shard_dir_name(name: &str) -> Option<usize> {
let id_text = name.strip_prefix("shard-")?;
let id: usize = id_text.parse().ok()?;
(id.to_string() == id_text).then_some(id)
}
fn read_dir(path: &Path) -> Result<fs::ReadDir, VacuumError> {
fs::read_dir(path).map_err(|error| io_at(path, error))
}
fn io_at(path: &Path, error: io::Error) -> VacuumError {
VacuumError::Io {
path: path.to_path_buf(),
error,
}
}
fn entry_kind(path: &Path) -> Result<EntryKind, VacuumError> {
let metadata = fs::symlink_metadata(path).map_err(|error| io_at(path, error))?;
Ok(kind_of(metadata.file_type()))
}
pub fn lstat_kind(path: &Path) -> Result<Option<EntryKind>, VacuumError> {
match fs::symlink_metadata(path) {
Ok(metadata) => Ok(Some(kind_of(metadata.file_type()))),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
Err(error) => Err(io_at(path, error)),
}
}
fn kind_of(file_type: fs::FileType) -> EntryKind {
if file_type.is_symlink() {
EntryKind::Symlink
} else if file_type.is_dir() {
EntryKind::Directory
} else if file_type.is_file() {
EntryKind::File
} else {
EntryKind::Other
}
}
fn file_len(path: &Path) -> Result<u64, VacuumError> {
fs::symlink_metadata(path)
.map(|metadata| metadata.len())
.map_err(|error| io_at(path, error))
}
fn file_name_str(path: &Path) -> Option<&str> {
path.file_name().and_then(|name| name.to_str())
}
fn is_lowercase_hex(text: &str) -> bool {
!text.is_empty()
&& text
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
fn parse_hash(prefix: &str, name: &str) -> Option<Hash> {
let mut hex = String::with_capacity(HASH_SIZE * 2);
hex.push_str(prefix);
hex.push_str(name);
let mut bytes = [0_u8; HASH_SIZE];
for (index, chunk) in hex.as_bytes().chunks_exact(2).enumerate() {
let high = hex_value(chunk[0])?;
let low = hex_value(chunk[1])?;
bytes[index] = (high << 4) | low;
}
Some(Hash::from_bytes(bytes))
}
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
_ => None,
}
}
fn entry_size(path: &Path, kind: EntryKind) -> Option<u64> {
match kind {
EntryKind::File | EntryKind::Symlink | EntryKind::Other => fs::symlink_metadata(path)
.map(|metadata| metadata.len())
.ok(),
EntryKind::Directory => directory_size(path),
}
}
fn directory_size(path: &Path) -> Option<u64> {
let mut total = 0_u64;
for entry in fs::read_dir(path).ok()? {
let entry = entry.ok()?;
let child = entry.path();
let metadata = fs::symlink_metadata(&child).ok()?;
if metadata.file_type().is_dir() {
total = total.checked_add(directory_size(&child)?)?;
} else {
total = total.checked_add(metadata.len())?;
}
}
Some(total)
}
fn malformed(path: &Path, reason: &str) -> MalformedEntry {
MalformedEntry {
path: path.to_path_buf(),
reason: reason.to_owned(),
}
}