#[cfg(unix)]
use super::MetadataExt;
use super::{
ArchiveFileName, CompactionOptions, Error, MaintenanceTask, Metadata, OsStr, OsString, Path,
PathBuf, Result, SystemTime, recovery_backup_target, temporary_kind,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DirectoryFingerprint {
pub(crate) entries: Vec<FileFingerprint>,
#[cfg(unix)]
pub(crate) device: u64,
#[cfg(unix)]
pub(crate) inode: u64,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct FileFingerprint {
pub(crate) name: OsString,
pub(crate) kind: u8,
pub(crate) length: u64,
pub(crate) modified: Option<SystemTime>,
#[cfg(unix)]
pub(crate) device: u64,
#[cfg(unix)]
pub(crate) inode: u64,
#[cfg(unix)]
pub(crate) change_time_seconds: i64,
#[cfg(unix)]
pub(crate) change_time_nanoseconds: i64,
}
pub(in crate::writer::maintenance) fn validate_options(options: &CompactionOptions) -> Result<()> {
if options.purge_orphaned_version_histories
&& options.compaction_kind != Some(crate::writer::compaction::CompactionKind::Full)
{
return Err(Error::InvalidFormat {
details: "purging orphaned version histories requires a full compaction".to_owned(),
});
}
if options.purged_history_minimum_age.is_some() && !options.purge_orphaned_version_histories {
return Err(Error::InvalidFormat {
details:
"a purged-history minimum age needs the orphaned-version-history purge selected"
.to_owned(),
});
}
if options.contains(MaintenanceTask::RecoveryBackups)
&& options.recovery_backup_policy.is_none()
{
return Err(Error::InvalidFormat {
details: "recovery-backups requires an explicit age/count retention policy".to_owned(),
});
}
if options.contains(MaintenanceTask::RepairArchives)
&& options.contains(MaintenanceTask::RecoveryBackups)
{
return Err(Error::InvalidFormat {
details: "repair-archives and recovery-backups cannot run together: repair retires \
the original archive to a `.bak` name that the backup policy could then \
delete in the same run, discarding the only copy of any segment the \
rebuild could not read — repair first, verify the store, then retire the \
backups in a later run"
.to_owned(),
});
}
if options.journal_revision_retention.is_some() && !options.contains(MaintenanceTask::Journal) {
return Err(Error::InvalidFormat {
details: "a journal revision retention bound requires the journal task: the bounded \
lines must leave the journal in the same run, or they remain retained \
history and the segments behind them stay protected"
.to_owned(),
});
}
Ok(())
}
pub(crate) fn canonical_repository_directory(directory: &Path) -> Result<PathBuf> {
std::fs::canonicalize(directory).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::InvalidFormat {
details: format!("{} is not a repository directory", directory.display()),
}
} else {
Error::InputOutput(source)
}
})
}
pub(crate) fn validate_repository_shape(directory: &Path) -> Result<()> {
crate::writer::maintenance::gate_observation::record("validate_repository_shape", directory);
let root_metadata = std::fs::symlink_metadata(directory).map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::InvalidFormat {
details: format!("{} is not a repository directory", directory.display()),
}
} else {
Error::InputOutput(source)
}
})?;
if root_metadata.file_type().is_symlink() {
return Err(Error::InvalidFormat {
details: format!(
"canonical repository target {} became a symbolic link after path resolution; refusing to continue",
directory.display()
),
});
}
if !directory.is_dir() {
return Err(Error::InvalidFormat {
details: format!("{} is not a repository directory", directory.display()),
});
}
let manifest = directory.join("manifest");
let journal = directory.join("journal.log");
if !manifest.try_exists()? || !journal.try_exists()? {
return Err(Error::InvalidFormat {
details: format!(
"{} is not an existing segment-tar repository (manifest and journal.log are required)",
directory.display()
),
});
}
validate_managed_file_types(directory)
}
pub(in crate::writer::maintenance) fn validate_managed_file_types(directory: &Path) -> Result<()> {
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
if !is_managed_name(&name) {
continue;
}
let metadata = std::fs::symlink_metadata(entry.path())?;
if !metadata.file_type().is_file() {
return Err(Error::InvalidFormat {
details: format!(
"managed repository path {} is not a regular file",
entry.path().display()
),
});
}
}
Ok(())
}
pub(in crate::writer::maintenance) fn is_managed_name(name: &OsStr) -> bool {
let Some(name) = name.to_str() else {
return false;
};
matches!(name, "manifest" | "journal.log" | "gc.log" | "repo.lock")
|| ArchiveFileName::parse(name).is_some()
|| temporary_kind(name).is_some()
|| recovery_backup_target(name).is_some()
}
pub(crate) fn directory_fingerprint(directory: &Path) -> Result<DirectoryFingerprint> {
let directory_metadata = std::fs::symlink_metadata(directory)?;
if !directory_metadata.file_type().is_dir() {
return Err(Error::InvalidFormat {
details: format!(
"{} ceased to be a repository directory",
directory.display()
),
});
}
let mut entries = Vec::new();
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let name = entry.file_name();
if name == OsStr::new("repo.lock") {
continue;
}
let metadata = std::fs::symlink_metadata(entry.path())?;
entries.push(file_fingerprint(name, &metadata));
}
entries.sort_by(|left, right| left.name.cmp(&right.name));
Ok(DirectoryFingerprint {
entries,
#[cfg(unix)]
device: directory_metadata.dev(),
#[cfg(unix)]
inode: directory_metadata.ino(),
})
}
pub(in crate::writer::maintenance) fn file_fingerprint(
name: OsString,
metadata: &Metadata,
) -> FileFingerprint {
let file_type = metadata.file_type();
let kind = if file_type.is_file() {
1
} else if file_type.is_dir() {
2
} else if file_type.is_symlink() {
3
} else {
4
};
FileFingerprint {
name,
kind,
length: metadata.len(),
modified: metadata.modified().ok(),
#[cfg(unix)]
device: metadata.dev(),
#[cfg(unix)]
inode: metadata.ino(),
#[cfg(unix)]
change_time_seconds: metadata.ctime(),
#[cfg(unix)]
change_time_nanoseconds: metadata.ctime_nsec(),
}
}
#[cfg(test)]
mod tests {
#[cfg(unix)]
use super::*;
use crate::store::Repository;
use crate::writer::maintenance::options::*;
use crate::writer::maintenance::prepared::*;
use crate::writer::maintenance::test_support::*;
#[cfg(unix)]
#[test]
fn non_utf8_backup_like_name_is_not_promoted_into_the_managed_allowlist() {
use std::os::unix::ffi::OsStrExt as _;
let hostile = std::ffi::OsStr::from_bytes(b"data00000a.tar.\xff.ro.bak");
assert!(!is_managed_name(hostile));
}
#[test]
fn empty_directory_is_refused_without_bootstrapping_anything() {
let directory = TestDirectory::new("empty");
let error = plan_compaction(&directory.path, &CompactionOptions::default())
.expect_err("an empty directory is not a repository");
let crate::error::Error::InvalidFormat { details } = error else {
panic!("unexpected repository-shape error: {error}");
};
assert_eq!(
details,
format!(
"{} is not an existing segment-tar repository (manifest and journal.log are required)",
canonical_fixture_directory(&directory.path).display()
)
);
assert!(file_bytes(&directory.path).is_empty());
}
#[cfg(unix)]
#[test]
fn managed_symlink_is_rejected_without_following_its_target() {
use std::os::unix::fs::symlink;
let directory = TestDirectory::repository("managed-symlink");
let victim = directory.path.join("victim");
std::fs::write(&victim, b"do not touch").expect("victim");
let staging = directory.path.join("journal.log.compacting");
symlink("victim", &staging).expect("staging symlink");
let before = file_bytes(&directory.path);
let error = plan_compaction(&directory.path, &CompactionOptions::default())
.expect_err("managed symlink must be rejected");
let crate::error::Error::InvalidFormat { details } = error else {
panic!("unexpected managed-file-type error: {error}");
};
assert_eq!(
details,
format!(
"managed repository path {} is not a regular file",
canonical_fixture_directory(&directory.path)
.join("journal.log.compacting")
.display()
)
);
assert_eq!(file_bytes(&directory.path), before);
assert_eq!(std::fs::read(victim).expect("victim"), b"do not touch");
}
#[test]
fn foreign_tar_and_unknown_files_are_never_cleanup_targets() {
let directory = TestDirectory::repository("foreign-files");
std::fs::write(directory.path.join("notes.tar"), b"foreign tar").expect("foreign tar");
std::fs::write(directory.path.join("operator-notes.txt"), b"keep me").expect("notes");
let plan = plan_compaction(&directory.path, &CompactionOptions::default()).expect("plan");
assert!(plan.is_empty());
assert_eq!(
std::fs::read(directory.path.join("notes.tar")).expect("foreign tar"),
b"foreign tar"
);
assert_eq!(
std::fs::read(directory.path.join("operator-notes.txt")).expect("notes"),
b"keep me"
);
}
#[test]
fn non_regular_numbered_read_only_backup_is_refused_even_during_preview() {
let directory = TestDirectory::repository("non-regular-numbered-ro-backup");
let backup = directory.path.join("data00000a.tar.2.ro.bak");
std::fs::create_dir(&backup).expect("create hostile managed-name directory");
let error = plan_compaction(&directory.path, &CompactionOptions::default())
.expect_err("managed backup names must remain regular files in dry-run");
assert_eq!(
error.to_string(),
format!(
"invalid segment-tar data: managed repository path {} is not a regular file",
canonical_fixture_directory(&directory.path)
.join("data00000a.tar.2.ro.bak")
.display()
)
);
}
#[test]
fn prepared_plan_rejects_same_length_inode_replacement() {
let directory = TestDirectory::repository("stale-plan");
let options = CompactionOptions::default().with_tasks([]);
let prepared = PreparedCompaction::prepare(&directory.path, options).expect("prepare");
let journal_path = directory.path.join("journal.log");
let bytes = std::fs::read(&journal_path).expect("journal");
let replacement = directory.path.join("replacement");
std::fs::write(&replacement, &bytes).expect("write replacement");
std::fs::rename(&replacement, &journal_path).expect("replace same-size journal");
assert!(prepared.apply().is_err());
Repository::open(&directory.path).expect("replacement bytes remain healthy");
}
#[cfg(unix)]
#[test]
fn prepared_plan_rejects_in_place_change_with_restored_mtime() {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
let directory = TestDirectory::repository("stale-plan-ctime");
let staging = directory.path.join("journal.log.compacting");
std::fs::copy(directory.path.join("journal.log"), &staging)
.expect("create redundant staging journal");
let options = CompactionOptions::default().with_tasks([MaintenanceTask::StaleTemporaries]);
let prepared = PreparedCompaction::prepare(&directory.path, options).expect("prepare");
let metadata = std::fs::metadata(&staging).expect("staging metadata");
std::thread::sleep(std::time::Duration::from_millis(2));
let changed = vec![b'x'; metadata.len() as usize];
std::fs::write(&staging, changed).expect("same-inode same-size overwrite");
let path = CString::new(staging.as_os_str().as_bytes()).expect("path without NUL");
let times = [
libc::timespec {
tv_sec: checked_timespec_field(metadata.atime()),
tv_nsec: checked_timespec_field(metadata.atime_nsec()),
},
libc::timespec {
tv_sec: checked_timespec_field(metadata.mtime()),
tv_nsec: checked_timespec_field(metadata.mtime_nsec()),
},
];
let result = unsafe { libc::utimensat(libc::AT_FDCWD, path.as_ptr(), times.as_ptr(), 0) };
assert_eq!(result, 0, "restore fixture mtime");
assert!(prepared.apply().is_err());
assert!(
staging.exists(),
"stale proof must not delete changed evidence"
);
Repository::open(&directory.path).expect("repository remains healthy");
}
}