feat(artifacts): add immutable artifact store
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
use thiserror::Error;
|
||||
|
||||
/// Stable, redacted failures from the artifact boundary.
|
||||
///
|
||||
/// This intentionally carries neither an operating-system source nor a path:
|
||||
/// those details belong only in the composing process's private logs.
|
||||
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
|
||||
pub enum ArtifactError {
|
||||
#[error("artifact reference is invalid")]
|
||||
InvalidReference,
|
||||
#[error("artifact source is empty")]
|
||||
EmptySource,
|
||||
#[error("artifact source exceeds the configured maximum")]
|
||||
SourceTooLarge,
|
||||
#[error("artifact was not found")]
|
||||
NotFound,
|
||||
#[error("artifact integrity verification failed")]
|
||||
Integrity,
|
||||
#[error("artifact storage root or entry is unsafe")]
|
||||
UnsafeRoot,
|
||||
#[error("artifact storage is unavailable")]
|
||||
Storage,
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
//! The legacy reader lives on [`crate::ArtifactStore`] so it shares the same
|
||||
//! pinned-FD and digest-verification primitives as ordinary reads.
|
||||
@@ -0,0 +1,23 @@
|
||||
//! A small, Linux-only immutable content-addressed artifact store.
|
||||
//!
|
||||
//! The directory file descriptor opened by [`ArtifactStore::open`] is the
|
||||
//! authority for all operations. Paths supplied by callers are never used for
|
||||
//! blob I/O, and the public API deliberately exposes only opaque digests.
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
compile_error!("crank-artifacts requires Linux");
|
||||
|
||||
mod error;
|
||||
mod housekeeping;
|
||||
mod legacy;
|
||||
mod model;
|
||||
mod store;
|
||||
|
||||
#[cfg(debug_assertions)]
|
||||
#[doc(hidden)]
|
||||
pub mod test_support;
|
||||
|
||||
pub use error::ArtifactError;
|
||||
pub use housekeeping::{StaleTemp, TempScan};
|
||||
pub use model::{ArtifactRef, MAX_ARTIFACT_BYTES, MAX_SOURCE_BYTES, StoredArtifact};
|
||||
pub use store::ArtifactStore;
|
||||
@@ -0,0 +1,68 @@
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
use crate::ArtifactError;
|
||||
|
||||
/// The largest source accepted by the OpenAPI ingress contract.
|
||||
pub const MAX_ARTIFACT_BYTES: usize = 256 * 1024;
|
||||
/// Backwards-compatible name for the source-upload limit.
|
||||
pub const MAX_SOURCE_BYTES: usize = MAX_ARTIFACT_BYTES;
|
||||
const PREFIX: &str = "sha256:";
|
||||
|
||||
/// Canonical, opaque SHA-256 reference (`sha256:` plus 64 lowercase hex
|
||||
/// characters). It cannot represent a filesystem name or path.
|
||||
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||
pub struct ArtifactRef(String);
|
||||
|
||||
impl ArtifactRef {
|
||||
pub fn parse(value: &str) -> Result<Self, ArtifactError> {
|
||||
let digest = value
|
||||
.strip_prefix(PREFIX)
|
||||
.ok_or(ArtifactError::InvalidReference)?;
|
||||
Self::from_digest_hex(digest)
|
||||
}
|
||||
|
||||
pub fn from_digest_hex(digest: &str) -> Result<Self, ArtifactError> {
|
||||
if digest.len() != 64
|
||||
|| !digest
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte))
|
||||
{
|
||||
return Err(ArtifactError::InvalidReference);
|
||||
}
|
||||
Ok(Self(format!("{PREFIX}{digest}")))
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn digest_hex(&self) -> &str {
|
||||
&self.0[PREFIX.len()..]
|
||||
}
|
||||
|
||||
pub(crate) fn shard(&self) -> &str {
|
||||
// Construction proves the digest has 64 ASCII bytes.
|
||||
&self.digest_hex()[..2]
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for ArtifactRef {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ArtifactRef {
|
||||
type Err = ArtifactError;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub struct StoredArtifact {
|
||||
pub artifact_ref: ArtifactRef,
|
||||
pub size_bytes: usize,
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
use std::{
|
||||
ffi::CString,
|
||||
fs::File,
|
||||
io::{self, Read, Write},
|
||||
os::{
|
||||
fd::{AsRawFd, FromRawFd, OwnedFd},
|
||||
unix::ffi::OsStrExt,
|
||||
},
|
||||
path::{Component, Path},
|
||||
sync::atomic::{AtomicU64, Ordering},
|
||||
};
|
||||
|
||||
use rand::random;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{ArtifactError, ArtifactRef, MAX_ARTIFACT_BYTES, StoredArtifact};
|
||||
|
||||
const SHA_DIR: &[u8] = b"sha256";
|
||||
const TEMP_PREFIX: &str = ".crank-artifact-tmp-v1-";
|
||||
const RESOLVE_NO_SYMLINKS: u64 = 0x04;
|
||||
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Root {
|
||||
pub(crate) fd: OwnedFd,
|
||||
pub(crate) dev: u64,
|
||||
pub(crate) ino: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct LegacyRoot {
|
||||
root: Root,
|
||||
configured_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
/// Immutable content-addressed store anchored to one trusted directory inode.
|
||||
#[derive(Debug)]
|
||||
pub struct ArtifactStore {
|
||||
root: Result<Root, ArtifactError>,
|
||||
legacy: Option<Result<LegacyRoot, ArtifactError>>,
|
||||
nonce: u128,
|
||||
}
|
||||
|
||||
impl Clone for ArtifactStore {
|
||||
fn clone(&self) -> Self {
|
||||
// A fresh open file description makes flock coordination independent
|
||||
// between clones while retaining the original directory authority.
|
||||
let root = self
|
||||
.root
|
||||
.as_ref()
|
||||
.map_err(|error| *error)
|
||||
.and_then(|root| duplicate_root(root).map_err(|_| ArtifactError::Storage));
|
||||
let legacy = self.legacy.as_ref().map(|legacy| {
|
||||
legacy.as_ref().map_err(|error| *error).and_then(|legacy| {
|
||||
duplicate_root(&legacy.root)
|
||||
.map(|root| LegacyRoot {
|
||||
root,
|
||||
configured_path: legacy.configured_path.clone(),
|
||||
})
|
||||
.map_err(|_| ArtifactError::Storage)
|
||||
})
|
||||
});
|
||||
Self {
|
||||
root,
|
||||
legacy,
|
||||
nonce: self.nonce,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ArtifactStore {
|
||||
/// Opens an already provisioned, private artifact root. The root is never
|
||||
/// reopened by pathname after this point.
|
||||
pub fn open(root: impl AsRef<Path>) -> Result<Self, ArtifactError> {
|
||||
Ok(Self {
|
||||
root: Ok(open_trusted_root(root.as_ref())?),
|
||||
legacy: None,
|
||||
nonce: random(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compatibility constructor. I/O returns the opening error fail-closed.
|
||||
pub fn new(root: impl AsRef<Path>) -> Self {
|
||||
Self {
|
||||
root: open_trusted_root(root.as_ref()),
|
||||
legacy: None,
|
||||
nonce: random(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Enables the explicit, read-only compatibility reader for one pinned
|
||||
/// legacy root. It never migrates, rewrites, or deletes legacy files.
|
||||
pub fn with_legacy_root(mut self, root: impl AsRef<Path>) -> Self {
|
||||
let configured_path = normalize_absolute_path(root.as_ref());
|
||||
self.legacy = Some(configured_path.and_then(|configured_path| {
|
||||
open_trusted_root(root.as_ref()).map(|root_fd| LegacyRoot {
|
||||
root: root_fd,
|
||||
configured_path,
|
||||
})
|
||||
}));
|
||||
self
|
||||
}
|
||||
|
||||
/// Stores a complete bounded byte slice durably and returns its SHA-256 ref.
|
||||
pub fn put(&self, bytes: &[u8]) -> Result<StoredArtifact, ArtifactError> {
|
||||
self.put_reader(&mut io::Cursor::new(bytes))
|
||||
}
|
||||
|
||||
/// Historical spelling retained for source compatibility; bytes are not
|
||||
/// restricted to UTF-8 at this filesystem boundary.
|
||||
pub fn put_utf8(&self, bytes: &[u8]) -> Result<StoredArtifact, ArtifactError> {
|
||||
self.put(bytes)
|
||||
}
|
||||
|
||||
/// Streams at most [`MAX_ARTIFACT_BYTES`] from `reader`, detecting an
|
||||
/// oversized source before publication even with short or interrupted reads.
|
||||
pub fn put_reader(&self, reader: &mut impl Read) -> Result<StoredArtifact, ArtifactError> {
|
||||
let mut bytes = Vec::with_capacity(8 * 1024);
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buffer = [0_u8; 8192];
|
||||
loop {
|
||||
match reader.read(&mut buffer) {
|
||||
Ok(0) => break,
|
||||
Ok(count) => {
|
||||
if bytes
|
||||
.len()
|
||||
.checked_add(count)
|
||||
.ok_or(ArtifactError::SourceTooLarge)?
|
||||
> MAX_ARTIFACT_BYTES
|
||||
{
|
||||
return Err(ArtifactError::SourceTooLarge);
|
||||
}
|
||||
hasher.update(&buffer[..count]);
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
}
|
||||
Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(_) => return Err(ArtifactError::Storage),
|
||||
}
|
||||
}
|
||||
if bytes.is_empty() {
|
||||
return Err(ArtifactError::EmptySource);
|
||||
}
|
||||
let artifact_ref = ArtifactRef::from_digest_hex(&format!("{:x}", hasher.finalize()))?;
|
||||
let root = self.root()?;
|
||||
let _lock = RootLock::shared(root)?;
|
||||
ensure_root_unchanged(root)?;
|
||||
let sha = open_or_create_dir(root.fd.as_raw_fd(), SHA_DIR)?;
|
||||
let shard = open_or_create_dir(sha.as_raw_fd(), artifact_ref.shard().as_bytes())?;
|
||||
self.publish(root, shard.as_raw_fd(), &artifact_ref, &bytes)?;
|
||||
Ok(StoredArtifact {
|
||||
artifact_ref,
|
||||
size_bytes: bytes.len(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Reads a blob only after validating its inode properties, bounded length,
|
||||
/// and complete SHA-256 digest.
|
||||
pub fn read(&self, artifact_ref: &ArtifactRef) -> Result<Vec<u8>, ArtifactError> {
|
||||
let root = self.root()?;
|
||||
let _lock = RootLock::shared(root)?;
|
||||
ensure_root_unchanged(root)?;
|
||||
let sha = open_existing_dir(root.fd.as_raw_fd(), SHA_DIR)?;
|
||||
let shard = open_existing_dir(sha.as_raw_fd(), artifact_ref.shard().as_bytes())?;
|
||||
let fd = open_existing_file(shard.as_raw_fd(), artifact_ref.digest_hex().as_bytes())?;
|
||||
read_verified(&fd, artifact_ref)
|
||||
}
|
||||
|
||||
/// Digest-verified `file://` compatibility read below the explicitly
|
||||
/// configured legacy root. A URL cannot select another filesystem root.
|
||||
pub fn read_legacy_file_url(
|
||||
&self,
|
||||
value: &str,
|
||||
expected: &ArtifactRef,
|
||||
) -> Result<Vec<u8>, ArtifactError> {
|
||||
let legacy = self
|
||||
.legacy
|
||||
.as_ref()
|
||||
.ok_or(ArtifactError::UnsafeRoot)?
|
||||
.as_ref()
|
||||
.map_err(|error| *error)?;
|
||||
let _lock = RootLock::shared(&legacy.root)?;
|
||||
ensure_root_unchanged(&legacy.root)?;
|
||||
let url = url::Url::parse(value).map_err(|_| ArtifactError::InvalidReference)?;
|
||||
if url.scheme() != "file" || url.host_str().is_some() {
|
||||
return Err(ArtifactError::InvalidReference);
|
||||
}
|
||||
let candidate = url
|
||||
.to_file_path()
|
||||
.map_err(|_| ArtifactError::InvalidReference)?;
|
||||
let relative = candidate
|
||||
.strip_prefix(&legacy.configured_path)
|
||||
.map_err(|_| ArtifactError::UnsafeRoot)?;
|
||||
let mut fd = duplicate_fd(legacy.root.fd.as_raw_fd())?;
|
||||
let mut components = relative.components().peekable();
|
||||
while let Some(component) = components.next() {
|
||||
let Component::Normal(name) = component else {
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
};
|
||||
fd = if components.peek().is_some() {
|
||||
open_legacy_dir(fd.as_raw_fd(), name.as_bytes())?
|
||||
} else {
|
||||
open_legacy_file(fd.as_raw_fd(), name.as_bytes())?
|
||||
};
|
||||
}
|
||||
read_legacy_verified(&fd, expected)
|
||||
}
|
||||
|
||||
fn publish(
|
||||
&self,
|
||||
root: &Root,
|
||||
shard_fd: i32,
|
||||
artifact_ref: &ArtifactRef,
|
||||
bytes: &[u8],
|
||||
) -> Result<(), ArtifactError> {
|
||||
let final_name = artifact_ref.digest_hex().as_bytes();
|
||||
match open_existing_file(shard_fd, final_name) {
|
||||
Ok(file) => {
|
||||
let existing = read_verified(&file, artifact_ref)?;
|
||||
if existing == bytes {
|
||||
checkpointed("dedupe_directory_fsync", || fsync_fd(shard_fd))?;
|
||||
return Ok(());
|
||||
}
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
Err(ArtifactError::NotFound) => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
|
||||
let temp_name = self.temp_name();
|
||||
let temp = create_temp(shard_fd, temp_name.as_bytes())?;
|
||||
let result = (|| {
|
||||
checkpointed("write", || write_all(&temp, bytes))?;
|
||||
checkpointed("file_fsync", || fsync_fd(temp.as_raw_fd()))?;
|
||||
chmod_read_only(temp.as_raw_fd())?;
|
||||
fsync_fd(temp.as_raw_fd())?;
|
||||
ensure_root_unchanged(root)?;
|
||||
match checkpointed("publish", || {
|
||||
rename_no_replace(shard_fd, temp_name.as_bytes(), final_name)
|
||||
})? {
|
||||
true => checkpointed("directory_fsync", || fsync_fd(shard_fd)),
|
||||
false => {
|
||||
let file = open_existing_file(shard_fd, final_name)?;
|
||||
let existing = read_verified(&file, artifact_ref)?;
|
||||
if existing == bytes {
|
||||
checkpointed("dedupe_directory_fsync", || fsync_fd(shard_fd))
|
||||
} else {
|
||||
Err(ArtifactError::Integrity)
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
let _ = unlinkat(shard_fd, temp_name.as_bytes());
|
||||
result
|
||||
}
|
||||
|
||||
pub(crate) fn root(&self) -> Result<&Root, ArtifactError> {
|
||||
self.root.as_ref().map_err(|error| *error)
|
||||
}
|
||||
pub(crate) fn temp_prefix() -> &'static str {
|
||||
TEMP_PREFIX
|
||||
}
|
||||
pub(crate) fn temp_name(&self) -> String {
|
||||
format!(
|
||||
"{TEMP_PREFIX}{:032x}-{}-{}",
|
||||
self.nonce,
|
||||
std::process::id(),
|
||||
TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_absolute_path(path: &Path) -> Result<std::path::PathBuf, ArtifactError> {
|
||||
if !path.is_absolute() {
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
}
|
||||
let mut normalized = std::path::PathBuf::from("/");
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::RootDir | Component::CurDir => {}
|
||||
Component::Normal(part) => normalized.push(part),
|
||||
Component::ParentDir => {
|
||||
if !normalized.pop() {
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
}
|
||||
}
|
||||
Component::Prefix(_) => return Err(ArtifactError::UnsafeRoot),
|
||||
}
|
||||
}
|
||||
Ok(normalized)
|
||||
}
|
||||
|
||||
pub(crate) fn c_name(value: &[u8]) -> Result<CString, ArtifactError> {
|
||||
CString::new(value).map_err(|_| ArtifactError::InvalidReference)
|
||||
}
|
||||
pub(crate) fn duplicate_fd(fd: i32) -> Result<OwnedFd, ArtifactError> {
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate < 0 {
|
||||
Err(ArtifactError::Storage)
|
||||
} else {
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(duplicate) })
|
||||
}
|
||||
}
|
||||
fn duplicate_root(root: &Root) -> Result<Root, ArtifactError> {
|
||||
let dot = c_name(b".")?;
|
||||
let raw = unsafe {
|
||||
libc::openat(
|
||||
root.fd.as_raw_fd(),
|
||||
dot.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if raw < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
Ok(Root {
|
||||
fd,
|
||||
dev: root.dev,
|
||||
ino: root.ino,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_trusted_root(path: &Path) -> Result<Root, ArtifactError> {
|
||||
let path = CString::new(path.as_os_str().as_bytes()).map_err(|_| ArtifactError::UnsafeRoot)?;
|
||||
let how = OpenHow {
|
||||
flags: (libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW) as u64,
|
||||
mode: 0,
|
||||
resolve: RESOLVE_NO_SYMLINKS,
|
||||
};
|
||||
let fd = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_openat2,
|
||||
libc::AT_FDCWD,
|
||||
path.as_ptr(),
|
||||
&how,
|
||||
std::mem::size_of::<OpenHow>(),
|
||||
) as i32
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
let stat = stat_fd(fd.as_raw_fd())?;
|
||||
check_private_dir(&stat)?;
|
||||
Ok(Root {
|
||||
fd,
|
||||
dev: stat.st_dev,
|
||||
ino: stat.st_ino,
|
||||
})
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct OpenHow {
|
||||
flags: u64,
|
||||
mode: u64,
|
||||
resolve: u64,
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_root_unchanged(root: &Root) -> Result<(), ArtifactError> {
|
||||
let stat = stat_fd(root.fd.as_raw_fd())?;
|
||||
if stat.st_dev != root.dev || stat.st_ino != root.ino {
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
}
|
||||
check_private_dir(&stat)
|
||||
}
|
||||
fn check_private_dir(stat: &libc::stat) -> Result<(), ArtifactError> {
|
||||
if stat.st_uid != unsafe { libc::geteuid() }
|
||||
|| (stat.st_mode & libc::S_IFMT) != libc::S_IFDIR
|
||||
|| (stat.st_mode & 0o777) != 0o700
|
||||
{
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn stat_fd(fd: i32) -> Result<libc::stat, ArtifactError> {
|
||||
let mut stat = unsafe { std::mem::zeroed() };
|
||||
if unsafe { libc::fstat(fd, &mut stat) } != 0 {
|
||||
Err(ArtifactError::Storage)
|
||||
} else {
|
||||
Ok(stat)
|
||||
}
|
||||
}
|
||||
fn check_file(stat: &libc::stat) -> Result<(), ArtifactError> {
|
||||
if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG
|
||||
|| stat.st_uid != unsafe { libc::geteuid() }
|
||||
|| stat.st_nlink != 1
|
||||
|| (stat.st_mode & 0o222) != 0
|
||||
{
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(crate) fn open_or_create_dir(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let fd = match open_existing_dir(parent, name) {
|
||||
Ok(fd) => fd,
|
||||
Err(ArtifactError::NotFound) => {
|
||||
let name = c_name(name)?;
|
||||
let code = unsafe { libc::mkdirat(parent, name.as_ptr(), 0o700) };
|
||||
if code != 0 && unsafe { *libc::__errno_location() } != libc::EEXIST {
|
||||
return Err(ArtifactError::Storage);
|
||||
}
|
||||
open_existing_dir(parent, name.as_bytes())?
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
checkpointed("mkdir_parent_fsync", || fsync_fd(parent))?;
|
||||
Ok(fd)
|
||||
}
|
||||
pub(crate) fn open_existing_dir(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
check_private_dir(&stat_fd(fd.as_raw_fd())?)?;
|
||||
Ok(fd)
|
||||
}
|
||||
pub(crate) fn open_existing_file(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
|
||||
check_file(&stat_fd(fd.as_raw_fd())?)?;
|
||||
Ok(fd)
|
||||
}
|
||||
fn open_legacy_dir(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
let raw = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||
)
|
||||
};
|
||||
if raw < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
if (stat_fd(fd.as_raw_fd())?.st_mode & libc::S_IFMT) != libc::S_IFDIR {
|
||||
return Err(ArtifactError::UnsafeRoot);
|
||||
}
|
||||
Ok(fd)
|
||||
}
|
||||
fn open_legacy_file(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
let raw = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK,
|
||||
)
|
||||
};
|
||||
if raw < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(raw) };
|
||||
if (stat_fd(fd.as_raw_fd())?.st_mode & libc::S_IFMT) != libc::S_IFREG {
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
Ok(fd)
|
||||
}
|
||||
fn create_temp(parent: i32, name: &[u8]) -> Result<OwnedFd, ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
let fd = unsafe {
|
||||
libc::openat(
|
||||
parent,
|
||||
name.as_ptr(),
|
||||
libc::O_WRONLY | libc::O_CREAT | libc::O_EXCL | libc::O_CLOEXEC | libc::O_NOFOLLOW,
|
||||
0o600,
|
||||
)
|
||||
};
|
||||
if fd < 0 {
|
||||
return Err(classify_errno());
|
||||
}
|
||||
Ok(unsafe { OwnedFd::from_raw_fd(fd) })
|
||||
}
|
||||
fn write_all(fd: &OwnedFd, bytes: &[u8]) -> Result<(), ArtifactError> {
|
||||
let mut file = File::from(duplicate_fd(fd.as_raw_fd())?);
|
||||
let result = file.write_all(bytes).map_err(|_| ArtifactError::Storage);
|
||||
drop(file);
|
||||
result
|
||||
}
|
||||
|
||||
/// Debug/test-only fault seam coupled to one syscall wrapper. `Fail` models a
|
||||
/// syscall that did not run; crash/hold actions happen only after success.
|
||||
fn checkpointed<T>(
|
||||
stage: &str,
|
||||
operation: impl FnOnce() -> Result<T, ArtifactError>,
|
||||
) -> Result<T, ArtifactError> {
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if crate::test_support::action(stage) == Some(crate::test_support::FaultAction::Fail) {
|
||||
return Err(ArtifactError::Storage);
|
||||
}
|
||||
}
|
||||
let result = operation()?;
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
match crate::test_support::checkpoint(stage) {
|
||||
Some(crate::test_support::FaultAction::Exit) => {
|
||||
// Do not run Rust destructors: this models abrupt process
|
||||
// death, rather than an ordinary in-process error path.
|
||||
unsafe { libc::_exit(86) };
|
||||
}
|
||||
Some(crate::test_support::FaultAction::Fail) => unreachable!("handled before syscall"),
|
||||
Some(crate::test_support::FaultAction::Hold) => {}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
let _ = stage;
|
||||
Ok(result)
|
||||
}
|
||||
pub(crate) fn read_verified(
|
||||
fd: &OwnedFd,
|
||||
expected: &ArtifactRef,
|
||||
) -> Result<Vec<u8>, ArtifactError> {
|
||||
check_file(&stat_fd(fd.as_raw_fd())?)?;
|
||||
let length =
|
||||
usize::try_from(stat_fd(fd.as_raw_fd())?.st_size).map_err(|_| ArtifactError::Integrity)?;
|
||||
if length == 0 || length > MAX_ARTIFACT_BYTES {
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
let mut file = File::from(duplicate_fd(fd.as_raw_fd())?);
|
||||
let mut bytes = Vec::with_capacity(length);
|
||||
let result = Read::take(&mut file, (MAX_ARTIFACT_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| ArtifactError::Storage);
|
||||
drop(file);
|
||||
result?;
|
||||
if bytes.len() > MAX_ARTIFACT_BYTES
|
||||
|| bytes.len() != length
|
||||
|| format!("{:x}", Sha256::digest(&bytes)) != expected.digest_hex()
|
||||
{
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
fn read_legacy_verified(fd: &OwnedFd, expected: &ArtifactRef) -> Result<Vec<u8>, ArtifactError> {
|
||||
let stat = stat_fd(fd.as_raw_fd())?;
|
||||
if (stat.st_mode & libc::S_IFMT) != libc::S_IFREG {
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
read_bounded_digest(fd, expected, stat.st_size)
|
||||
}
|
||||
|
||||
fn read_bounded_digest(
|
||||
fd: &OwnedFd,
|
||||
expected: &ArtifactRef,
|
||||
raw_length: libc::off_t,
|
||||
) -> Result<Vec<u8>, ArtifactError> {
|
||||
let length = usize::try_from(raw_length).map_err(|_| ArtifactError::Integrity)?;
|
||||
if length == 0 || length > MAX_ARTIFACT_BYTES {
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
let mut file = File::from(duplicate_fd(fd.as_raw_fd())?);
|
||||
let mut bytes = Vec::with_capacity(length);
|
||||
let result = Read::take(&mut file, (MAX_ARTIFACT_BYTES + 1) as u64)
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|_| ArtifactError::Storage);
|
||||
drop(file);
|
||||
result?;
|
||||
if bytes.len() > MAX_ARTIFACT_BYTES
|
||||
|| bytes.len() != length
|
||||
|| format!("{:x}", Sha256::digest(&bytes)) != expected.digest_hex()
|
||||
{
|
||||
return Err(ArtifactError::Integrity);
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
pub(crate) fn fsync_fd(fd: i32) -> Result<(), ArtifactError> {
|
||||
if unsafe { libc::fsync(fd) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ArtifactError::Storage)
|
||||
}
|
||||
}
|
||||
fn chmod_read_only(fd: i32) -> Result<(), ArtifactError> {
|
||||
if unsafe { libc::fchmod(fd, 0o400) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ArtifactError::Storage)
|
||||
}
|
||||
}
|
||||
fn rename_no_replace(parent: i32, old: &[u8], new: &[u8]) -> Result<bool, ArtifactError> {
|
||||
let old = c_name(old)?;
|
||||
let new = c_name(new)?;
|
||||
let result = unsafe {
|
||||
libc::syscall(
|
||||
libc::SYS_renameat2,
|
||||
parent,
|
||||
old.as_ptr(),
|
||||
parent,
|
||||
new.as_ptr(),
|
||||
libc::RENAME_NOREPLACE,
|
||||
)
|
||||
};
|
||||
if result == 0 {
|
||||
Ok(true)
|
||||
} else if unsafe { *libc::__errno_location() } == libc::EEXIST {
|
||||
Ok(false)
|
||||
} else {
|
||||
Err(ArtifactError::Storage)
|
||||
}
|
||||
}
|
||||
pub(crate) fn unlinkat(parent: i32, name: &[u8]) -> Result<(), ArtifactError> {
|
||||
let name = c_name(name)?;
|
||||
if unsafe { libc::unlinkat(parent, name.as_ptr(), 0) } == 0 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(classify_errno())
|
||||
}
|
||||
}
|
||||
pub(crate) fn classify_errno() -> ArtifactError {
|
||||
match unsafe { *libc::__errno_location() } {
|
||||
libc::ENOENT => ArtifactError::NotFound,
|
||||
libc::ELOOP | libc::ENOTDIR => ArtifactError::UnsafeRoot,
|
||||
_ => ArtifactError::Storage,
|
||||
}
|
||||
}
|
||||
pub(crate) struct RootLock {
|
||||
fd: OwnedFd,
|
||||
}
|
||||
impl RootLock {
|
||||
pub(crate) fn shared(root: &Root) -> Result<Self, ArtifactError> {
|
||||
let fd = duplicate_root(root)?.fd;
|
||||
if unsafe { libc::flock(fd.as_raw_fd(), libc::LOCK_SH) } == 0 {
|
||||
Ok(Self { fd })
|
||||
} else {
|
||||
Err(ArtifactError::Storage)
|
||||
}
|
||||
}
|
||||
pub(crate) fn exclusive(root: &Root) -> Result<Self, ArtifactError> {
|
||||
let fd = duplicate_root(root)?.fd;
|
||||
if unsafe { libc::flock(fd.as_raw_fd(), libc::LOCK_EX) } == 0 {
|
||||
Ok(Self { fd })
|
||||
} else {
|
||||
Err(ArtifactError::Storage)
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Drop for RootLock {
|
||||
fn drop(&mut self) {
|
||||
unsafe {
|
||||
libc::flock(self.fd.as_raw_fd(), libc::LOCK_UN);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Test-only crash and fault control for syscall-coupled storage checkpoints.
|
||||
|
||||
use std::{
|
||||
sync::{Condvar, Mutex, OnceLock},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum FaultAction {
|
||||
Exit,
|
||||
Fail,
|
||||
Hold,
|
||||
}
|
||||
|
||||
struct State {
|
||||
checkpoint: Option<(String, FaultAction)>,
|
||||
held: bool,
|
||||
}
|
||||
fn slot() -> &'static (Mutex<State>, Condvar) {
|
||||
static SLOT: OnceLock<(Mutex<State>, Condvar)> = OnceLock::new();
|
||||
SLOT.get_or_init(|| {
|
||||
(
|
||||
Mutex::new(State {
|
||||
checkpoint: None,
|
||||
held: false,
|
||||
}),
|
||||
Condvar::new(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_checkpoint(stage: impl Into<String>, action: FaultAction) {
|
||||
let (lock, _) = slot();
|
||||
lock.lock()
|
||||
.expect("artifact test checkpoint lock poisoned")
|
||||
.checkpoint = Some((stage.into(), action));
|
||||
}
|
||||
|
||||
pub fn clear_checkpoint() {
|
||||
let (lock, wake) = slot();
|
||||
let mut state = lock.lock().expect("artifact test checkpoint lock poisoned");
|
||||
state.checkpoint = None;
|
||||
state.held = false;
|
||||
wake.notify_all();
|
||||
}
|
||||
pub fn wait_until_held(timeout: Duration) -> bool {
|
||||
let (lock, wake) = slot();
|
||||
let state = lock.lock().expect("artifact test checkpoint lock poisoned");
|
||||
let (state, _) = wake
|
||||
.wait_timeout_while(state, timeout, |state| !state.held)
|
||||
.expect("artifact test checkpoint lock poisoned");
|
||||
state.held
|
||||
}
|
||||
|
||||
pub(crate) fn checkpoint(stage: &str) -> Option<FaultAction> {
|
||||
let (lock, wake) = slot();
|
||||
let mut state = lock.lock().expect("artifact test checkpoint lock poisoned");
|
||||
let action = state
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|(expected, action)| (expected == stage).then_some(*action));
|
||||
if action == Some(FaultAction::Hold) {
|
||||
state.held = true;
|
||||
wake.notify_all();
|
||||
while state.checkpoint.is_some() {
|
||||
state = wake
|
||||
.wait(state)
|
||||
.expect("artifact test checkpoint lock poisoned");
|
||||
}
|
||||
}
|
||||
action
|
||||
}
|
||||
|
||||
pub(crate) fn action(stage: &str) -> Option<FaultAction> {
|
||||
let (lock, _) = slot();
|
||||
let state = lock.lock().expect("artifact test checkpoint lock poisoned");
|
||||
state
|
||||
.checkpoint
|
||||
.as_ref()
|
||||
.and_then(|(expected, action)| (expected == stage).then_some(*action))
|
||||
}
|
||||
Reference in New Issue
Block a user