Files
crank/crates/crank-artifacts/src/housekeeping.rs
T

722 lines
26 KiB
Rust

use std::{
fmt,
os::fd::AsRawFd,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::{
ArtifactError, ArtifactStore, ReconciliationMutation, ReconciliationNamespace,
ReconciliationScan,
store::{
QUARANTINE_DIR, RootLock, check_file, checkpointed, ensure_root_unchanged, fsync_fd,
open_existing_dir, open_or_create_dir, rename_no_replace_at, stat_fd, unlinkat,
},
};
const FINAL_NAMESPACE: u8 = 0;
const QUARANTINE_NAMESPACE: u8 = 1;
/// Opaque continuation for a bounded reconciliation traversal. It is valid
/// only for the unchanged pinned root from which it was returned.
pub struct ReconciliationCursor {
root_dev: u64,
root_ino: u64,
namespace: u8,
shard: u8,
position: i64,
}
impl fmt::Debug for ReconciliationCursor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ReconciliationCursor(..)")
}
}
/// Opaque, inode-bound evidence for one valid final or quarantined artifact.
/// It intentionally exposes neither its digest nor filesystem path.
#[derive(Clone)]
pub struct ReconciliationCandidate {
root_dev: u64,
root_ino: u64,
namespace: ReconciliationNamespace,
shard: String,
shard_dev: u64,
shard_ino: u64,
name: String,
dev: u64,
ino: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
}
impl fmt::Debug for ReconciliationCandidate {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ReconciliationCandidate(..)")
}
}
impl ReconciliationCandidate {
pub fn namespace(&self) -> ReconciliationNamespace {
self.namespace
}
}
/// Opaque, single-use evidence that a particular stale temporary inode was
/// observed under this store's pinned root. It is not a blob reference.
#[derive(Debug)]
pub struct StaleTemp {
name: String,
shard: String,
root_dev: u64,
root_ino: u64,
dev: u64,
ino: u64,
modified_seconds: i64,
modified_nanoseconds: i64,
grace: Duration,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct TempScan {
pub scanned: usize,
pub omitted: usize,
}
impl ArtifactStore {
/// Streams valid final and quarantine entries with an opaque continuation.
/// Neither the report nor the returned capabilities disclose paths/digests.
pub fn scan_reconciliation(
&self,
continuation: Option<ReconciliationCursor>,
scan_budget: usize,
result_limit: usize,
) -> Result<(ReconciliationScan, Vec<ReconciliationCandidate>), ArtifactError> {
let root = self.root()?;
let _lock = RootLock::shared(root)?;
ensure_root_unchanged(root)?;
let mut cursor = match continuation {
Some(cursor) => {
if cursor.root_dev != root.dev
|| cursor.root_ino != root.ino
|| cursor.namespace > QUARANTINE_NAMESPACE
{
return Err(ArtifactError::UnsafeRoot);
}
cursor
}
None => reconciliation_cursor(root.dev, root.ino, FINAL_NAMESPACE, 0, 0),
};
let mut report = ReconciliationScan::empty(None);
if scan_budget == 0 || result_limit == 0 {
report.continuation = Some(cursor);
return Ok((report, Vec::new()));
}
let mut remaining = scan_budget;
let mut entries = Vec::new();
while cursor.namespace <= QUARANTINE_NAMESPACE {
if remaining == 0 || entries.len() == result_limit {
report.continuation = Some(cursor);
return Ok((report, entries));
}
let namespace_name = namespace_name(cursor.namespace);
let namespace = match open_existing_dir(root.fd.as_raw_fd(), namespace_name) {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => {
advance_namespace(&mut cursor);
continue;
}
Err(error) => return Err(error),
};
loop {
if remaining == 0 {
report.continuation = Some(cursor);
return Ok((report, entries));
}
let shard_name = format!("{:02x}", cursor.shard);
let shard = match open_existing_dir(namespace.as_raw_fd(), shard_name.as_bytes()) {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => {
consume_scan_work(&mut report, &mut remaining);
if cursor.shard == u8::MAX {
break;
}
advance_shard(&mut cursor);
continue;
}
Err(error) => return Err(error),
};
let shard_stat = stat_fd(shard.as_raw_fd())?;
let mut directory = DirectoryStream::open(shard.as_raw_fd(), cursor.position)?;
loop {
if remaining == 0 || entries.len() == result_limit {
report.continuation = Some(cursor);
return Ok((report, entries));
}
let Some((name, after)) = directory.next()? else {
// An empty directory still consumes one bounded
// traversal unit, so a sparse namespace cannot make
// us probe all 256 shards in one page.
consume_scan_work(&mut report, &mut remaining);
break;
};
cursor.position = after;
consume_scan_work(&mut report, &mut remaining);
let Ok(name) = String::from_utf8(name) else {
report.malformed += 1;
continue;
};
if !valid_final_name(&name, &shard_name) {
report.malformed += 1;
continue;
}
#[cfg(debug_assertions)]
let _ = crate::test_support::checkpoint("reconciliation_before_stat");
let stat = match checkpointed("reconciliation_stat", || {
nofollow_stat(shard.as_raw_fd(), &name)
}) {
Ok(stat) => stat,
Err(ArtifactError::NotFound) => {
// The entry was concurrently removed. It is no
// longer retryable, so the cursor may advance.
continue;
}
Err(ArtifactError::UnsafeRoot) => {
report.unsafe_entries += 1;
continue;
}
Err(error) => return Err(error),
};
if check_file(&stat).is_err() {
report.unsafe_entries += 1;
continue;
}
let namespace = if cursor.namespace == FINAL_NAMESPACE {
report.final_entries += 1;
ReconciliationNamespace::Final
} else {
report.quarantined_entries += 1;
ReconciliationNamespace::Quarantine
};
entries.push(ReconciliationCandidate {
root_dev: root.dev,
root_ino: root.ino,
namespace,
shard: shard_name.clone(),
shard_dev: shard_stat.st_dev,
shard_ino: shard_stat.st_ino,
name,
dev: stat.st_dev,
ino: stat.st_ino,
modified_seconds: stat.st_mtime,
modified_nanoseconds: stat.st_mtime_nsec,
});
// The continuation is deliberately kept at `after`,
// immediately before the next unissued item.
}
if cursor.shard == u8::MAX {
break;
}
advance_shard(&mut cursor);
}
advance_namespace(&mut cursor);
}
Ok((report, entries))
}
/// Moves a revalidated final inode into the private quarantine namespace
/// without replacing any existing quarantine inode.
pub fn quarantine_reconciliation(
&self,
candidate: ReconciliationCandidate,
) -> Result<ReconciliationMutation, ArtifactError> {
if candidate.namespace != ReconciliationNamespace::Final {
return Err(ArtifactError::UnsafeRoot);
}
let root = self.root()?;
let _lock = RootLock::exclusive(root)?;
ensure_reconciliation_root(root, &candidate)?;
let final_root = open_existing_dir(root.fd.as_raw_fd(), namespace_name(FINAL_NAMESPACE))?;
let final_shard = open_existing_dir(final_root.as_raw_fd(), candidate.shard.as_bytes())?;
match revalidate_candidate(&candidate, final_shard.as_raw_fd()) {
Ok(()) => {}
Err(ArtifactError::NotFound) => {
return self.recover_quarantine(root, &candidate);
}
Err(ArtifactError::UnsafeRoot) => {
return match self.recover_quarantine(root, &candidate) {
Ok(outcome) => Ok(outcome),
Err(ArtifactError::NotFound) => Err(ArtifactError::UnsafeRoot),
Err(error) => Err(error),
};
}
Err(error) => return Err(error),
}
let quarantine_root = open_or_create_dir(root.fd.as_raw_fd(), QUARANTINE_DIR)?;
let quarantine_shard =
open_or_create_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes())?;
match checkpointed("reconciliation_quarantine_rename", || {
rename_no_replace_at(
final_shard.as_raw_fd(),
candidate.name.as_bytes(),
quarantine_shard.as_raw_fd(),
candidate.name.as_bytes(),
)
})? {
true => match fsync_quarantine_dirs(&final_shard, &quarantine_shard) {
Ok(()) => Ok(ReconciliationMutation::Quarantined),
Err(_) => Ok(ReconciliationMutation::Retryable),
},
false => self.recover_quarantine(root, &candidate),
}
}
/// Idempotently removes a revalidated quarantined inode and fsyncs its
/// directory. An fsync ambiguity is explicitly retryable.
pub fn delete_quarantined_reconciliation(
&self,
candidate: ReconciliationCandidate,
) -> Result<ReconciliationMutation, ArtifactError> {
if candidate.namespace != ReconciliationNamespace::Quarantine {
return Err(ArtifactError::UnsafeRoot);
}
let root = self.root()?;
let _lock = RootLock::exclusive(root)?;
ensure_reconciliation_root(root, &candidate)?;
let quarantine_root = match open_existing_dir(root.fd.as_raw_fd(), QUARANTINE_DIR) {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => return Ok(ReconciliationMutation::AlreadyAbsent),
Err(error) => return Err(error),
};
let shard = match open_existing_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes())
{
Ok(fd) => fd,
Err(ArtifactError::NotFound) => return Ok(ReconciliationMutation::AlreadyAbsent),
Err(error) => return Err(error),
};
match revalidate_candidate(&candidate, shard.as_raw_fd()) {
Ok(()) => {}
Err(ArtifactError::NotFound) => {
return fsync_mutation(&shard, ReconciliationMutation::AlreadyAbsent);
}
Err(error) => return Err(error),
}
match checkpointed("reconciliation_delete_unlink", || {
unlinkat(shard.as_raw_fd(), candidate.name.as_bytes())
}) {
Ok(()) => fsync_mutation(&shard, ReconciliationMutation::Deleted),
Err(ArtifactError::NotFound) => {
fsync_mutation(&shard, ReconciliationMutation::AlreadyAbsent)
}
Err(error) => Err(error),
}
}
fn recover_quarantine(
&self,
root: &crate::store::Root,
candidate: &ReconciliationCandidate,
) -> Result<ReconciliationMutation, ArtifactError> {
let final_root = open_existing_dir(root.fd.as_raw_fd(), namespace_name(FINAL_NAMESPACE))?;
let final_shard = open_existing_dir(final_root.as_raw_fd(), candidate.shard.as_bytes())?;
let quarantine_root = match open_existing_dir(root.fd.as_raw_fd(), QUARANTINE_DIR) {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => return Err(ArtifactError::NotFound),
Err(error) => return Err(error),
};
let shard = match open_existing_dir(quarantine_root.as_raw_fd(), candidate.shard.as_bytes())
{
Ok(fd) => fd,
Err(ArtifactError::NotFound) => return Err(ArtifactError::NotFound),
Err(error) => return Err(error),
};
revalidate_moved_candidate(candidate, shard.as_raw_fd())?;
match fsync_quarantine_dirs(&final_shard, &shard) {
Ok(()) => Ok(ReconciliationMutation::AlreadyQuarantined),
Err(_) => Ok(ReconciliationMutation::Retryable),
}
}
/// Streams temporary entries, reading at most `scan_budget` directory
/// entries and returning at most `result_limit` stale capabilities.
pub fn scan_stale_temps(
&self,
grace: Duration,
scan_budget: usize,
result_limit: usize,
) -> Result<(TempScan, Vec<StaleTemp>), ArtifactError> {
let root = self.root()?;
let _lock = RootLock::shared(root)?;
ensure_root_unchanged(root)?;
let sha = match open_existing_dir(root.fd.as_raw_fd(), b"sha256") {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => return Ok((TempScan::default(), Vec::new())),
Err(error) => return Err(error),
};
let mut report = TempScan::default();
let mut remaining = scan_budget;
let mut result = Vec::new();
for shard in (0_u8..=255).map(|value| format!("{value:02x}")) {
if remaining == 0 {
break;
}
let shard_fd = match open_existing_dir(sha.as_raw_fd(), shard.as_bytes()) {
Ok(fd) => fd,
Err(ArtifactError::NotFound) => continue,
Err(error) => return Err(error),
};
for name in list_names(shard_fd.as_raw_fd(), &mut remaining, &mut report)? {
if !valid_temp_name(&name) {
continue;
}
#[cfg(debug_assertions)]
let _ = crate::test_support::checkpoint("housekeeping_before_stat");
let stat = match nofollow_stat(shard_fd.as_raw_fd(), &name) {
Ok(stat) => stat,
Err(ArtifactError::NotFound) => continue,
Err(error) => return Err(error),
};
if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG || stat.st_nlink != 1 {
continue;
}
let modified = UNIX_EPOCH
.checked_add(Duration::new(
stat.st_mtime.max(0) as u64,
stat.st_mtime_nsec.max(0) as u32,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
if SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age >= grace)
{
if result.len() == result_limit {
report.omitted += 1;
continue;
}
result.push(StaleTemp {
name,
shard: shard.clone(),
root_dev: root.dev,
root_ino: root.ino,
dev: stat.st_dev,
ino: stat.st_ino,
modified_seconds: stat.st_mtime,
modified_nanoseconds: stat.st_mtime_nsec,
grace,
});
}
}
}
Ok((report, result))
}
/// Deletes only a previously discovered temporary inode after an exclusive
/// cooperative root lock and inode revalidation, then fsyncs its shard.
pub fn delete_stale_temp(&self, candidate: StaleTemp) -> Result<(), ArtifactError> {
let root = self.root()?;
let _lock = RootLock::exclusive(root)?;
ensure_root_unchanged(root)?;
if candidate.root_dev != root.dev || candidate.root_ino != root.ino {
return Err(ArtifactError::UnsafeRoot);
}
if !valid_temp_name(&candidate.name)
|| candidate.shard.len() != 2
|| !candidate.shard.bytes().all(is_lower_hex)
{
return Err(ArtifactError::UnsafeRoot);
}
let sha = open_existing_dir(root.fd.as_raw_fd(), b"sha256")?;
let shard = open_existing_dir(sha.as_raw_fd(), candidate.shard.as_bytes())?;
let stat = nofollow_stat(shard.as_raw_fd(), &candidate.name)?;
if stat.st_dev != candidate.dev
|| stat.st_ino != candidate.ino
|| stat.st_mtime != candidate.modified_seconds
|| stat.st_mtime_nsec != candidate.modified_nanoseconds
|| (stat.st_mode & libc::S_IFMT) != libc::S_IFREG
|| stat.st_nlink != 1
{
return Err(ArtifactError::UnsafeRoot);
}
let modified = UNIX_EPOCH
.checked_add(Duration::new(
stat.st_mtime.max(0) as u64,
stat.st_mtime_nsec.max(0) as u32,
))
.unwrap_or(SystemTime::UNIX_EPOCH);
if !SystemTime::now()
.duration_since(modified)
.is_ok_and(|age| age >= candidate.grace)
{
return Err(ArtifactError::UnsafeRoot);
}
unlinkat(shard.as_raw_fd(), candidate.name.as_bytes())?;
fsync_fd(shard.as_raw_fd())
}
}
fn reconciliation_cursor(
root_dev: u64,
root_ino: u64,
namespace: u8,
shard: u8,
position: i64,
) -> ReconciliationCursor {
ReconciliationCursor {
root_dev,
root_ino,
namespace,
shard,
position,
}
}
fn namespace_name(namespace: u8) -> &'static [u8] {
match namespace {
FINAL_NAMESPACE => b"sha256",
QUARANTINE_NAMESPACE => QUARANTINE_DIR,
_ => unreachable!("validated reconciliation namespace"),
}
}
fn advance_shard(cursor: &mut ReconciliationCursor) {
debug_assert_ne!(cursor.shard, u8::MAX);
cursor.shard += 1;
cursor.position = 0;
}
fn advance_namespace(cursor: &mut ReconciliationCursor) {
cursor.namespace = cursor.namespace.saturating_add(1);
cursor.shard = 0;
cursor.position = 0;
}
fn consume_scan_work(report: &mut ReconciliationScan, remaining: &mut usize) {
debug_assert!(*remaining > 0);
*remaining -= 1;
report.scanned += 1;
}
fn ensure_reconciliation_root(
root: &crate::store::Root,
candidate: &ReconciliationCandidate,
) -> Result<(), ArtifactError> {
ensure_root_unchanged(root)?;
if candidate.root_dev != root.dev
|| candidate.root_ino != root.ino
|| candidate.shard.len() != 2
|| !candidate.shard.bytes().all(is_lower_hex)
|| !valid_final_name(&candidate.name, &candidate.shard)
{
return Err(ArtifactError::UnsafeRoot);
}
Ok(())
}
fn revalidate_candidate(
candidate: &ReconciliationCandidate,
shard: i32,
) -> Result<(), ArtifactError> {
let shard_stat = stat_fd(shard)?;
if shard_stat.st_dev != candidate.shard_dev || shard_stat.st_ino != candidate.shard_ino {
return Err(ArtifactError::UnsafeRoot);
}
let stat = nofollow_stat(shard, &candidate.name)?;
if stat.st_dev != candidate.dev
|| stat.st_ino != candidate.ino
|| stat.st_mtime != candidate.modified_seconds
|| stat.st_mtime_nsec != candidate.modified_nanoseconds
{
return Err(ArtifactError::UnsafeRoot);
}
check_file(&stat).map_err(|_| ArtifactError::UnsafeRoot)
}
/// Recovery follows a successful cross-directory rename. The candidate remains
/// bound to the original final shard, so the quarantine shard identity cannot
/// equal its recorded shard identity; only the canonical name and inode are
/// revalidated here.
fn revalidate_moved_candidate(
candidate: &ReconciliationCandidate,
shard: i32,
) -> Result<(), ArtifactError> {
let stat = nofollow_stat(shard, &candidate.name)?;
if stat.st_dev != candidate.dev
|| stat.st_ino != candidate.ino
|| stat.st_mtime != candidate.modified_seconds
|| stat.st_mtime_nsec != candidate.modified_nanoseconds
{
return Err(ArtifactError::UnsafeRoot);
}
check_file(&stat).map_err(|_| ArtifactError::UnsafeRoot)
}
fn fsync_mutation(
shard: &std::os::fd::OwnedFd,
success: ReconciliationMutation,
) -> Result<ReconciliationMutation, ArtifactError> {
match checkpointed("reconciliation_delete_fsync", || {
fsync_fd(shard.as_raw_fd())
}) {
Ok(()) => Ok(success),
Err(_) => Ok(ReconciliationMutation::Retryable),
}
}
/// Persist both directory entry changes even if the first fsync reports an
/// error. Either failure remains an ambiguity, but skipping the second fsync
/// would needlessly enlarge the crash window.
fn fsync_quarantine_dirs(
final_shard: &std::os::fd::OwnedFd,
quarantine_shard: &std::os::fd::OwnedFd,
) -> Result<(), ArtifactError> {
let source = checkpointed("reconciliation_quarantine_source_fsync", || {
fsync_fd(final_shard.as_raw_fd())
});
let destination = checkpointed("reconciliation_quarantine_destination_fsync", || {
fsync_fd(quarantine_shard.as_raw_fd())
});
source.and(destination)
}
fn valid_final_name(name: &str, shard: &str) -> bool {
name.len() == 64 && name.starts_with(shard) && name.bytes().all(is_lower_hex)
}
struct DirectoryStream {
directory: *mut libc::DIR,
}
impl DirectoryStream {
fn open(parent: i32, position: i64) -> Result<Self, ArtifactError> {
let duplicate = crate::store::duplicate_fd(parent)?;
let raw = std::os::fd::IntoRawFd::into_raw_fd(duplicate);
let directory = unsafe { libc::fdopendir(raw) };
if directory.is_null() {
unsafe {
libc::close(raw);
}
return Err(ArtifactError::Storage);
}
if position != 0 {
unsafe {
libc::seekdir(directory, position as libc::c_long);
}
}
Ok(Self { directory })
}
fn next(&mut self) -> Result<Option<(Vec<u8>, i64)>, ArtifactError> {
loop {
unsafe {
*libc::__errno_location() = 0;
}
let entry = unsafe { libc::readdir(self.directory) };
if entry.is_null() {
if unsafe { *libc::__errno_location() } != 0 {
return Err(ArtifactError::Storage);
}
return Ok(None);
}
let after = unsafe { libc::telldir(self.directory) } as i64;
let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
if name == b"." || name == b".." {
continue;
}
return Ok(Some((name.to_vec(), after)));
}
}
}
impl Drop for DirectoryStream {
fn drop(&mut self) {
unsafe {
libc::closedir(self.directory);
}
}
}
fn list_names(
parent: i32,
remaining: &mut usize,
report: &mut TempScan,
) -> Result<Vec<String>, ArtifactError> {
let dot = std::ffi::CString::new(".").map_err(|_| ArtifactError::Storage)?;
let raw = unsafe {
libc::openat(
parent,
dot.as_ptr(),
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
)
};
if raw < 0 {
return Err(ArtifactError::Storage);
}
let directory = unsafe { libc::fdopendir(raw) };
if directory.is_null() {
unsafe {
libc::close(raw);
}
return Err(ArtifactError::Storage);
}
let mut names = Vec::new();
loop {
if *remaining == 0 {
break;
}
unsafe {
*libc::__errno_location() = 0;
}
let entry = unsafe { libc::readdir(directory) };
if entry.is_null() {
if unsafe { *libc::__errno_location() } != 0 {
unsafe {
libc::closedir(directory);
}
return Err(ArtifactError::Storage);
}
break;
}
let name = unsafe { std::ffi::CStr::from_ptr((*entry).d_name.as_ptr()) }.to_bytes();
if name == b"." || name == b".." {
continue;
}
report.scanned += 1;
*remaining -= 1;
if let Ok(name) = std::str::from_utf8(name) {
names.push(name.to_owned());
}
}
unsafe {
libc::closedir(directory);
}
Ok(names)
}
fn valid_temp_name(name: &str) -> bool {
let Some(rest) = name.strip_prefix(ArtifactStore::temp_prefix()) else {
return false;
};
let mut fields = rest.split('-');
let (Some(nonce), Some(pid), Some(sequence), None) =
(fields.next(), fields.next(), fields.next(), fields.next())
else {
return false;
};
nonce.len() == 32
&& nonce.bytes().all(is_lower_hex)
&& !pid.is_empty()
&& pid.bytes().all(|byte| byte.is_ascii_digit())
&& !sequence.is_empty()
&& sequence.bytes().all(|byte| byte.is_ascii_digit())
}
fn is_lower_hex(byte: u8) -> bool {
byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
}
fn nofollow_stat(parent: i32, name: &str) -> Result<libc::stat, ArtifactError> {
let name = crate::store::c_name(name.as_bytes())?;
let mut stat = unsafe { std::mem::zeroed() };
if unsafe { libc::fstatat(parent, name.as_ptr(), &mut stat, libc::AT_SYMLINK_NOFOLLOW) } == 0 {
Ok(stat)
} else {
Err(crate::store::classify_errno())
}
}