use std::{
io::Read,
path::{Component, Path, PathBuf},
sync::{
atomic::{AtomicU64, Ordering},
Arc,
},
time::UNIX_EPOCH,
};
use murmur_artifact::{ContainmentClass, ExportMode, FileExport, PeerFilesExport};
use serde::Serialize;
use crate::{errors::RuntimeError, trace::ResourceTraceAppender};
pub const RESOURCE_PATH_PREFIX: &str = "/resources/";
const FILES_ROUTE: &str = "files";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeclaredExport {
pub declared_root: String,
pub root: PathBuf,
pub mode: ExportMode,
pub max_bytes: u64,
}
impl DeclaredExport {
pub fn resolve(accessible_workdir: &Path, export: &FileExport) -> Self {
Self {
declared_root: export.root.clone(),
root: accessible_workdir.join(&export.root),
mode: export.mode,
max_bytes: export.max_bytes,
}
}
pub fn for_peer_files(accessible_workdir: &Path, export: &PeerFilesExport) -> Self {
Self {
declared_root: export.root.clone(),
root: accessible_workdir.join(&export.root),
mode: ExportMode::ReadOnly,
max_bytes: export.max_bytes,
}
}
}
pub fn check_peer_files_root(
accessible_workdir: &Path,
export: &PeerFilesExport,
) -> Result<(), RuntimeError> {
check_root_within_workdir(accessible_workdir, "exports.peer_files.root", &export.root)
}
pub fn check_export_root(
accessible_workdir: &Path,
export: &FileExport,
) -> Result<(), RuntimeError> {
check_root_within_workdir(accessible_workdir, "exports.files.root", &export.root)
}
fn check_root_within_workdir(
accessible_workdir: &Path,
field: &str,
root: &str,
) -> Result<(), RuntimeError> {
let Ok(workdir_canon) = std::fs::canonicalize(accessible_workdir) else {
return Ok(());
};
let Ok(resolved) = std::fs::canonicalize(workdir_canon.join(root)) else {
return Ok(());
};
if !resolved.starts_with(&workdir_canon) {
return Err(RuntimeError::ExportRootOutsideWorkdir {
field: field.to_string(),
declared: root.to_string(),
resolved: resolved.display().to_string(),
workdir: workdir_canon.display().to_string(),
});
}
Ok(())
}
pub struct ResourcePlane {
export: Option<DeclaredExport>,
containment_achieved: ContainmentClass,
generation: Arc<AtomicU64>,
trace: Option<Arc<ResourceTraceAppender>>,
}
impl ResourcePlane {
pub fn new(
accessible_workdir: &Path,
export: Option<&FileExport>,
containment_achieved: ContainmentClass,
generation: Arc<AtomicU64>,
trace: Option<Arc<ResourceTraceAppender>>,
) -> Self {
Self::with_export(
export.map(|export| DeclaredExport::resolve(accessible_workdir, export)),
containment_achieved,
generation,
trace,
)
}
pub fn with_export(
export: Option<DeclaredExport>,
containment_achieved: ContainmentClass,
generation: Arc<AtomicU64>,
trace: Option<Arc<ResourceTraceAppender>>,
) -> Self {
Self {
export,
containment_achieved,
generation,
trace,
}
}
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::SeqCst)
}
pub fn containment_achieved(&self) -> ContainmentClass {
self.containment_achieved
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResourceResponse {
pub status: u16,
pub headers: Vec<(String, String)>,
pub body: Vec<u8>,
}
impl ResourceResponse {
pub(crate) fn framed(status: u16, mut headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
headers.push(("content-length".to_string(), body.len().to_string()));
Self {
status,
headers,
body,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ListResponse {
pub root: String,
pub mode: ExportMode,
pub max_bytes: u64,
pub generation: u64,
pub containment_achieved: ContainmentClass,
pub entries: Vec<ListEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ListEntry {
pub path: String,
pub size_bytes: u64,
pub mtime_ms: u64,
pub sha256: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadResponse {
pub bytes: Vec<u8>,
pub sha256: String,
pub mtime_ms: u64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ResourceError {
NoResourcePlane,
NotFound,
OutsideRoot,
SymlinkRefused,
NotARegularFile,
TooLarge {
max_bytes: u64,
},
MethodNotAllowed,
IoError(String),
}
impl ResourceError {
pub fn code(&self) -> &'static str {
match self {
ResourceError::NoResourcePlane => "no_resource_plane",
ResourceError::NotFound => "not_found",
ResourceError::OutsideRoot => "outside_root",
ResourceError::SymlinkRefused => "symlink_refused",
ResourceError::NotARegularFile => "not_a_regular_file",
ResourceError::TooLarge { .. } => "too_large",
ResourceError::MethodNotAllowed => "method_not_allowed",
ResourceError::IoError(_) => "io_error",
}
}
pub fn status(&self) -> u16 {
match self {
ResourceError::NoResourcePlane | ResourceError::NotFound => 404,
ResourceError::OutsideRoot
| ResourceError::SymlinkRefused
| ResourceError::NotARegularFile => 403,
ResourceError::TooLarge { .. } => 413,
ResourceError::MethodNotAllowed => 405,
ResourceError::IoError(_) => 500,
}
}
pub fn message(&self) -> String {
match self {
ResourceError::NoResourcePlane => {
"this capsule declares no exports.files block, so it has no resource plane"
.to_string()
}
ResourceError::NotFound => "no such file under the export root".to_string(),
ResourceError::OutsideRoot => {
"the requested path resolves outside the export root".to_string()
}
ResourceError::SymlinkRefused => {
"a symlink was encountered and the achieved containment class is scoped".to_string()
}
ResourceError::NotARegularFile => {
"the requested path is not a regular file".to_string()
}
ResourceError::TooLarge { max_bytes } => {
format!("file exceeds the declared exports.files.max_bytes of {max_bytes} bytes")
}
ResourceError::MethodNotAllowed => {
"the resource plane serves GET only; it has no write path".to_string()
}
ResourceError::IoError(detail) => format!("read failed: {detail}"),
}
}
}
fn io_to_resource_error(error: &std::io::Error) -> ResourceError {
match error.kind() {
std::io::ErrorKind::NotFound => ResourceError::NotFound,
std::io::ErrorKind::NotADirectory => ResourceError::NotFound,
std::io::ErrorKind::PermissionDenied => ResourceError::IoError(error.to_string()),
_ if error.raw_os_error() == Some(libc::ELOOP) => ResourceError::SymlinkRefused,
_ => ResourceError::IoError(error.to_string()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SymlinkPolicy {
Refuse,
FollowWithinRoot,
}
pub fn symlink_policy(class: ContainmentClass) -> SymlinkPolicy {
match class {
ContainmentClass::Scoped => SymlinkPolicy::Refuse,
ContainmentClass::Sealed | ContainmentClass::Advisory => SymlinkPolicy::FollowWithinRoot,
}
}
fn percent_decode(raw: &str) -> Vec<u8> {
let bytes = raw.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
let hex = std::str::from_utf8(&bytes[index + 1..index + 3])
.ok()
.and_then(|pair| u8::from_str_radix(pair, 16).ok());
if let Some(byte) = hex {
out.push(byte);
index += 3;
continue;
}
}
out.push(bytes[index]);
index += 1;
}
out
}
pub(crate) fn export_relpath_components(raw: &str) -> Result<Vec<String>, ResourceError> {
let decoded = percent_decode(raw);
if decoded.is_empty() || decoded.contains(&0) {
return Err(ResourceError::OutsideRoot);
}
let decoded = String::from_utf8(decoded).map_err(|_| ResourceError::OutsideRoot)?;
if decoded.starts_with('/') || Path::new(&decoded).is_absolute() {
return Err(ResourceError::OutsideRoot);
}
let mut components = Vec::new();
for part in decoded.strip_suffix('/').unwrap_or(&decoded).split('/') {
if part.is_empty() || part == "." || part == ".." {
return Err(ResourceError::OutsideRoot);
}
components.push(part.to_string());
}
if components.is_empty() {
return Err(ResourceError::OutsideRoot);
}
for component in &components {
if Path::new(component)
.components()
.any(|c| !matches!(c, Component::Normal(_)))
{
return Err(ResourceError::OutsideRoot);
}
}
Ok(components)
}
fn resolve_beneath(
root_canon: &Path,
components: &[String],
policy: SymlinkPolicy,
) -> Result<PathBuf, ResourceError> {
let mut target = root_canon.to_path_buf();
if policy == SymlinkPolicy::Refuse {
for component in components {
target.push(component);
let metadata =
std::fs::symlink_metadata(&target).map_err(|e| io_to_resource_error(&e))?;
if metadata.file_type().is_symlink() {
return Err(ResourceError::SymlinkRefused);
}
}
} else {
for component in components {
target.push(component);
}
}
let canonical = std::fs::canonicalize(&target).map_err(|e| io_to_resource_error(&e))?;
if !canonical.starts_with(root_canon) {
return Err(ResourceError::OutsideRoot);
}
Ok(canonical)
}
fn open_regular_file(path: &Path) -> Result<(std::fs::File, std::fs::Metadata), ResourceError> {
use std::os::unix::fs::OpenOptionsExt;
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK)
.open(path)
.map_err(|e| io_to_resource_error(&e))?;
let metadata = file.metadata().map_err(|e| io_to_resource_error(&e))?;
if !metadata.is_file() {
return Err(ResourceError::NotARegularFile);
}
Ok((file, metadata))
}
fn mtime_ms(metadata: &std::fs::Metadata) -> u64 {
metadata
.modified()
.ok()
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub(crate) fn resolve_relpath_beneath_root(
export: &DeclaredExport,
relpath: &str,
policy: SymlinkPolicy,
) -> Result<(PathBuf, String), ResourceError> {
let components = export_relpath_components(relpath)?;
let root_canon = std::fs::canonicalize(&export.root).map_err(|e| io_to_resource_error(&e))?;
let target = resolve_beneath(&root_canon, &components, policy)?;
let relative = target
.strip_prefix(&root_canon)
.map_err(|_| ResourceError::OutsideRoot)?
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/");
if relative.is_empty() {
return Err(ResourceError::OutsideRoot);
}
Ok((target, relative))
}
pub(crate) fn read_export_file_with_policy(
export: &DeclaredExport,
relpath: &str,
policy: SymlinkPolicy,
) -> Result<ReadResponse, ResourceError> {
let (target, _) = resolve_relpath_beneath_root(export, relpath, policy)?;
let (file, metadata) = open_regular_file(&target)?;
if metadata.len() > export.max_bytes {
return Err(ResourceError::TooLarge {
max_bytes: export.max_bytes,
});
}
let mtime = mtime_ms(&metadata);
let mut bytes = Vec::with_capacity(metadata.len() as usize);
let mut limited = file.take(export.max_bytes.saturating_add(1));
limited
.read_to_end(&mut bytes)
.map_err(|e| io_to_resource_error(&e))?;
if bytes.len() as u64 > export.max_bytes {
return Err(ResourceError::TooLarge {
max_bytes: export.max_bytes,
});
}
let sha256 = murmur_artifact::sha256_hex(&bytes);
Ok(ReadResponse {
bytes,
sha256,
mtime_ms: mtime,
})
}
pub(crate) fn read_file_beneath_root(
root: &Path,
relpath: &str,
max_bytes: u64,
policy: SymlinkPolicy,
) -> Result<ReadResponse, ResourceError> {
let export = DeclaredExport {
declared_root: ".".to_string(),
root: root.to_path_buf(),
mode: ExportMode::ReadOnly,
max_bytes,
};
read_export_file_with_policy(&export, relpath, policy)
}
fn list_export_files(
export: &DeclaredExport,
policy: SymlinkPolicy,
) -> Result<Vec<ListEntry>, ResourceError> {
let root_canon = match std::fs::canonicalize(&export.root) {
Ok(path) => path,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(error) => return Err(io_to_resource_error(&error)),
};
if !root_canon.is_dir() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
let mut pending = vec![(root_canon.clone(), String::new())];
while let Some((dir, prefix)) = pending.pop() {
let reader = match std::fs::read_dir(&dir) {
Ok(reader) => reader,
Err(_) => continue,
};
for entry in reader.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
let relative = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}/{name}")
};
let Ok(file_type) = entry.file_type() else {
continue;
};
let path = entry.path();
if file_type.is_symlink() {
if policy == SymlinkPolicy::Refuse {
continue;
}
let Ok(canonical) = std::fs::canonicalize(&path) else {
continue;
};
if !canonical.starts_with(&root_canon) {
continue;
}
if let Some(entry) = describe_entry(&canonical, relative) {
entries.push(entry);
}
continue;
}
if file_type.is_dir() {
pending.push((path, relative));
} else if file_type.is_file() {
if let Some(entry) = describe_entry(&path, relative) {
entries.push(entry);
}
}
}
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(entries)
}
fn describe_entry(path: &Path, relative: String) -> Option<ListEntry> {
let (mut file, metadata) = open_regular_file(path).ok()?;
let mtime = mtime_ms(&metadata);
let (size_bytes, sha256) = murmur_artifact::sha256_hex_of_reader(&mut file).ok()?;
Some(ListEntry {
path: relative,
size_bytes,
mtime_ms: mtime,
sha256,
})
}
pub async fn handle_resource_request(
plane: &ResourcePlane,
method: &str,
raw_path: &str,
) -> ResourceResponse {
let path = raw_path.split('?').next().unwrap_or("");
let Some(rest) = path.strip_prefix(RESOURCE_PATH_PREFIX) else {
return error_response(&ResourceError::NotFound, None);
};
if rest == FILES_ROUTE || rest == "files/" {
return handle_list(plane, method).await;
}
if let Some(relpath) = rest.strip_prefix("files/") {
return handle_read(plane, method, relpath).await;
}
if method != "GET" {
return error_response(&ResourceError::MethodNotAllowed, None);
}
error_response(&ResourceError::NotFound, None)
}
fn servable_export<'a>(
plane: &'a ResourcePlane,
method: &str,
) -> Result<&'a DeclaredExport, ResourceError> {
if method != "GET" {
return Err(ResourceError::MethodNotAllowed);
}
plane.export.as_ref().ok_or(ResourceError::NoResourcePlane)
}
async fn handle_list(plane: &ResourcePlane, method: &str) -> ResourceResponse {
let generation = plane.generation();
let class = plane.containment_achieved;
let export = match servable_export(plane, method) {
Ok(export) => export.clone(),
Err(error) => return refuse_list(plane, "", &error, generation, class).await,
};
let listed = {
let export = export.clone();
let policy = symlink_policy(class);
tokio::task::spawn_blocking(move || list_export_files(&export, policy))
.await
.unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
};
let entries = match listed {
Ok(entries) => entries,
Err(error) => {
return refuse_list(plane, &export.declared_root, &error, generation, class).await
}
};
if let Some(trace) = &plane.trace {
trace
.write_resource_list(
&export.declared_root,
entries.len(),
entries.iter().map(|entry| entry.size_bytes).sum(),
generation,
class,
"ok",
None,
)
.await;
}
let body = ListResponse {
root: export.declared_root.clone(),
mode: export.mode,
max_bytes: export.max_bytes,
generation,
containment_achieved: class,
entries,
};
ResourceResponse::framed(
200,
vec![
("content-type".to_string(), "application/json".to_string()),
("x-murmur-generation".to_string(), generation.to_string()),
("x-murmur-containment".to_string(), class.to_string()),
("x-murmur-export-root".to_string(), export.declared_root),
],
serde_json::to_vec(&body).unwrap_or_default(),
)
}
async fn handle_read(plane: &ResourcePlane, method: &str, relpath: &str) -> ResourceResponse {
let generation = plane.generation();
let class = plane.containment_achieved;
let traced_path = decoded_request_path(relpath);
let export = match servable_export(plane, method) {
Ok(export) => export.clone(),
Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
};
let read = {
let export = export.clone();
let policy = symlink_policy(class);
let relpath = relpath.to_string();
tokio::task::spawn_blocking(move || read_export_file_with_policy(&export, &relpath, policy))
.await
.unwrap_or_else(|join| Err(ResourceError::IoError(join.to_string())))
};
let response = match read {
Ok(response) => response,
Err(error) => return refuse_read(plane, &traced_path, &error, generation, class).await,
};
if let Some(trace) = &plane.trace {
trace
.write_resource_read(
&traced_path,
"ok",
Some(response.bytes.len() as u64),
Some(response.sha256.clone()),
generation,
class,
None,
)
.await;
}
ResourceResponse::framed(
200,
vec![
(
"content-type".to_string(),
"application/octet-stream".to_string(),
),
(
"etag".to_string(),
format!("\"sha256:{}\"", response.sha256),
),
(
"x-murmur-mtime-ms".to_string(),
response.mtime_ms.to_string(),
),
("x-murmur-generation".to_string(), generation.to_string()),
("x-murmur-containment".to_string(), class.to_string()),
("x-murmur-export-root".to_string(), export.declared_root),
],
response.bytes,
)
}
async fn refuse_list(
plane: &ResourcePlane,
root: &str,
error: &ResourceError,
generation: u64,
class: ContainmentClass,
) -> ResourceResponse {
if let Some(trace) = &plane.trace {
trace
.write_resource_list(
root,
0,
0,
generation,
class,
error.code(),
Some(error.message()),
)
.await;
}
error_response(error, Some(generation))
}
async fn refuse_read(
plane: &ResourcePlane,
path: &str,
error: &ResourceError,
generation: u64,
class: ContainmentClass,
) -> ResourceResponse {
if let Some(trace) = &plane.trace {
trace
.write_resource_read(
path,
error.code(),
None,
None,
generation,
class,
Some(error.message()),
)
.await;
}
error_response(error, Some(generation))
}
fn decoded_request_path(raw: &str) -> String {
String::from_utf8_lossy(&percent_decode(raw)).into_owned()
}
fn error_response(error: &ResourceError, generation: Option<u64>) -> ResourceResponse {
let body = serde_json::json!({"error": error.code(), "message": error.message()});
let mut headers = vec![("content-type".to_string(), "application/json".to_string())];
if let Some(generation) = generation {
headers.push(("x-murmur-generation".to_string(), generation.to_string()));
}
if matches!(error, ResourceError::MethodNotAllowed) {
headers.push(("allow".to_string(), "GET".to_string()));
}
ResourceResponse::framed(
error.status(),
headers,
serde_json::to_vec(&body).unwrap_or_default(),
)
}
pub fn reason_phrase(status: u16) -> &'static str {
match status {
200 => "OK",
400 => "Bad Request",
403 => "Forbidden",
404 => "Not Found",
405 => "Method Not Allowed",
410 => "Gone",
413 => "Payload Too Large",
_ => "Internal Server Error",
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn export_for(root: &Path, max_bytes: u64) -> DeclaredExport {
DeclaredExport {
declared_root: "out/".to_string(),
root: root.to_path_buf(),
mode: ExportMode::ReadOnly,
max_bytes,
}
}
#[test]
fn percent_decoding_happens_before_validation() {
assert_eq!(
export_relpath_components("%2e%2e%2fsecret.txt"),
Err(ResourceError::OutsideRoot)
);
assert_eq!(
export_relpath_components("%2E%2E/secret.txt"),
Err(ResourceError::OutsideRoot)
);
}
#[test]
fn percent_decoding_accepts_ordinary_escapes() {
assert_eq!(
export_relpath_components("sub%20dir/report.md").unwrap(),
vec!["sub dir".to_string(), "report.md".to_string()]
);
}
#[test]
fn a_lone_percent_is_left_as_written() {
assert_eq!(percent_decode("100%"), b"100%".to_vec());
assert_eq!(percent_decode("%zz"), b"%zz".to_vec());
}
#[test]
fn every_escaping_shape_is_refused() {
for path in [
"",
"..",
"../secret.txt",
"out/../../secret.txt",
"/etc/passwd",
"/",
"./report.md",
"a//b",
"a/./b",
"a/../b",
"report.md%00.txt",
"%00",
] {
assert_eq!(
export_relpath_components(path),
Err(ResourceError::OutsideRoot),
"path {path:?} must be refused"
);
}
}
#[test]
fn a_trailing_slash_names_the_same_file() {
assert_eq!(
export_relpath_components("dir/report.md/").unwrap(),
vec!["dir".to_string(), "report.md".to_string()]
);
}
#[test]
fn ordinary_relative_paths_survive() {
assert_eq!(
export_relpath_components("a/b/c.txt").unwrap(),
vec!["a".to_string(), "b".to_string(), "c.txt".to_string()]
);
}
#[test]
fn symlink_policy_covers_every_containment_class() {
assert_eq!(
symlink_policy(ContainmentClass::Scoped),
SymlinkPolicy::Refuse
);
assert_eq!(
symlink_policy(ContainmentClass::Sealed),
SymlinkPolicy::FollowWithinRoot
);
assert_eq!(
symlink_policy(ContainmentClass::Advisory),
SymlinkPolicy::FollowWithinRoot
);
}
#[test]
fn an_escaping_symlink_is_refused_at_every_class() {
let dir = tempfile::tempdir().unwrap();
let secret = dir.path().join("secret.txt");
std::fs::write(&secret, b"top secret").unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::os::unix::fs::symlink(&secret, root.join("escape.txt")).unwrap();
let export = export_for(&root, 1024);
for class in [
ContainmentClass::Advisory,
ContainmentClass::Scoped,
ContainmentClass::Sealed,
] {
let error = read_export_file_with_policy(&export, "escape.txt", symlink_policy(class))
.expect_err("an escaping symlink must never be served");
let expected = match class {
ContainmentClass::Scoped => ResourceError::SymlinkRefused,
_ => ResourceError::OutsideRoot,
};
assert_eq!(error, expected, "class {class}");
let entries = list_export_files(&export, symlink_policy(class)).unwrap();
assert!(
entries.is_empty(),
"class {class}: an escaping symlink must not be listed; got {entries:?}"
);
}
}
#[test]
fn an_in_root_symlink_is_refused_under_scoped_and_followed_otherwise() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("real.txt"), b"real bytes").unwrap();
std::os::unix::fs::symlink("real.txt", root.join("alias.txt")).unwrap();
let export = export_for(&root, 1024);
assert_eq!(
read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::Refuse),
Err(ResourceError::SymlinkRefused)
);
assert_eq!(
list_export_files(&export, SymlinkPolicy::Refuse)
.unwrap()
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec!["real.txt".to_string()]
);
let followed =
read_export_file_with_policy(&export, "alias.txt", SymlinkPolicy::FollowWithinRoot)
.unwrap();
assert_eq!(followed.bytes, b"real bytes");
assert_eq!(
list_export_files(&export, SymlinkPolicy::FollowWithinRoot)
.unwrap()
.len(),
2
);
}
#[test]
fn a_symlinked_directory_on_the_path_is_refused_under_scoped() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir_all(root.join("real")).unwrap();
std::fs::write(root.join("real/report.md"), b"hello").unwrap();
std::os::unix::fs::symlink("real", root.join("alias")).unwrap();
let export = export_for(&root, 1024);
assert_eq!(
read_export_file_with_policy(&export, "alias/report.md", SymlinkPolicy::Refuse),
Err(ResourceError::SymlinkRefused)
);
}
#[test]
fn a_read_hashes_the_bytes_it_serves() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("report.md"), b"# report\n").unwrap();
let response = read_export_file_with_policy(
&export_for(&root, 1024),
"report.md",
SymlinkPolicy::FollowWithinRoot,
)
.unwrap();
assert_eq!(response.bytes, b"# report\n");
assert_eq!(response.sha256, murmur_artifact::sha256_hex(b"# report\n"));
assert!(response.mtime_ms > 0);
}
#[test]
fn max_bytes_refuses_the_read_but_not_the_listing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("small.txt"), vec![b'a'; 100]).unwrap();
std::fs::write(root.join("big.bin"), vec![b'b'; 4096]).unwrap();
let export = export_for(&root, 1024);
assert_eq!(
read_export_file_with_policy(&export, "big.bin", SymlinkPolicy::FollowWithinRoot),
Err(ResourceError::TooLarge { max_bytes: 1024 })
);
assert_eq!(
read_export_file_with_policy(&export, "small.txt", SymlinkPolicy::FollowWithinRoot)
.unwrap()
.bytes
.len(),
100
);
let entries = list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap();
let big = entries.iter().find(|e| e.path == "big.bin").unwrap();
assert_eq!(
big.size_bytes, 4096,
"discovery must not hide an oversized file"
);
}
#[test]
fn a_directory_is_not_a_regular_file() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir_all(root.join("nested")).unwrap();
assert_eq!(
read_export_file_with_policy(
&export_for(&root, 1024),
"nested",
SymlinkPolicy::FollowWithinRoot
),
Err(ResourceError::NotARegularFile)
);
}
#[test]
fn a_missing_root_lists_empty_and_reads_not_found() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
let export = export_for(&root, 1024);
assert_eq!(
list_export_files(&export, SymlinkPolicy::FollowWithinRoot).unwrap(),
Vec::new()
);
assert_eq!(
read_export_file_with_policy(&export, "report.md", SymlinkPolicy::FollowWithinRoot),
Err(ResourceError::NotFound)
);
}
#[test]
fn a_listing_is_recursive_and_sorted() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir_all(root.join("b/c")).unwrap();
std::fs::write(root.join("z.txt"), b"z").unwrap();
std::fs::write(root.join("b/y.txt"), b"y").unwrap();
std::fs::write(root.join("b/c/x.txt"), b"x").unwrap();
let entries = list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse).unwrap();
assert_eq!(
entries
.iter()
.map(|entry| entry.path.clone())
.collect::<Vec<_>>(),
vec![
"b/c/x.txt".to_string(),
"b/y.txt".to_string(),
"z.txt".to_string()
]
);
assert_eq!(entries[2].sha256, murmur_artifact::sha256_hex(b"z"));
}
#[test]
fn a_fifo_is_refused_without_blocking() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
let fifo = root.join("pipe");
let status = std::process::Command::new("mkfifo")
.arg(&fifo)
.status()
.expect("mkfifo should be runnable");
if !status.success() {
return;
}
assert_eq!(
read_export_file_with_policy(
&export_for(&root, 1024),
"pipe",
SymlinkPolicy::FollowWithinRoot
),
Err(ResourceError::NotARegularFile)
);
assert!(
list_export_files(&export_for(&root, 1024), SymlinkPolicy::Refuse)
.unwrap()
.is_empty()
);
}
#[test]
fn every_error_maps_to_its_documented_status() {
for (error, code, status) in [
(ResourceError::NoResourcePlane, "no_resource_plane", 404),
(ResourceError::NotFound, "not_found", 404),
(ResourceError::OutsideRoot, "outside_root", 403),
(ResourceError::SymlinkRefused, "symlink_refused", 403),
(ResourceError::NotARegularFile, "not_a_regular_file", 403),
(ResourceError::TooLarge { max_bytes: 1 }, "too_large", 413),
(ResourceError::MethodNotAllowed, "method_not_allowed", 405),
(ResourceError::IoError("x".into()), "io_error", 500),
] {
assert_eq!(error.code(), code);
assert_eq!(error.status(), status);
}
}
fn file_export(root: &str) -> FileExport {
FileExport {
root: root.to_string(),
mode: ExportMode::ReadOnly,
max_bytes: 1024,
}
}
#[test]
fn a_root_that_already_escapes_the_workdir_refuses_the_launch() {
let workdir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
std::os::unix::fs::symlink(outside.path(), workdir.path().join("out")).unwrap();
let error = check_export_root(workdir.path(), &file_export("out/")).unwrap_err();
let RuntimeError::ExportRootOutsideWorkdir {
declared, resolved, ..
} = &error
else {
panic!("expected ExportRootOutsideWorkdir, got {error:?}");
};
assert_eq!(declared, "out/");
assert_eq!(
Path::new(resolved),
std::fs::canonicalize(outside.path()).unwrap()
);
}
#[test]
fn a_root_that_does_not_exist_yet_is_accepted() {
let workdir = tempfile::tempdir().unwrap();
assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
std::fs::create_dir(workdir.path().join("out")).unwrap();
assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
}
#[test]
fn a_workdir_that_does_not_exist_yet_is_accepted() {
let parent = tempfile::tempdir().unwrap();
let missing = parent.path().join("not-created-yet");
assert!(check_export_root(&missing, &file_export("out/")).is_ok());
}
#[test]
fn a_root_symlinked_within_the_workdir_is_accepted() {
let workdir = tempfile::tempdir().unwrap();
std::fs::create_dir(workdir.path().join("real")).unwrap();
std::os::unix::fs::symlink(workdir.path().join("real"), workdir.path().join("out"))
.unwrap();
assert!(check_export_root(workdir.path(), &file_export("out/")).is_ok());
}
fn plane_for(export: Option<DeclaredExport>, class: ContainmentClass) -> ResourcePlane {
ResourcePlane::with_export(export, class, Arc::new(AtomicU64::new(0)), None)
}
#[tokio::test]
async fn an_undeclared_plane_denies_both_verbs() {
let plane = plane_for(None, ContainmentClass::Advisory);
for path in ["/resources/files", "/resources/files/report.md"] {
let response = handle_resource_request(&plane, "GET", path).await;
assert_eq!(response.status, 404);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(body["error"], "no_resource_plane");
}
}
#[tokio::test]
async fn every_write_verb_is_refused_with_an_allow_header() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("report.md"), b"hello").unwrap();
let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
for method in ["POST", "PUT", "PATCH", "DELETE"] {
let response =
handle_resource_request(&plane, method, "/resources/files/report.md").await;
assert_eq!(response.status, 405, "{method}");
assert!(response
.headers
.iter()
.any(|(name, value)| name == "allow" && value == "GET"));
}
assert_eq!(
std::fs::read(root.join("report.md")).unwrap(),
b"hello".to_vec()
);
assert!(!root.join("new.txt").exists());
}
#[tokio::test]
async fn a_read_carries_its_validator_and_provenance() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
let mut file = std::fs::File::create(root.join("report.md")).unwrap();
file.write_all(b"# report\n").unwrap();
drop(file);
let generation = Arc::new(AtomicU64::new(7));
let plane = ResourcePlane::with_export(
Some(export_for(&root, 1024)),
ContainmentClass::Scoped,
Arc::clone(&generation),
None,
);
let response = handle_resource_request(&plane, "GET", "/resources/files/report.md").await;
assert_eq!(response.status, 200);
assert_eq!(response.body, b"# report\n");
let header = |name: &str| {
response
.headers
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.clone())
.unwrap_or_default()
};
assert_eq!(
header("etag"),
format!("\"sha256:{}\"", murmur_artifact::sha256_hex(b"# report\n"))
);
assert_eq!(header("x-murmur-generation"), "7");
assert_eq!(header("x-murmur-containment"), "scoped");
assert_eq!(header("x-murmur-export-root"), "out/");
}
#[tokio::test]
async fn a_query_string_does_not_select_a_generation() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("report.md"), b"now").unwrap();
let plane = ResourcePlane::with_export(
Some(export_for(&root, 1024)),
ContainmentClass::Advisory,
Arc::new(AtomicU64::new(3)),
None,
);
let response =
handle_resource_request(&plane, "GET", "/resources/files/report.md?generation=1").await;
assert_eq!(response.status, 200);
assert_eq!(response.body, b"now");
}
#[tokio::test]
async fn a_list_reports_the_declared_block_verbatim() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("report.md"), b"hello").unwrap();
let plane = plane_for(Some(export_for(&root, 4096)), ContainmentClass::Advisory);
let response = handle_resource_request(&plane, "GET", "/resources/files").await;
assert_eq!(response.status, 200);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(body["root"], "out/");
assert_eq!(body["mode"], "read-only");
assert_eq!(body["max_bytes"], 4096);
assert_eq!(body["generation"], 0);
assert_eq!(body["containment_achieved"], "advisory");
assert_eq!(body["entries"][0]["path"], "report.md");
assert_eq!(body["entries"][0]["size_bytes"], 5);
}
#[tokio::test]
async fn an_unknown_route_under_the_prefix_is_not_found() {
let plane = plane_for(None, ContainmentClass::Advisory);
let response = handle_resource_request(&plane, "GET", "/resources/secrets").await;
assert_eq!(response.status, 404);
let body: serde_json::Value = serde_json::from_slice(&response.body).unwrap();
assert_eq!(body["error"], "not_found");
}
#[tokio::test]
async fn an_unknown_route_still_answers_the_method_first() {
let plane = plane_for(None, ContainmentClass::Advisory);
let response = handle_resource_request(&plane, "PUT", "/resources/secrets").await;
assert_eq!(response.status, 405);
assert!(response
.headers
.iter()
.any(|(name, value)| name == "allow" && value == "GET"));
}
#[tokio::test]
async fn every_response_carries_its_own_content_length() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("out");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("report.md"), b"hello").unwrap();
let plane = plane_for(Some(export_for(&root, 1024)), ContainmentClass::Advisory);
for (method, path) in [
("GET", "/resources/files"),
("GET", "/resources/files/report.md"),
("GET", "/resources/files/../secret.txt"),
("PUT", "/resources/files/report.md"),
] {
let response = handle_resource_request(&plane, method, path).await;
let declared = response
.headers
.iter()
.find(|(name, _)| name == "content-length")
.map(|(_, value)| value.clone())
.unwrap_or_else(|| panic!("{method} {path} must declare a content-length"));
assert_eq!(
declared,
response.body.len().to_string(),
"{method} {path}: content-length must match the body served"
);
}
}
}