use std::error::Error;
use std::fmt::{self, Display, Formatter};
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(deny_unknown_fields)]
pub struct PublishedFileIdentity {
pub device: u64,
pub inode: u64,
pub size: u64,
pub sha256: [u8; 32],
}
impl PublishedFileIdentity {
#[must_use]
pub fn is_valid(&self) -> bool {
self.inode != 0 && self.size != 0 && self.sha256.iter().any(|byte| *byte != 0)
}
}
pub struct SecureCreateNewFilePublisher {
#[cfg(any(target_os = "linux", target_os = "macos"))]
files: std::sync::Arc<unix::PublisherFiles>,
}
pub struct SecurePrivateDirectoryTree {
#[cfg(any(target_os = "linux", target_os = "macos"))]
root: std::sync::Arc<std::fs::File>,
}
impl SecurePrivateDirectoryTree {
pub async fn open_or_create_root(path: PathBuf) -> Result<(Self, bool), SecurePublicationError> {
if !valid_absolute_path(&path) {
return Err(SecurePublicationError::InvalidPath);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let (root, created) = tokio::task::spawn_blocking(move || unix::open_or_create_private_root(&path))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)??;
Ok((
Self {
root: std::sync::Arc::new(root),
},
created,
))
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = path;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn ensure_directory(&self, relative: PathBuf) -> Result<(), SecurePublicationError> {
if !valid_relative_directory_path(&relative) {
return Err(SecurePublicationError::InvalidPath);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let root = std::sync::Arc::clone(&self.root);
tokio::task::spawn_blocking(move || unix::ensure_private_directory(&root, &relative))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = relative;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
}
impl SecureCreateNewFilePublisher {
pub async fn open(
stage_directory: PathBuf,
stage_name: String,
destination: PathBuf,
) -> Result<Self, SecurePublicationError> {
if !valid_absolute_path(&stage_directory) || !valid_absolute_path(&destination) || !valid_name(&stage_name) {
return Err(SecurePublicationError::InvalidPath);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = tokio::task::spawn_blocking(move || {
unix::PublisherFiles::open(&stage_directory, &stage_name, &destination)
})
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)??;
Ok(Self {
files: std::sync::Arc::new(files),
})
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (stage_directory, stage_name, destination);
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn stage(
&self,
bytes: Vec<u8>,
maximum_bytes: usize,
expected: Option<PublishedFileIdentity>,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
self.stage_inner(bytes, maximum_bytes, expected, false).await
}
pub async fn stage_sensitive(
&self,
bytes: Vec<u8>,
maximum_bytes: usize,
expected: Option<PublishedFileIdentity>,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
self.stage_inner(bytes, maximum_bytes, expected, true).await
}
async fn stage_inner(
&self,
mut bytes: Vec<u8>,
maximum_bytes: usize,
expected: Option<PublishedFileIdentity>,
sensitive: bool,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
if bytes.is_empty() || bytes.len() > maximum_bytes || maximum_bytes == 0 {
if sensitive {
bytes.fill(0);
std::hint::black_box(&mut bytes);
}
return Err(SecurePublicationError::InvalidMaterial);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || {
let mut bytes = bytes;
let result = unix::stage(&files, &bytes, maximum_bytes, expected.as_ref());
if sensitive {
bytes.fill(0);
std::hint::black_box(&mut bytes);
}
result
})
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (bytes, maximum_bytes, expected, sensitive);
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn read_stage(&self, maximum_bytes: usize) -> Result<Option<Vec<u8>>, SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::read_stage(&files, maximum_bytes))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = maximum_bytes;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn read_published(&self, maximum_bytes: usize) -> Result<Option<Vec<u8>>, SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::read_published(&files, maximum_bytes))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = maximum_bytes;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn remove_unpublished_stage(
&self,
expected: Option<PublishedFileIdentity>,
) -> Result<(), SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::remove_unpublished_stage(&files, expected.as_ref()))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = expected;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn publish(&self, expected: PublishedFileIdentity) -> Result<(), SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::publish(&files, &expected))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = expected;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn publish_deterministic(
&self,
bytes: Vec<u8>,
maximum_bytes: usize,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
if bytes.is_empty() || bytes.len() > maximum_bytes || maximum_bytes == 0 {
return Err(SecurePublicationError::InvalidMaterial);
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::publish_deterministic(&files, &bytes, maximum_bytes))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (bytes, maximum_bytes);
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn verify_published(&self, expected: PublishedFileIdentity) -> Result<(), SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::verify_published(&files, &expected))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = expected;
Err(SecurePublicationError::UnsupportedPlatform)
}
}
pub async fn validate_destination(
&self,
expected: Option<PublishedFileIdentity>,
required: bool,
) -> Result<(), SecurePublicationError> {
#[cfg(any(target_os = "linux", target_os = "macos"))]
{
let files = std::sync::Arc::clone(&self.files);
tokio::task::spawn_blocking(move || unix::validate_destination(&files, expected.as_ref(), required))
.await
.map_err(|_| SecurePublicationError::WorkerUnavailable)?
}
#[cfg(not(any(target_os = "linux", target_os = "macos")))]
{
let _ = (expected, required);
Err(SecurePublicationError::UnsupportedPlatform)
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum SecurePublicationError {
UnsupportedPlatform,
InvalidPath,
InsecurePath,
DestinationExists,
Conflict,
InvalidState,
InvalidMaterial,
Io,
WorkerUnavailable,
}
impl SecurePublicationError {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
Self::UnsupportedPlatform => "unsupported_platform",
Self::InvalidPath => "invalid_path",
Self::InsecurePath => "insecure_path",
Self::DestinationExists => "destination_exists",
Self::Conflict => "conflict",
Self::InvalidState => "invalid_state",
Self::InvalidMaterial => "invalid_material",
Self::Io => "io",
Self::WorkerUnavailable => "worker_unavailable",
}
}
}
impl Display for SecurePublicationError {
fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::UnsupportedPlatform => "the platform cannot provide secure create-new publication",
Self::InvalidPath => "a secure publication path is not canonical and absolute",
Self::InsecurePath => "a secure publication path is symlinked, foreign-owned, or broadly writable",
Self::DestinationExists => "a secure create-new publication destination already exists",
Self::Conflict => "secure publication state conflicts with the expected file identity",
Self::InvalidState => "secure publication is incomplete or out of order",
Self::InvalidMaterial => "secure publication material is invalid",
Self::Io => "secure publication filesystem operation failed",
Self::WorkerUnavailable => "secure publication filesystem worker did not complete",
})
}
}
impl Error for SecurePublicationError {}
fn valid_absolute_path(path: &Path) -> bool {
path.is_absolute()
&& path.parent().is_some()
&& path
.components()
.all(|component| matches!(component, Component::RootDir | Component::Normal(_)))
&& path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
}
fn valid_name(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 255
&& Path::new(value).components().count() == 1
&& matches!(Path::new(value).components().next(), Some(Component::Normal(_)))
&& !value.bytes().any(|byte| byte.is_ascii_control())
}
fn valid_relative_directory_path(path: &Path) -> bool {
!path.as_os_str().is_empty()
&& !path.is_absolute()
&& path
.components()
.all(|component| matches!(component, Component::Normal(_)))
&& path.components().collect::<PathBuf>().as_os_str() == path.as_os_str()
}
#[cfg(any(target_os = "linux", target_os = "macos"))]
mod unix {
use std::ffi::{OsStr, OsString};
use std::fs::File;
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
use std::path::{Component, Path};
use aws_lc_rs::digest;
use rustix::fs::{AtFlags, FileType, Mode, OFlags, fstat, fsync, linkat, mkdirat, open, openat, unlinkat};
use super::{PublishedFileIdentity, SecurePublicationError};
const FILE_MODE: rustix::fs::RawMode = 0o600;
const DIRECTORY_MODE: rustix::fs::RawMode = 0o700;
fn stat_value_u64<T: TryInto<u64>>(value: T) -> Option<u64> {
value.try_into().ok()
}
pub(super) struct PublisherFiles {
stage_directory: File,
stage_name: OsString,
destination_parent: File,
destination_name: OsString,
}
impl PublisherFiles {
pub(super) fn open(
stage_directory: &Path,
stage_name: &str,
destination: &Path,
) -> Result<Self, SecurePublicationError> {
let destination_parent_path = destination.parent().ok_or(SecurePublicationError::InvalidPath)?;
let destination_name = destination.file_name().ok_or(SecurePublicationError::InvalidPath)?;
let stage_directory = open_directory_chain(stage_directory)?;
let destination_parent = open_directory_chain(destination_parent_path)?;
validate_private_directory(&stage_directory)?;
validate_destination_parent(&destination_parent)?;
Ok(Self {
stage_directory,
stage_name: OsString::from(stage_name),
destination_parent,
destination_name: destination_name.to_owned(),
})
}
}
pub(super) fn open_or_create_private_root(path: &Path) -> Result<(File, bool), SecurePublicationError> {
let parent_path = path.parent().ok_or(SecurePublicationError::InvalidPath)?;
let name = path.file_name().ok_or(SecurePublicationError::InvalidPath)?;
let parent = open_directory_chain(parent_path)?;
validate_destination_parent(&parent)?;
let created = match mkdirat(&parent, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
Ok(()) => {
fsync(&parent).map_err(|_| SecurePublicationError::Io)?;
true
}
Err(error) if error == rustix::io::Errno::EXIST => false,
Err(error) => return Err(map_open_error(error)),
};
let root = File::from(openat(&parent, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
validate_private_directory(&root)?;
Ok((root, created))
}
pub(super) fn ensure_private_directory(root: &File, relative: &Path) -> Result<(), SecurePublicationError> {
let mut current = root.try_clone().map_err(|_| SecurePublicationError::Io)?;
validate_private_directory(¤t)?;
for component in relative.components() {
let Component::Normal(name) = component else {
return Err(SecurePublicationError::InvalidPath);
};
match mkdirat(¤t, name, Mode::from_raw_mode(DIRECTORY_MODE)) {
Ok(()) => fsync(¤t).map_err(|_| SecurePublicationError::Io)?,
Err(error) if error == rustix::io::Errno::EXIST => {}
Err(error) => return Err(map_open_error(error)),
}
let next = File::from(openat(¤t, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
validate_private_directory(&next)?;
current = next;
}
Ok(())
}
pub(super) fn stage(
files: &PublisherFiles,
bytes: &[u8],
maximum_bytes: usize,
expected: Option<&PublishedFileIdentity>,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
if open_optional(&files.destination_parent, &files.destination_name)?.is_some() && expected.is_none() {
return Err(SecurePublicationError::DestinationExists);
}
let (file, created) = open_or_create_stage(&files.stage_directory, &files.stage_name)?;
if created {
(&file).write_all(bytes).map_err(|_| SecurePublicationError::Io)?;
file.sync_all().map_err(|_| SecurePublicationError::Io)?;
fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
} else if read_bounded(&file, maximum_bytes)? != bytes {
return Err(SecurePublicationError::Conflict);
}
let identity = file_identity(&file, maximum_bytes, Some(1))?;
if expected.is_some_and(|expected| expected != &identity) {
return Err(SecurePublicationError::Conflict);
}
Ok(identity)
}
pub(super) fn read_stage(
files: &PublisherFiles,
maximum_bytes: usize,
) -> Result<Option<Vec<u8>>, SecurePublicationError> {
let Some(file) = open_optional(&files.stage_directory, &files.stage_name)? else {
return Ok(None);
};
validate_file(&file, Some(1))?;
read_bounded(&file, maximum_bytes).map(Some)
}
pub(super) fn read_published(
files: &PublisherFiles,
maximum_bytes: usize,
) -> Result<Option<Vec<u8>>, SecurePublicationError> {
let Some(file) = open_optional(&files.destination_parent, &files.destination_name)? else {
return Ok(None);
};
validate_file(&file, Some(1))?;
read_bounded(&file, maximum_bytes).map(Some)
}
pub(super) fn remove_unpublished_stage(
files: &PublisherFiles,
expected: Option<&PublishedFileIdentity>,
) -> Result<(), SecurePublicationError> {
if open_optional(&files.destination_parent, &files.destination_name)?.is_some() {
return Err(SecurePublicationError::Conflict);
}
let Some(file) = open_optional(&files.stage_directory, &files.stage_name)? else {
return Ok(());
};
match expected {
Some(expected) => require_identity(&file, expected, Some(1))?,
None => {
validate_file(&file, Some(1))?;
}
}
drop(file);
unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
.map_err(|_| SecurePublicationError::Io)?;
fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)
}
pub(super) fn publish(
files: &PublisherFiles,
expected: &PublishedFileIdentity,
) -> Result<(), SecurePublicationError> {
let staged = open_optional(&files.stage_directory, &files.stage_name)?;
let destination = open_optional(&files.destination_parent, &files.destination_name)?;
match (staged, destination) {
(Some(staged), None) => {
require_identity(&staged, expected, Some(1))?;
linkat(
&files.stage_directory,
&files.stage_name,
&files.destination_parent,
&files.destination_name,
AtFlags::empty(),
)
.map_err(|error| {
if error == rustix::io::Errno::EXIST {
SecurePublicationError::DestinationExists
} else {
SecurePublicationError::Io
}
})?;
fsync(&files.destination_parent).map_err(|_| SecurePublicationError::Io)?;
let destination = open_optional(&files.destination_parent, &files.destination_name)?
.ok_or(SecurePublicationError::Io)?;
require_identity(&staged, expected, Some(2))?;
require_identity(&destination, expected, Some(2))?;
require_same_inode(&staged, &destination)?;
drop(staged);
drop(destination);
unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
.map_err(|_| SecurePublicationError::Io)?;
fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
}
(Some(staged), Some(destination)) => {
require_identity(&staged, expected, Some(2))?;
require_identity(&destination, expected, Some(2))?;
require_same_inode(&staged, &destination)?;
drop(staged);
drop(destination);
unlinkat(&files.stage_directory, &files.stage_name, AtFlags::empty())
.map_err(|_| SecurePublicationError::Io)?;
fsync(&files.stage_directory).map_err(|_| SecurePublicationError::Io)?;
}
(None, Some(destination)) => require_identity(&destination, expected, Some(1))?,
(None, None) => return Err(SecurePublicationError::InvalidState),
}
verify_published(files, expected)
}
pub(super) fn publish_deterministic(
files: &PublisherFiles,
bytes: &[u8],
maximum_bytes: usize,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
let staged = open_optional(&files.stage_directory, &files.stage_name)?;
let destination = open_optional(&files.destination_parent, &files.destination_name)?;
let identity = match (staged, destination) {
(Some(staged), Some(destination)) => {
require_same_inode(&staged, &destination)?;
if read_bounded(&staged, maximum_bytes)? != bytes || read_bounded(&destination, maximum_bytes)? != bytes
{
return Err(SecurePublicationError::Conflict);
}
let identity = file_identity(&staged, maximum_bytes, Some(2))?;
require_identity(&destination, &identity, Some(2))?;
identity
}
(Some(staged), None) => {
if read_bounded(&staged, maximum_bytes)? != bytes {
return Err(SecurePublicationError::Conflict);
}
file_identity(&staged, maximum_bytes, Some(1))?
}
(None, Some(destination)) => {
if read_bounded(&destination, maximum_bytes)? != bytes {
return Err(SecurePublicationError::Conflict);
}
return file_identity(&destination, maximum_bytes, Some(1));
}
(None, None) => stage(files, bytes, maximum_bytes, None)?,
};
publish(files, &identity)?;
Ok(identity)
}
pub(super) fn verify_published(
files: &PublisherFiles,
expected: &PublishedFileIdentity,
) -> Result<(), SecurePublicationError> {
let destination = open_optional(&files.destination_parent, &files.destination_name)?
.ok_or(SecurePublicationError::InvalidState)?;
require_identity(&destination, expected, Some(1))
}
pub(super) fn validate_destination(
files: &PublisherFiles,
expected: Option<&PublishedFileIdentity>,
required: bool,
) -> Result<(), SecurePublicationError> {
let destination = open_optional(&files.destination_parent, &files.destination_name)?;
match (destination, expected, required) {
(Some(file), Some(expected), _) => require_identity(&file, expected, None),
(Some(_), None, _) => Err(SecurePublicationError::DestinationExists),
(None, _, true) => Err(SecurePublicationError::InvalidState),
(None, _, false) => Ok(()),
}
}
fn open_directory_chain(path: &Path) -> Result<File, SecurePublicationError> {
let mut components = path.components();
if !matches!(components.next(), Some(Component::RootDir)) {
return Err(SecurePublicationError::InvalidPath);
}
let mut current = File::from(open("/", directory_flags(), Mode::empty()).map_err(map_open_error)?);
for component in components {
let Component::Normal(name) = component else {
return Err(SecurePublicationError::InvalidPath);
};
current = File::from(openat(¤t, name, directory_flags(), Mode::empty()).map_err(map_open_error)?);
}
Ok(current)
}
const fn directory_flags() -> OFlags {
OFlags::RDONLY
.union(OFlags::DIRECTORY)
.union(OFlags::CLOEXEC)
.union(OFlags::NOFOLLOW)
}
fn validate_private_directory(directory: &File) -> Result<(), SecurePublicationError> {
let stat = fstat(directory).map_err(|_| SecurePublicationError::Io)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
|| stat.st_uid != rustix::process::geteuid().as_raw()
|| stat.st_mode & 0o777 != 0o700
{
return Err(SecurePublicationError::InsecurePath);
}
Ok(())
}
fn validate_destination_parent(directory: &File) -> Result<(), SecurePublicationError> {
let stat = fstat(directory).map_err(|_| SecurePublicationError::Io)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::Directory
|| stat.st_uid != rustix::process::geteuid().as_raw()
|| stat.st_mode & 0o022 != 0
{
return Err(SecurePublicationError::InsecurePath);
}
Ok(())
}
fn open_or_create_stage(directory: &File, name: &OsStr) -> Result<(File, bool), SecurePublicationError> {
let create = OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::CLOEXEC | OFlags::NOFOLLOW;
match openat(directory, name, create, Mode::from_raw_mode(FILE_MODE)) {
Ok(file) => Ok((File::from(file), true)),
Err(error) if error == rustix::io::Errno::EXIST => {
let file = open_optional(directory, name)?.ok_or(SecurePublicationError::Io)?;
validate_file(&file, Some(1))?;
Ok((file, false))
}
Err(error) => Err(map_open_error(error)),
}
}
fn open_optional(directory: &File, name: &OsStr) -> Result<Option<File>, SecurePublicationError> {
let flags = OFlags::RDONLY | OFlags::CLOEXEC | OFlags::NOFOLLOW | OFlags::NONBLOCK;
match openat(directory, name, flags, Mode::empty()) {
Ok(file) => Ok(Some(File::from(file))),
Err(error) if error == rustix::io::Errno::NOENT => Ok(None),
Err(error) => Err(map_open_error(error)),
}
}
fn validate_file(file: &File, links: Option<u64>) -> Result<rustix::fs::Stat, SecurePublicationError> {
let stat = fstat(file).map_err(|_| SecurePublicationError::Io)?;
if FileType::from_raw_mode(stat.st_mode) != FileType::RegularFile
|| stat.st_uid != rustix::process::geteuid().as_raw()
|| stat.st_mode & 0o777 != FILE_MODE
|| links.is_some_and(|links| stat_value_u64(stat.st_nlink) != Some(links))
{
return Err(SecurePublicationError::InsecurePath);
}
Ok(stat)
}
fn file_identity(
file: &File,
maximum_bytes: usize,
links: Option<u64>,
) -> Result<PublishedFileIdentity, SecurePublicationError> {
let stat = validate_file(file, links)?;
let bytes = read_bounded(file, maximum_bytes)?;
if bytes.is_empty() {
return Err(SecurePublicationError::InvalidMaterial);
}
Ok(PublishedFileIdentity {
device: stat_value_u64(stat.st_dev).ok_or(SecurePublicationError::InvalidMaterial)?,
inode: stat.st_ino,
size: u64::try_from(stat.st_size).map_err(|_| SecurePublicationError::InvalidMaterial)?,
sha256: sha256(&bytes),
})
}
fn require_identity(
file: &File,
expected: &PublishedFileIdentity,
links: Option<u64>,
) -> Result<(), SecurePublicationError> {
let maximum = usize::try_from(expected.size).map_err(|_| SecurePublicationError::InvalidMaterial)?;
if !expected.is_valid() || file_identity(file, maximum, links)? != *expected {
return Err(SecurePublicationError::Conflict);
}
Ok(())
}
fn require_same_inode(left: &File, right: &File) -> Result<(), SecurePublicationError> {
let left = fstat(left).map_err(|_| SecurePublicationError::Io)?;
let right = fstat(right).map_err(|_| SecurePublicationError::Io)?;
if left.st_dev != right.st_dev || left.st_ino != right.st_ino || left.st_size != right.st_size {
return Err(SecurePublicationError::Conflict);
}
Ok(())
}
fn read_bounded(file: &File, maximum: usize) -> Result<Vec<u8>, SecurePublicationError> {
let mut file = file.try_clone().map_err(|_| SecurePublicationError::Io)?;
file.seek(SeekFrom::Start(0)).map_err(|_| SecurePublicationError::Io)?;
let mut bytes = Vec::new();
let limit = u64::try_from(maximum)
.map_err(|_| SecurePublicationError::InvalidMaterial)?
.saturating_add(1);
file.take(limit)
.read_to_end(&mut bytes)
.map_err(|_| SecurePublicationError::Io)?;
if bytes.is_empty() || bytes.len() > maximum {
return Err(SecurePublicationError::InvalidMaterial);
}
Ok(bytes)
}
fn sha256(bytes: &[u8]) -> [u8; 32] {
let observed = digest::digest(&digest::SHA256, bytes);
let mut output = [0; 32];
output.copy_from_slice(observed.as_ref());
output
}
fn map_open_error(error: rustix::io::Errno) -> SecurePublicationError {
match error {
rustix::io::Errno::LOOP | rustix::io::Errno::NOTDIR => SecurePublicationError::InsecurePath,
rustix::io::Errno::NOENT => SecurePublicationError::InvalidPath,
_ => SecurePublicationError::Io,
}
}
}
#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use std::fs;
use std::os::unix::fs::{MetadataExt as _, PermissionsExt as _, symlink};
use super::*;
struct TemporaryDirectory(PathBuf);
impl TemporaryDirectory {
fn create() -> Result<Self, Box<dyn Error>> {
let mut nonce = [0_u8; 16];
getrandom::fill(&mut nonce)?;
let root = fs::canonicalize(std::env::temp_dir())?
.join(format!("lodestone-secure-publication-test-{}", lower_hex(&nonce)));
fs::create_dir(&root)?;
fs::set_permissions(&root, fs::Permissions::from_mode(0o700))?;
Ok(Self(root))
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TemporaryDirectory {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
fn private_directory(path: &Path) -> Result<(), Box<dyn Error>> {
fs::create_dir(path)?;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
Ok(())
}
#[tokio::test]
async fn stage_publish_and_resume_preserve_one_inode() -> Result<(), Box<dyn Error>> {
let temporary = TemporaryDirectory::create()?;
let stage = temporary.path().join("stage");
let output = temporary.path().join("output");
private_directory(&stage)?;
private_directory(&output)?;
let destination = output.join("material.json");
let publisher =
SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
assert!(identity.is_valid());
publisher.publish(identity.clone()).await?;
publisher.verify_published(identity.clone()).await?;
let reopened = SecureCreateNewFilePublisher::open(stage, "material.stage".to_owned(), destination).await?;
reopened.verify_published(identity).await?;
Ok(())
}
#[tokio::test]
async fn deterministic_publication_repairs_link_before_unlink_crash() -> Result<(), Box<dyn Error>> {
let temporary = TemporaryDirectory::create()?;
let stage = temporary.path().join("stage");
let output = temporary.path().join("output");
private_directory(&stage)?;
private_directory(&output)?;
let destination = output.join("material.json");
let publisher =
SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
let staged = publisher.stage(b"deterministic".to_vec(), 128, None).await?;
fs::hard_link(stage.join("material.stage"), &destination)?;
let published = publisher.publish_deterministic(b"deterministic".to_vec(), 128).await?;
assert_eq!(published.sha256, staged.sha256);
assert!(!stage.join("material.stage").exists());
assert_eq!(fs::metadata(&destination)?.nlink(), 1);
assert_eq!(
publisher.publish_deterministic(b"deterministic".to_vec(), 128).await?,
published
);
assert_eq!(
publisher.publish_deterministic(b"different".to_vec(), 128).await,
Err(SecurePublicationError::Conflict)
);
Ok(())
}
#[tokio::test]
async fn substitution_symlink_and_existing_destination_refuse() -> Result<(), Box<dyn Error>> {
let temporary = TemporaryDirectory::create()?;
let stage = temporary.path().join("stage");
let output = temporary.path().join("output");
private_directory(&stage)?;
private_directory(&output)?;
let destination = output.join("material.json");
let publisher =
SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination.clone()).await?;
let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
assert_eq!(
publisher.stage(b"different".to_vec(), 128, Some(identity)).await,
Err(SecurePublicationError::Conflict)
);
fs::remove_file(stage.join("material.stage"))?;
symlink("missing", stage.join("material.stage"))?;
assert_eq!(
publisher.read_stage(128).await,
Err(SecurePublicationError::InsecurePath)
);
fs::remove_file(stage.join("material.stage"))?;
fs::write(&destination, b"occupied")?;
fs::set_permissions(&destination, fs::Permissions::from_mode(0o600))?;
assert_eq!(
publisher.stage(b"material".to_vec(), 128, None).await,
Err(SecurePublicationError::DestinationExists)
);
Ok(())
}
#[tokio::test]
async fn broad_permissions_and_extra_hard_links_refuse() -> Result<(), Box<dyn Error>> {
let temporary = TemporaryDirectory::create()?;
let stage = temporary.path().join("stage");
let output = temporary.path().join("output");
private_directory(&stage)?;
private_directory(&output)?;
let destination = output.join("material.json");
let publisher =
SecureCreateNewFilePublisher::open(stage.clone(), "material.stage".to_owned(), destination).await?;
let identity = publisher.stage(b"material".to_vec(), 128, None).await?;
fs::hard_link(stage.join("material.stage"), stage.join("unexpected-link"))?;
assert_eq!(
publisher.stage(b"material".to_vec(), 128, Some(identity)).await,
Err(SecurePublicationError::InsecurePath)
);
fs::remove_file(stage.join("unexpected-link"))?;
fs::set_permissions(stage.join("material.stage"), fs::Permissions::from_mode(0o644))?;
assert_eq!(
publisher.read_stage(128).await,
Err(SecurePublicationError::InsecurePath)
);
Ok(())
}
#[tokio::test]
async fn private_tree_creates_exact_nofollow_scaffold_and_reopens() -> Result<(), Box<dyn Error>> {
let temporary = TemporaryDirectory::create()?;
let root = temporary.path().join("instance");
let (tree, created) = SecurePrivateDirectoryTree::open_or_create_root(root.clone()).await?;
assert!(created);
tree.ensure_directory(PathBuf::from("operator/workflows")).await?;
assert_eq!(
fs::metadata(root.join("operator/workflows"))?.permissions().mode() & 0o777,
0o700
);
let (_, created) = SecurePrivateDirectoryTree::open_or_create_root(root.clone()).await?;
assert!(!created);
fs::set_permissions(&root, fs::Permissions::from_mode(0o755))?;
assert!(matches!(
SecurePrivateDirectoryTree::open_or_create_root(root).await,
Err(SecurePublicationError::InsecurePath)
));
Ok(())
}
fn lower_hex(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len().saturating_mul(2));
for byte in bytes {
output.push(char::from(HEX[usize::from(byte >> 4)]));
output.push(char::from(HEX[usize::from(byte & 0x0f)]));
}
output
}
}