use rocksdb::{DBWithThreadMode, SingleThreaded};
use serde::Serialize;
use std::collections::HashMap;
use std::path::PathBuf;
use crate::core::db;
use crate::core::db::dir_hashes::dir_hashes_db::{
dir_hash_db_path_from_commit_id, with_exclusive_access,
};
use crate::core::db::key_val::str_val_db;
use crate::core::db::merkle_node::merkle_node_db::{
MerkleNodeDB, is_pre_v025_payload, suppress_retired_format_logging,
};
use crate::error::OxenError;
use crate::model::merkle_tree::node::{EMerkleTreeNode, MerkleTreeNode, MerkleTreeNodeType};
use crate::model::{Commit, LocalRepository, MerkleHash};
use crate::repositories;
use crate::util;
#[derive(Debug, Default, Clone, Serialize)]
pub struct NodeFormatReport {
pub total_nodes: usize,
pub pre_v025: HashMap<MerkleTreeNodeType, usize>,
pub undecodable: usize,
}
impl NodeFormatReport {
pub fn pre_v025_total(&self) -> usize {
self.pre_v025.values().sum()
}
pub fn is_affected(&self) -> bool {
self.pre_v025_total() > 0
}
}
pub fn scan_node_format(repo: &LocalRepository) -> Result<NodeFormatReport, OxenError> {
let _quiet = suppress_retired_format_logging();
let store = repo.merkle_node_store();
let hashes = store.list_hashes()?;
let mut report = NodeFormatReport {
total_nodes: hashes.len(),
..Default::default()
};
for hash in hashes {
match MerkleNodeDB::open_read_only(store.clone(), &hash) {
Ok(db) => {
let payload = db.data();
if is_pre_v025_payload(db.dtype, &payload) {
*report.pre_v025.entry(db.dtype).or_default() += 1;
} else if let Err(err) = EMerkleTreeNode::from_type_and_bytes(db.dtype, &payload) {
log::warn!("Node {hash} in {:?} did not decode: {err}", repo.path);
report.undecodable += 1;
}
}
Err(err) => {
log::warn!("Node {hash} in {:?} could not be opened: {err}", repo.path);
report.undecodable += 1;
}
}
}
Ok(report)
}
#[derive(Debug, Clone, Serialize)]
pub struct RebuildDirHashesStats {
pub commit_id: String,
pub dirs_written: usize,
}
pub fn rebuild_dir_hash_db(
repo: &LocalRepository,
commit: &Commit,
) -> Result<RebuildDirHashesStats, OxenError> {
log::info!(
"rebuild_dir_hash_db: repo={:?} commit={}",
repo.path,
commit.id
);
let root = repositories::tree::get_root_with_children(repo, commit)?
.ok_or_else(|| OxenError::RevisionNotFound(commit.id.clone().into()))?;
let pairs = collect_dir_hashes(&root);
let db_path = dir_hash_db_path_from_commit_id(repo, &commit.id);
let new_path = db_path.with_file_name(format!("dir_hashes.new.{}", commit.id));
let old_path = db_path.with_file_name(format!("dir_hashes.old.{}", commit.id));
for path in [&new_path, &old_path] {
if path.exists() {
util::fs::remove_dir_all(path)?;
}
}
let mut successful_writes: usize = 0;
{
let opts = db::key_val::opts::default();
let new_db: DBWithThreadMode<SingleThreaded> =
DBWithThreadMode::open(&opts, dunce::simplified(&new_path))?;
for (path, hash) in &pairs {
let Some(path_str) = path.to_str() else {
log::error!("Skipping non-UTF-8 path during rebuild: {path:?}");
continue;
};
str_val_db::put(&new_db, path_str, &hash.to_string())?;
successful_writes += 1;
}
}
let swap_db_path = db_path.clone();
with_exclusive_access(repo, move || {
let had_existing = swap_db_path.exists();
if had_existing {
util::fs::rename(&swap_db_path, &old_path)?;
}
util::fs::rename(&new_path, &swap_db_path)?;
if had_existing
&& old_path.exists()
&& let Err(err) = util::fs::remove_dir_all(&old_path)
{
log::warn!(
"rebuild_dir_hash_db: could not remove previous dir_hashes at \
{old_path:?}: {err}"
);
}
Ok(())
})?;
Ok(RebuildDirHashesStats {
commit_id: commit.id.clone(),
dirs_written: successful_writes,
})
}
fn collect_dir_hashes(root: &MerkleTreeNode) -> Vec<(PathBuf, MerkleHash)> {
let mut out = Vec::new();
let mut stack: Vec<(&MerkleTreeNode, PathBuf)> = vec![(root, PathBuf::new())];
while let Some((node, path)) = stack.pop() {
if matches!(&node.node, EMerkleTreeNode::Directory(_)) {
out.push((path.clone(), node.hash));
}
for child in &node.children {
let next_path = if let EMerkleTreeNode::Directory(dir) = &child.node {
path.join(dir.name())
} else {
path.clone()
};
stack.push((child, next_path));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use crate::error::OxenError;
use crate::repositories;
use crate::storage::version_store::VersionLocation;
use crate::test;
use crate::util;
#[tokio::test]
async fn test_fsck_dry_run_detects_corrupted_version() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let file_path = repo.path.join("hello.txt");
test::write_txt_file_to_path(&file_path, "hello world")?;
repositories::add(&repo, &file_path).await?;
repositories::commit(&repo, "Adding hello.txt")?;
let version_store = repo.version_store();
let versions = version_store.list_versions().await?;
assert!(!versions.is_empty());
let hash = &versions[0];
let VersionLocation::Local(path) = version_store.version_location(hash).await? else {
panic!("Expected a local version store (Local path). This test only works with local storage.");
};
std::fs::write(&path, b"corrupted data")?;
let result = version_store.clean_corrupted_versions(true).await?;
assert!(result.corrupted > 0);
assert_eq!(result.cleaned, 0);
assert!(version_store.version_exists(hash).await?);
Ok(())
})
.await
}
#[tokio::test]
async fn test_fsck_clean_removes_corrupted_version() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let file_path = repo.path.join("hello.txt");
test::write_txt_file_to_path(&file_path, "hello world")?;
repositories::add(&repo, &file_path).await?;
repositories::commit(&repo, "Adding hello.txt")?;
let version_store = repo.version_store();
let versions = version_store.list_versions().await?;
assert!(!versions.is_empty());
let hash = &versions[0];
let VersionLocation::Local(path) = version_store.version_location(hash).await? else {
panic!("Expected a local version store (Local path). This test only works with local storage.");
};
std::fs::write(&path, b"corrupted data")?;
let result = version_store.clean_corrupted_versions(false).await?;
assert!(result.corrupted > 0);
assert!(result.cleaned > 0);
assert!(!version_store.version_exists(hash).await?);
Ok(())
})
.await
}
#[tokio::test]
async fn test_fsck_no_corruption_on_clean_repo() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let file_path = repo.path.join("hello.txt");
test::write_txt_file_to_path(&file_path, "hello world")?;
repositories::add(&repo, &file_path).await?;
repositories::commit(&repo, "Adding hello.txt")?;
let version_store = repo.version_store();
let result = version_store.clean_corrupted_versions(true).await?;
assert_eq!(result.corrupted, 0);
assert!(result.scanned > 0);
Ok(())
})
.await
}
#[tokio::test]
async fn test_rebuild_dir_hash_db_restores_missing_entry() -> Result<(), OxenError> {
test::run_empty_local_repo_test_async(|repo| async move {
let parent_dir = repo.path.join("features").join("fbimg");
let child_dir = parent_dir.join("dinov3_vits16");
util::fs::create_dir_all(&child_dir)?;
let file = child_dir.join("note.txt");
test::write_txt_file_to_path(&file, "hello")?;
repositories::add(&repo, &repo.path).await?;
let commit = repositories::commit(&repo, "seed nested dirs")?;
let child_rel = PathBuf::from("features/fbimg/dinov3_vits16");
let ok = repositories::tree::get_dir_with_children(&repo, &commit, &child_rel, None)?;
assert!(
ok.is_some(),
"expected dir_with_children to find {child_rel:?} on a healthy repo"
);
let db_path =
crate::core::db::dir_hashes::dir_hashes_db::dir_hash_db_path_from_commit_id(
&repo, &commit.id,
);
crate::core::db::dir_hashes::dir_hashes_db::remove_from_cache_with_children(&db_path)?;
util::fs::remove_dir_all(&db_path)?;
let broken =
repositories::tree::get_dir_with_children(&repo, &commit, &child_rel, None);
assert!(
matches!(broken, Err(OxenError::PathDoesNotExist(_))),
"expected PathDoesNotExist after dir_hash_db was removed, got {broken:?}"
);
let stats = rebuild_dir_hash_db(&repo, &commit)?;
assert_eq!(stats.commit_id, commit.id);
assert!(
stats.dirs_written >= 3,
"expected at least root + features + fbimg + dinov3_vits16 entries, got {}",
stats.dirs_written
);
let repaired =
repositories::tree::get_dir_with_children(&repo, &commit, &child_rel, None)?;
assert!(
repaired.is_some(),
"expected dir_with_children to find {child_rel:?} after rebuild"
);
Ok(())
})
.await
}
#[derive(serde::Serialize)]
struct LegacyVNodeData {
hash: MerkleHash,
node_type: MerkleTreeNodeType,
}
#[tokio::test]
async fn test_scan_node_format_finds_planted_pre_v0_25_node() -> Result<(), OxenError> {
test::run_one_commit_local_repo_test_async(|repo| async move {
let clean = scan_node_format(&repo)?;
assert!(
!clean.is_affected(),
"a freshly written repo holds no pre-0.25 nodes, got {clean:?}"
);
assert!(clean.total_nodes > 0, "fixture repo should have nodes");
assert_eq!(clean.undecodable, 0);
let nodes_dir = util::fs::oxen_hidden_dir(&repo.path)
.join(crate::constants::TREE_DIR)
.join(crate::constants::NODES_DIR);
let mut planted = false;
for prefix in util::fs::list_dirs_in_dir(&nodes_dir)? {
for node_dir in util::fs::list_dirs_in_dir(&prefix)? {
let node_file = node_dir.join("node");
let blob = std::fs::read(&node_file)?;
if blob.first() != Some(&MerkleTreeNodeType::VNode.to_u8()) {
continue;
}
let data_len =
u32::from_le_bytes(blob[17..21].try_into().expect("4 bytes")) as usize;
let vnode = crate::model::merkle_tree::node::VNode::deserialize(
&blob[21..21 + data_len],
)
.expect("fixture vnode should decode before rewriting");
let legacy = rmp_serde::to_vec(&LegacyVNodeData {
hash: *vnode.hash(),
node_type: MerkleTreeNodeType::VNode,
})
.expect("legacy vnode should serialize");
let mut rewritten = blob[..17].to_vec();
rewritten.extend_from_slice(&(legacy.len() as u32).to_le_bytes());
rewritten.extend_from_slice(&legacy);
rewritten.extend_from_slice(&blob[21 + data_len..]);
std::fs::write(&node_file, rewritten)?;
planted = true;
break;
}
if planted {
break;
}
}
assert!(planted, "fixture repo should contain a vnode to rewrite");
let scanned = scan_node_format(&repo)?;
assert_eq!(
scanned.pre_v025.get(&MerkleTreeNodeType::VNode).copied(),
Some(1),
"the planted vnode should be counted against its own type, got {scanned:?}"
);
assert_eq!(scanned.pre_v025_total(), 1);
assert_eq!(
scanned.total_nodes, clean.total_nodes,
"node count is stable"
);
assert_eq!(
scanned.undecodable, 0,
"a retired format is not the same as an undecodable node"
);
Ok(())
})
.await
}
}