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

683 lines
22 KiB
Rust

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, RegisteredArtifact, StoredArtifact};
const SHA_DIR: &[u8] = b"sha256";
pub(crate) const QUARANTINE_DIR: &[u8] = b"quarantine";
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))
}
/// Stores bytes and returns a non-forgeable capability suitable for
/// creating authoritative registry metadata.
pub fn put_registered(&self, bytes: &[u8]) -> Result<RegisteredArtifact, ArtifactError> {
let stored = self.put(bytes)?;
Ok(RegisteredArtifact {
artifact_ref: stored.artifact_ref,
size_bytes: stored.size_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(())
}
pub(crate) 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)
}
}
pub(crate) 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.
pub(crate) 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> {
rename_no_replace_at(parent, old, parent, new)
}
pub(crate) fn rename_no_replace_at(
old_parent: i32,
old: &[u8],
new_parent: i32,
new: &[u8],
) -> Result<bool, ArtifactError> {
let old = c_name(old)?;
let new = c_name(new)?;
let result = unsafe {
libc::syscall(
libc::SYS_renameat2,
old_parent,
old.as_ptr(),
new_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);
}
}
}