feat(artifacts): add immutable artifact store

This commit is contained in:
2026-08-25 18:06:33 +03:00
parent 182bde8ac0
commit 497e1b740f
13 changed files with 2198 additions and 3 deletions
+234
View File
@@ -0,0 +1,234 @@
use std::{
os::fd::AsRawFd,
time::{Duration, SystemTime, UNIX_EPOCH},
};
use crate::{
ArtifactError, ArtifactStore,
store::{RootLock, ensure_root_unchanged, fsync_fd, open_existing_dir, unlinkat},
};
/// 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 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 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())
}
}