use std::{
fs,
io::{Read, Write},
path::{Path, PathBuf},
};
#[cfg(unix)]
use std::{
fs::File,
sync::atomic::{AtomicU64, Ordering},
};
use sha2::{Digest, Sha256};
use crate::{ExecutionContext, interface::CliError, run_store::RunAccess};
const MAX_BYTES: u64 = 128 * 1024;
#[cfg(unix)]
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
#[derive(Clone, Debug)]
pub(crate) struct OpenedBytes {
pub(crate) bytes: Vec<u8>,
pub(crate) sha256: String,
}
pub(crate) fn read_snapshot(
root: &Path,
relative: &str,
label: &str,
) -> Result<OpenedBytes, CliError> {
safe_relative(relative)?;
#[cfg(unix)]
{
read_at(&open_dir_chain(&canonical_path(root)?)?, relative, label)
}
#[cfg(not(unix))]
{
read_path(&canonical_path(root)?.join(relative), label)
}
}
#[cfg(unix)]
pub(crate) struct AnchoredFiles<'a> {
pub(crate) access: &'a RunAccess<'a>,
run_fd: &'a std::os::fd::OwnedFd,
project_fd: std::os::fd::OwnedFd,
authoring_root: PathBuf,
run: String,
pub(crate) run_dir: PathBuf,
pub(crate) project_root: PathBuf,
}
#[cfg(not(unix))]
pub(crate) struct AnchoredFiles<'a> {
pub(crate) access: &'a RunAccess<'a>,
authoring_root: PathBuf,
run: String,
pub(crate) run_dir: PathBuf,
pub(crate) project_root: PathBuf,
}
impl<'a> AnchoredFiles<'a> {
pub(crate) fn open(
context: &ExecutionContext,
run: &str,
access: &'a RunAccess<'a>,
) -> Result<Self, CliError> {
#[cfg(unix)]
{
let project_root = canonical_path(&context.primary_root)?;
let authoring_root = canonical_path(&context.workspace_root)?;
let run_dir = canonical_path(&context.runs_root.join(run))?;
let expected = fs::metadata(&run_dir).map_err(|source| error(source.to_string()))?;
let actual = rustix::fs::fstat(access.run_fd)
.map_err(|source| error(format!("inspect locked run directory: {source}")))?;
if !same_inode(&expected, &actual) {
return Err(error("locked run directory changed identity"));
}
Ok(Self {
access,
run_fd: access.run_fd,
project_fd: open_dir_chain(&authoring_root)?,
authoring_root,
run: run.into(),
run_dir,
project_root,
})
}
#[cfg(not(unix))]
{
let project_root = canonical_path(&context.primary_root)?;
let authoring_root = canonical_path(&context.workspace_root)?;
let run_dir = canonical_path(&context.runs_root.join(run))?;
reject_path(&run_dir)?;
Ok(Self {
access,
authoring_root,
run: run.into(),
run_dir,
project_root,
})
}
}
pub(crate) fn ensure_anchor(&self) -> Result<(), CliError> {
#[cfg(unix)]
{
let expected =
fs::metadata(&self.run_dir).map_err(|source| error(source.to_string()))?;
let actual = rustix::fs::fstat(self.run_fd)
.map_err(|source| error(format!("inspect locked run directory: {source}")))?;
if !same_inode(&expected, &actual) {
return Err(error(
"run directory was swapped during orientation custody",
));
}
let expected = fs::symlink_metadata(&self.authoring_root)
.map_err(|source| error(source.to_string()))?;
let actual = rustix::fs::fstat(&self.project_fd)
.map_err(|source| error(format!("inspect authoring directory: {source}")))?;
if !same_inode(&expected, &actual) {
return Err(error(
"authoring worktree changed identity during orientation",
));
}
}
Ok(())
}
pub(crate) fn read_file(&self, relative: &str, label: &str) -> Result<OpenedBytes, CliError> {
self.ensure_anchor()?;
#[cfg(unix)]
{
read_at(self.run_fd, relative, label)
}
#[cfg(not(unix))]
{
read_path(&self.run_dir.join(relative), label)
}
}
pub(crate) fn read_project_file(
&self,
relative: &str,
label: &str,
) -> Result<OpenedBytes, CliError> {
self.ensure_anchor()?;
#[cfg(unix)]
{
read_at(&self.project_fd, relative, label)
}
#[cfg(not(unix))]
{
read_path(&self.authoring_root.join(relative), label)
}
}
pub(crate) fn read_authoring_file(
&self,
relative: &str,
label: &str,
) -> Result<OpenedBytes, CliError> {
safe_relative(relative)?;
self.read_project_file(&format!(".shepherd/runs/{}/{relative}", self.run), label)
}
pub(crate) fn write_new(&self, name: &str, bytes: &[u8]) -> Result<(), CliError> {
self.ensure_anchor()?;
#[cfg(unix)]
{
let fd = rustix::fs::openat(
self.run_fd,
name,
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::EXCL
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.map_err(|source| {
error(format!(
"create native orientation artifact {name}: {source}"
))
})?;
let mut file = File::from(fd);
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|source| {
error(format!(
"write native orientation artifact {name}: {source}"
))
})?;
rustix::fs::fsync(self.run_fd)
.map_err(|source| error(format!("sync native orientation directory: {source}")))?;
Ok(())
}
#[cfg(not(unix))]
{
let path = self.run_dir.join(name);
reject_path(&path)?;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&path)
.map_err(|source| error(format!("create native orientation artifact: {source}")))?;
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|source| error(format!("write native orientation artifact: {source}")))
}
}
pub(crate) fn write_replace(&self, name: &str, bytes: &[u8]) -> Result<(), CliError> {
self.ensure_anchor()?;
#[cfg(unix)]
{
let sequence = TEMP_SEQUENCE.fetch_add(1, Ordering::Relaxed);
let temporary = format!(".{name}.tmp-{}-{sequence}", std::process::id());
let fd = rustix::fs::openat(
self.run_fd,
&temporary,
rustix::fs::OFlags::WRONLY
| rustix::fs::OFlags::CREATE
| rustix::fs::OFlags::EXCL
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW,
rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR,
)
.map_err(|source| error(format!("create orientation temporary: {source}")))?;
let mut file = File::from(fd);
let result = (|| {
file.write_all(bytes)
.and_then(|_| file.sync_all())
.map_err(|source| error(format!("write orientation temporary: {source}")))?;
match rustix::fs::statat(self.run_fd, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) {
Ok(stat) if rustix::fs::FileType::from_raw_mode(stat.st_mode).is_file() => {}
Ok(_) => {
return Err(error(format!(
"orientation target is not a regular file: {name}"
)));
}
Err(source) if source == rustix::io::Errno::NOENT => {}
Err(source) => {
return Err(error(format!("inspect orientation target: {source}")));
}
}
rustix::fs::renameat(self.run_fd, &temporary, self.run_fd, name).map_err(
|source| error(format!("publish orientation artifact {name}: {source}")),
)?;
rustix::fs::fsync(self.run_fd)
.map_err(|source| error(format!("sync native orientation directory: {source}")))
})();
if result.is_err() {
let _ = rustix::fs::unlinkat(self.run_fd, &temporary, rustix::fs::AtFlags::empty());
}
result
}
#[cfg(not(unix))]
{
let path = self.run_dir.join(name);
reject_path(&path)?;
let temporary = self
.run_dir
.join(format!(".{name}.tmp-{}", std::process::id()));
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&temporary)
.map_err(|source| error(format!("create orientation temporary: {source}")))?;
let result = file
.write_all(bytes)
.and_then(|_| file.sync_all())
.and_then(|_| fs::rename(&temporary, &path));
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result.map_err(|source| error(format!("publish orientation artifact: {source}")))
}
}
pub(crate) fn unlink(&self, name: &str) -> Result<(), CliError> {
#[cfg(unix)]
{
match rustix::fs::unlinkat(self.run_fd, name, rustix::fs::AtFlags::empty()) {
Ok(()) | Err(rustix::io::Errno::NOENT) => {}
Err(source) => {
return Err(error(format!(
"remove orientation artifact {name}: {source}"
)));
}
}
rustix::fs::fsync(self.run_fd)
.map_err(|source| error(format!("sync native orientation directory: {source}")))
}
#[cfg(not(unix))]
{
match fs::remove_file(self.run_dir.join(name)) {
Ok(()) => Ok(()),
Err(source) if source.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(source) => Err(error(format!(
"remove orientation artifact {name}: {source}"
))),
}
}
}
pub(crate) fn reject_unowned_orientation_artifacts(&self) -> Result<(), CliError> {
self.ensure_anchor()?;
for entry in fs::read_dir(&self.run_dir)
.map_err(|source| error(format!("read run directory: {source}")))?
{
let entry = entry.map_err(|source| error(source.to_string()))?;
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with("orientation")
&& name.ends_with(".json")
&& !matches!(
name.as_str(),
"orientation-manifest.json" | "orientation-pre.json" | "orientation-post.json"
)
{
return Err(error(format!("unowned orientation artifact: {name}")));
}
}
Ok(())
}
}
#[cfg(unix)]
fn directory_flags() -> rustix::fs::OFlags {
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::DIRECTORY
| rustix::fs::OFlags::CLOEXEC
| rustix::fs::OFlags::NOFOLLOW
}
#[cfg(unix)]
fn open_dir_chain(path: &Path) -> Result<std::os::fd::OwnedFd, CliError> {
if !path.is_absolute()
|| path.components().any(|component| {
matches!(
component,
std::path::Component::ParentDir | std::path::Component::CurDir
)
})
{
return Err(error(format!(
"unsafe anchored directory: {}",
path.display()
)));
}
let mut descriptor = rustix::fs::open("/", directory_flags(), rustix::fs::Mode::empty())
.map_err(|source| error(format!("open anchored root: {source}")))?;
for component in path.components() {
let std::path::Component::Normal(name) = component else {
continue;
};
descriptor = rustix::fs::openat(
&descriptor,
name,
directory_flags(),
rustix::fs::Mode::empty(),
)
.map_err(|source| error(format!("open anchored directory component: {source}")))?;
}
Ok(descriptor)
}
#[cfg(unix)]
fn read_at(
root: &std::os::fd::OwnedFd,
relative: &str,
label: &str,
) -> Result<OpenedBytes, CliError> {
let parts = safe_relative(relative)?;
let mut directory = root
.try_clone()
.map_err(|source| error(source.to_string()))?;
let leaf = parts.last().expect("safe relative path is non-empty");
for component in &parts[..parts.len() - 1] {
directory = rustix::fs::openat(
&directory,
*component,
directory_flags(),
rustix::fs::Mode::empty(),
)
.map_err(|source| {
error(format!(
"{label} parent cannot be opened without links: {source}"
))
})?;
}
let descriptor = rustix::fs::openat(
&directory,
*leaf,
rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::CLOEXEC | rustix::fs::OFlags::NOFOLLOW,
rustix::fs::Mode::empty(),
)
.map_err(|source| error(format!("{label} cannot be opened without links: {source}")))?;
let mut file = File::from(descriptor);
let before =
rustix::fs::fstat(&file).map_err(|source| error(format!("inspect {label}: {source}")))?;
if !rustix::fs::FileType::from_raw_mode(before.st_mode).is_file() || before.st_size < 0 {
return Err(error(format!("{label} is not a regular file")));
}
if before.st_size.cast_unsigned() > MAX_BYTES {
return Err(error(format!("{label} exceeds {MAX_BYTES} bytes")));
}
let mut bytes = Vec::new();
(&mut file)
.take(MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|source| error(format!("read {label}: {source}")))?;
if bytes.len() as u64 > MAX_BYTES {
return Err(error(format!("{label} exceeds {MAX_BYTES} bytes")));
}
let after =
rustix::fs::fstat(&file).map_err(|source| error(format!("inspect {label}: {source}")))?;
if before.st_dev != after.st_dev
|| before.st_ino != after.st_ino
|| before.st_size != after.st_size
|| before.st_mtime != after.st_mtime
{
return Err(error(format!("{label} changed while it was read")));
}
Ok(OpenedBytes {
sha256: digest(&bytes),
bytes,
})
}
#[cfg(not(unix))]
fn read_path(path: &Path, label: &str) -> Result<OpenedBytes, CliError> {
reject_path(path)?;
let mut file = std::fs::OpenOptions::new()
.read(true)
.open(path)
.map_err(|source| error(format!("open {label}: {source}")))?;
let before = file
.metadata()
.map_err(|source| error(format!("inspect {label}: {source}")))?;
if !before.is_file() || before.len() > MAX_BYTES {
return Err(error(format!("{label} is not a bounded regular file")));
}
let mut bytes = Vec::new();
(&mut file)
.take(MAX_BYTES + 1)
.read_to_end(&mut bytes)
.map_err(|source| error(format!("read {label}: {source}")))?;
if bytes.len() as u64 > MAX_BYTES {
return Err(error(format!("{label} exceeds {MAX_BYTES} bytes")));
}
let after = file
.metadata()
.map_err(|source| error(format!("inspect {label}: {source}")))?;
if before.len() != after.len() || before.modified().ok() != after.modified().ok() {
return Err(error(format!("{label} changed while it was read")));
}
Ok(OpenedBytes {
sha256: digest(&bytes),
bytes,
})
}
#[cfg(unix)]
fn metadata_value_matches<T>(actual: T, expected: u64) -> bool
where
T: TryInto<u64>,
{
matches!(actual.try_into(), Ok(value) if value == expected)
}
#[cfg(unix)]
fn same_inode(path: &fs::Metadata, descriptor: &rustix::fs::Stat) -> bool {
use std::os::unix::fs::MetadataExt;
metadata_value_matches(descriptor.st_dev, path.dev())
&& metadata_value_matches(descriptor.st_ino, path.ino())
}
fn canonical_path(path: &Path) -> Result<PathBuf, CliError> {
reject_path(path)?;
fs::canonicalize(path)
.map_err(|source| error(format!("canonicalize {}: {source}", path.display())))
}
fn safe_relative(value: &str) -> Result<Vec<&str>, CliError> {
if value.is_empty() || value.starts_with('/') || value.contains('\\') || value.contains('\0') {
return Err(error(format!("unsafe relative path: {value:?}")));
}
let parts = value.split('/').collect::<Vec<_>>();
if parts
.iter()
.any(|part| part.is_empty() || *part == "." || *part == "..")
{
return Err(error(format!("unsafe relative path: {value:?}")));
}
Ok(parts)
}
fn reject_path(path: &Path) -> Result<(), CliError> {
let mut cursor = PathBuf::new();
for component in path.components() {
cursor.push(component.as_os_str());
if matches!(
component,
std::path::Component::Prefix(_) | std::path::Component::RootDir
) {
continue;
}
match fs::symlink_metadata(&cursor) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(error(format!(
"anchored path is a symlink: {}",
cursor.display()
)));
}
Ok(_) => {}
Err(source) if source.kind() == std::io::ErrorKind::NotFound => break,
Err(source) => return Err(error(format!("inspect {}: {source}", cursor.display()))),
}
}
Ok(())
}
fn digest(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
fn error(message: impl Into<String>) -> CliError {
CliError::message(message.into())
}
#[cfg(all(test, unix))]
mod tests {
use super::metadata_value_matches;
#[test]
fn metadata_value_match_accepts_portable_signed_and_unsigned_values() {
assert!(metadata_value_matches(7_u64, 7));
assert!(metadata_value_matches(7_i32, 7));
assert!(!metadata_value_matches(-1_i32, u64::MAX));
assert!(!metadata_value_matches(8_u64, 7));
}
}