use std::time::UNIX_EPOCH;
use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde::{Deserialize, Serialize};
use super::handlers::AppState;
use crate::fs::{platform, FsError, FsRoot};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FsEntry {
pub path: String,
pub size: u64,
pub mtime_ms: u64,
pub is_dir: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sha256: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PathQuery {
pub path: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DeleteQuery {
pub path: String,
#[serde(default)]
pub recursive: bool,
#[serde(default)]
pub dry_run: bool,
#[serde(default)]
pub limit: Option<usize>,
}
pub fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
(
status,
Json(serde_json::json!({ "error": code, "message": message })),
)
.into_response()
}
pub fn fs_error_response(error: FsError) -> Response {
match error {
FsError::Malformed(reason) => error_response(StatusCode::BAD_REQUEST, "bad-path", reason),
FsError::Escapes => error_response(
StatusCode::FORBIDDEN,
"path-escapes-root",
"path resolves outside the configured root",
),
FsError::NotFound => error_response(
StatusCode::NOT_FOUND,
"not-found",
"no such file or directory",
),
}
}
pub fn fs_not_enabled() -> Response {
error_response(
StatusCode::FORBIDDEN,
"fs-not-enabled",
"the filesystem API is disabled; start with --fs-root <path> to enable it",
)
}
pub fn mtime_ms(meta: &std::fs::Metadata) -> u64 {
meta.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn entry_for(
root: &FsRoot,
absolute: &std::path::Path,
meta: &std::fs::Metadata,
sha256: Option<String>,
) -> FsEntry {
FsEntry {
path: root.relative(absolute).unwrap_or_default(),
size: if meta.is_dir() { 0 } else { meta.len() },
mtime_ms: mtime_ms(meta),
is_dir: meta.is_dir(),
sha256,
}
}
pub const DEFAULT_LIST_LIMIT: usize = 1_000;
pub const MAX_LIST_LIMIT: usize = 10_000;
fn resolve_limit(requested: Option<usize>) -> usize {
requested
.unwrap_or(DEFAULT_LIST_LIMIT)
.clamp(1, MAX_LIST_LIMIT)
}
pub use crate::fs::UPLOAD_DIR;
fn is_reserved_path(rel: &str) -> bool {
rel.split('/').any(|segment| segment == UPLOAD_DIR)
}
fn reserved_path_response() -> Response {
error_response(
StatusCode::FORBIDDEN,
"reserved-path",
"path resolves into the upload staging directory, which is reserved",
)
}
fn refuse_if_reserved(root: &FsRoot, resolved: &std::path::Path) -> Option<Response> {
match root.relative(resolved) {
Some(rel) if is_reserved_path(&rel) => Some(reserved_path_response()),
Some(_) => None,
None => Some(error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"path-resolution-failed",
"could not compute the entry's canonical path",
)),
}
}
fn audit_delete_denial(
audit: &crate::audit::AuditSink,
identity: Option<crate::audit::Identity>,
kind: &str,
path: &str,
status: StatusCode,
reason: &str,
) {
audit.record(
crate::audit::AuditEvent::new(kind)
.with_identity(identity)
.with_route("DELETE /api/v1/fs/file")
.with_denial(status.as_u16(), reason)
.with_file(path.to_string(), None),
);
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ListQuery {
pub path: String,
#[serde(default)]
pub recursive: bool,
#[serde(default)]
pub hash: Option<String>,
#[serde(default)]
pub cursor: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ListResponse {
pub entries: Vec<FsEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
}
pub async fn list(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
match tokio::task::spawn_blocking(move || list_blocking(&root, &query)).await {
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"list-failed",
"listing the directory failed unexpectedly",
),
}
}
fn list_blocking(root: &FsRoot, query: &ListQuery) -> Response {
let base = match root.resolve_existing(&query.path) {
Ok(path) => path,
Err(error) => return fs_error_response(error),
};
match std::fs::metadata(&base) {
Ok(meta) if meta.is_dir() => {}
Ok(_) => {
return error_response(
StatusCode::BAD_REQUEST,
"not-a-directory",
"path is a file; use /api/v1/fs/stat for a single entry",
)
}
Err(_) => return fs_error_response(FsError::NotFound),
}
let limit = resolve_limit(query.limit);
let want_hash = query.hash.as_deref() == Some("sha256");
let mut collected: Vec<(String, std::path::PathBuf, std::fs::Metadata)> = Vec::new();
if let Err(WalkError::Unreadable) = walk(root, &base, query.recursive, &mut collected) {
return error_response(StatusCode::FORBIDDEN, "unreadable", "directory unreadable");
}
collected.sort_by(|a, b| a.0.cmp(&b.0));
let start = match query.cursor.as_deref() {
Some(token) => match decode_cursor(token) {
Some(cursor) => {
collected.partition_point(|(path, _, _)| path.as_str() <= cursor.as_str())
}
None => {
return error_response(
StatusCode::BAD_REQUEST,
"bad-cursor",
"cursor is not a value this endpoint produced",
)
}
},
None => 0,
};
let end = (start + limit).min(collected.len());
let next_cursor =
(end < collected.len()).then(|| encode_cursor(&collected[end.saturating_sub(1)].0));
let mut entries = Vec::with_capacity(end.saturating_sub(start));
for (relative, absolute, meta) in &collected[start..end] {
let sha256 = match want_hash && !meta.is_dir() {
true => match root.resolve_existing(relative) {
Ok(canonical) => match std::fs::metadata(&canonical) {
Ok(target) if target.is_file() => crate::fs::sha256::hash_file(&canonical).ok(),
_ => None,
},
Err(_) => None,
},
false => None,
};
entries.push(entry_for(root, absolute, meta, sha256));
}
Json(ListResponse {
entries,
next_cursor,
})
.into_response()
}
fn encode_cursor(path: &str) -> String {
let mut out = String::with_capacity(path.len() * 2);
for byte in path.as_bytes() {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn decode_cursor(token: &str) -> Option<String> {
if token.is_empty() || token.len() % 2 != 0 {
return None;
}
let mut bytes = Vec::with_capacity(token.len() / 2);
for pair in token.as_bytes().chunks(2) {
let hi = (pair[0] as char).to_digit(16)?;
let lo = (pair[1] as char).to_digit(16)?;
bytes.push((hi * 16 + lo) as u8);
}
String::from_utf8(bytes).ok()
}
enum WalkError {
Unreadable,
}
fn walk(
root: &FsRoot,
base: &std::path::Path,
recursive: bool,
out: &mut Vec<(String, std::path::PathBuf, std::fs::Metadata)>,
) -> Result<(), WalkError> {
let read = std::fs::read_dir(base).map_err(|_| WalkError::Unreadable)?;
for entry in read.flatten() {
let absolute = entry.path();
let Some(relative) = root.relative(&absolute) else {
continue;
};
if is_reserved_path(&relative) {
continue;
}
let Ok(meta) = entry.metadata() else {
continue;
};
let is_dir = meta.is_dir();
out.push((relative, absolute.clone(), meta));
if recursive && is_dir {
let _ = walk(root, &absolute, true, out);
}
}
Ok(())
}
pub fn etag_for(meta: &std::fs::Metadata) -> String {
format!(
"\"{:x}-{:x}-{:x}\"",
meta.len(),
mtime_ms(meta),
platform::file_identity(meta)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RangeOutcome {
Ignore,
Unsatisfiable,
Satisfiable(u64, u64),
}
pub fn parse_range(header: &str, size: u64) -> RangeOutcome {
use RangeOutcome::{Ignore, Satisfiable, Unsatisfiable};
let Some(spec) = header.trim().strip_prefix("bytes=") else {
return Ignore; };
if spec.contains(',') {
return Ignore; }
let Some((from, to)) = spec.split_once('-') else {
return Ignore;
};
let (start, end) = match (from.trim(), to.trim()) {
("", "") => return Ignore,
("", suffix) => {
let Ok(n) = suffix.parse::<u64>() else {
return Ignore;
};
(size.saturating_sub(n), size.saturating_sub(1))
}
(first, "") => {
let Ok(start) = first.parse::<u64>() else {
return Ignore;
};
(start, size.saturating_sub(1))
}
(first, last) => {
let (Ok(start), Ok(end)) = (first.parse::<u64>(), last.parse::<u64>()) else {
return Ignore;
};
if end < start {
return Ignore;
}
(start, end)
}
};
if start >= size {
return Unsatisfiable;
}
Satisfiable(start, end.min(size - 1))
}
pub async fn download(
State(state): State<AppState>,
method: axum::http::Method,
headers: axum::http::HeaderMap,
Query(query): Query<PathQuery>,
) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
let header_str = |name: axum::http::HeaderName| {
headers
.get(name)
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string())
};
let range_header = header_str(axum::http::header::RANGE);
let if_range_header = header_str(axum::http::header::IF_RANGE);
let include_body = method != axum::http::Method::HEAD;
match tokio::task::spawn_blocking(move || {
download_blocking(
&root,
&query,
range_header.as_deref(),
if_range_header.as_deref(),
include_body,
)
})
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"download-failed",
"reading the file failed unexpectedly",
),
}
}
fn download_blocking(
root: &FsRoot,
query: &PathQuery,
range_header: Option<&str>,
if_range_header: Option<&str>,
include_body: bool,
) -> Response {
let resolved = match root.resolve_existing(&query.path) {
Ok(path) => path,
Err(error) => return fs_error_response(error),
};
if let Some(response) = refuse_if_reserved(root, &resolved) {
return response;
}
let meta = match std::fs::metadata(&resolved) {
Ok(meta) if meta.is_file() => meta,
Ok(_) => {
return error_response(
StatusCode::BAD_REQUEST,
"not-a-file",
"path is not a regular file; if it is a directory, use /api/v1/fs/list",
)
}
Err(_) => return fs_error_response(FsError::NotFound),
};
let size = meta.len();
let etag = etag_for(&meta);
let range_allowed = match if_range_header {
Some(sent) => sent == etag,
None => true,
};
let requested = range_header
.filter(|_| range_allowed)
.map(|raw| parse_range(raw, size));
match requested {
Some(RangeOutcome::Satisfiable(start, end)) => {
let length = end - start + 1;
let bytes = if include_body {
match read_span(&resolved, start, length) {
Ok(bytes) => bytes,
Err(_) => return fs_error_response(FsError::NotFound),
}
} else {
Vec::new()
};
(
StatusCode::PARTIAL_CONTENT,
[
("content-type", "application/octet-stream".to_string()),
("accept-ranges", "bytes".to_string()),
("etag", etag),
("content-range", format!("bytes {start}-{end}/{size}")),
("content-length", length.to_string()),
],
bytes,
)
.into_response()
}
Some(RangeOutcome::Unsatisfiable) => (
StatusCode::RANGE_NOT_SATISFIABLE,
[("content-range", format!("bytes */{size}"))],
)
.into_response(),
Some(RangeOutcome::Ignore) | None => {
let bytes = if include_body {
match std::fs::read(&resolved) {
Ok(bytes) => bytes,
Err(_) => return fs_error_response(FsError::NotFound),
}
} else {
Vec::new()
};
let content_length = if include_body {
bytes.len() as u64
} else {
size
};
(
StatusCode::OK,
[
("content-type", "application/octet-stream".to_string()),
("accept-ranges", "bytes".to_string()),
("etag", etag),
("content-length", content_length.to_string()),
],
bytes,
)
.into_response()
}
}
}
fn read_span(path: &std::path::Path, start: u64, length: u64) -> std::io::Result<Vec<u8>> {
use std::io::{Read, Seek, SeekFrom};
let mut file = std::fs::File::open(path)?;
file.seek(SeekFrom::Start(start))?;
let mut buffer = vec![0_u8; length as usize];
file.read_exact(&mut buffer)?;
Ok(buffer)
}
pub async fn delete_file(
State(state): State<AppState>,
identity: Option<axum::Extension<crate::audit::Identity>>,
Query(query): Query<DeleteQuery>,
) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
let audit = state.audit.clone();
let uploads = state.uploads.clone();
let identity = identity.map(|axum::Extension(id)| id);
match tokio::task::spawn_blocking(move || {
delete_file_blocking(&root, &audit, &uploads, identity, &query)
})
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"delete-panicked",
"removing the file failed unexpectedly",
),
}
}
fn split_last_component(rel: &str) -> (&str, &str) {
match rel.rfind(['/', '\\']) {
Some(idx) => (&rel[..idx], &rel[idx + 1..]),
None => (".", rel),
}
}
fn delete_file_blocking(
root: &FsRoot,
audit: &crate::audit::AuditSink,
uploads: &crate::fs::UploadStore,
identity: Option<crate::audit::Identity>,
query: &DeleteQuery,
) -> Response {
if let Err(error) = root.resolve_existing(&query.path) {
return fs_error_response(error);
}
let (parent_rel, name) = split_last_component(&query.path);
if name == ".." {
return fs_error_response(FsError::Escapes);
}
if name == "." {
return fs_error_response(FsError::Malformed(
"delete target must name an entry, not `.`",
));
}
let parent = match root.resolve_existing(parent_rel) {
Ok(path) => path,
Err(error) => return fs_error_response(error),
};
let named = parent.join(name);
if !named.starts_with(&parent) {
return fs_error_response(FsError::Escapes);
}
if let Some(response) = refuse_if_reserved(root, &named) {
let (kind, reason) = if response.status() == StatusCode::FORBIDDEN {
("fs.delete.refused", "reserved-path")
} else {
("fs.delete.failed", "path-resolution-failed")
};
audit_delete_denial(
audit,
identity,
kind,
&query.path,
response.status(),
reason,
);
return response;
}
let meta = match std::fs::symlink_metadata(&named) {
Ok(meta) => meta,
Err(_) => return fs_error_response(FsError::NotFound),
};
if meta.is_dir() {
if !query.recursive {
audit_delete_denial(
audit,
identity,
"fs.delete.refused",
&query.path,
StatusCode::BAD_REQUEST,
"recursive-required",
);
return error_response(
StatusCode::BAD_REQUEST,
"recursive-required",
"path is a directory; pass recursive=true to remove it and everything under it",
);
}
let staging_in_tree = uploads.has_live_part_under(&named);
if staging_in_tree && !query.dry_run {
audit_delete_denial(
audit,
identity,
"fs.delete.refused",
&query.path,
StatusCode::CONFLICT,
"staging-in-tree",
);
return error_response(
StatusCode::CONFLICT,
"staging-in-tree",
"an upload is in flight under this path; cancel it or wait for it to finish",
);
}
let limit = resolve_limit(query.limit);
let outcome = crate::fs::remove_tree(root, &named, query.dry_run, limit);
let kind = if query.dry_run && outcome.failures.is_empty() {
"fs.delete.dry_run"
} else if query.dry_run {
"fs.delete.preview_incomplete"
} else if outcome.failures.is_empty() {
"fs.delete"
} else {
"fs.delete.partial"
};
let mut event = crate::audit::AuditEvent::new(kind)
.with_identity(identity)
.with_route("DELETE /api/v1/fs/file")
.with_file(query.path.clone(), Some(outcome.bytes));
event.entries = Some(outcome.removed);
audit.record(event);
let body = serde_json::json!({
"removed": outcome.removed,
"bytes": outcome.bytes,
"entries": outcome.entries,
"truncated": outcome.truncated,
"dry_run": query.dry_run,
"staging_in_tree": staging_in_tree,
});
if outcome.failures.is_empty() {
return (StatusCode::OK, axum::Json(body)).into_response();
}
let mut body = body;
body["failures"] = serde_json::json!(outcome.failures);
if query.dry_run {
body["error"] = serde_json::json!("preview-incomplete");
body["message"] = serde_json::json!(
"some entries could not be enumerated, so removed/bytes is a lower bound; nothing was removed"
);
return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(body)).into_response();
}
body["error"] = serde_json::json!("partial-delete");
body["message"] = serde_json::json!(
"some entries survived; removed/bytes counts what was visited and attempted, not what actually disappeared -- see failures for what did not go"
);
return (StatusCode::INTERNAL_SERVER_ERROR, axum::Json(body)).into_response();
}
if query.dry_run {
audit.record(
crate::audit::AuditEvent::new("fs.delete.dry_run")
.with_identity(identity)
.with_route("DELETE /api/v1/fs/file")
.with_file(query.path.clone(), Some(meta.len())),
);
return (
StatusCode::OK,
axum::Json(serde_json::json!({
"removed": 1,
"bytes": meta.len(),
"entries": [root.relative(&named).unwrap_or_default()],
"truncated": false,
"dry_run": true,
"staging_in_tree": false,
})),
)
.into_response();
}
match platform::remove_entry(&named, &meta) {
Ok(()) => {
audit.record(
crate::audit::AuditEvent::new("fs.delete")
.with_identity(identity)
.with_route("DELETE /api/v1/fs/file")
.with_file(query.path.clone(), None),
);
StatusCode::NO_CONTENT.into_response()
}
Err(e) => {
audit_delete_denial(
audit,
identity,
"fs.delete.failed",
&query.path,
StatusCode::INTERNAL_SERVER_ERROR,
&e.to_string(),
);
error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"delete-failed",
&format!("could not remove the file: {e}"),
)
}
}
}
pub async fn stat(State(state): State<AppState>, Query(query): Query<PathQuery>) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
match tokio::task::spawn_blocking(move || stat_blocking(&root, &query)).await {
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"stat-failed",
"reading the entry failed unexpectedly",
),
}
}
fn stat_blocking(root: &FsRoot, query: &PathQuery) -> Response {
let resolved = match root.resolve_existing(&query.path) {
Ok(path) => path,
Err(error) => return fs_error_response(error),
};
if let Some(response) = refuse_if_reserved(root, &resolved) {
return response;
}
let meta = match std::fs::metadata(&resolved) {
Ok(meta) => meta,
Err(_) => return fs_error_response(FsError::NotFound),
};
let _ = platform::file_identity(&meta);
Json(entry_for(root, &resolved, &meta, None)).into_response()
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CreateUpload {
pub path: String,
pub size: u64,
pub sha256: String,
}
#[derive(Debug, Serialize)]
pub struct UploadState {
pub upload_id: String,
pub offset: u64,
pub chunk_size: usize,
}
fn upload_error_response(error: crate::fs::UploadError) -> Response {
use crate::fs::UploadError;
match error {
UploadError::NotFound => error_response(
StatusCode::NOT_FOUND,
"no-such-upload",
"unknown, completed, or expired upload session",
),
UploadError::OffsetMismatch { expected } => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "offset-mismatch",
"message": "chunk does not continue from the session offset",
"offset": expected,
})),
)
.into_response(),
UploadError::Conflict { upload_id } => (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "destination-busy",
"message": "another upload session is already targeting this path; resume or cancel it by upload_id",
"upload_id": upload_id,
})),
)
.into_response(),
UploadError::TooLarge => error_response(
StatusCode::PAYLOAD_TOO_LARGE,
"chunk-too-large",
"chunk exceeds the advertised chunk_size",
),
UploadError::SizeExceeded => error_response(
StatusCode::PAYLOAD_TOO_LARGE,
"declared-size-exceeded",
"chunk would exceed the size declared when the session was created",
),
UploadError::TooManySessions => error_response(
StatusCode::TOO_MANY_REQUESTS,
"too-many-uploads",
"too many upload sessions are open; finish or cancel one and retry",
),
UploadError::Checksum {
expected, actual, ..
} => (
StatusCode::UNPROCESSABLE_ENTITY,
Json(serde_json::json!({
"error": "checksum-mismatch",
"message": "the assembled bytes do not match the declared digest",
"expected": expected,
"actual": actual,
})),
)
.into_response(),
UploadError::Io {
detail,
raw_os_error,
} => {
let out_of_space = raw_os_error
.map(std::io::Error::from_raw_os_error)
.is_some_and(|e| platform::is_out_of_space(&e));
let status = match out_of_space {
true => StatusCode::INSUFFICIENT_STORAGE,
false => StatusCode::INTERNAL_SERVER_ERROR,
};
error_response(status, "io-error", &detail)
}
}
}
pub async fn create_upload(
State(state): State<AppState>,
identity: Option<axum::Extension<crate::audit::Identity>>,
Json(body): Json<CreateUpload>,
) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
let uploads = state.uploads.clone();
let audit = state.audit.clone();
let identity = identity.map(|axum::Extension(id)| id);
match tokio::task::spawn_blocking(move || {
create_upload_blocking(&root, &uploads, &audit, identity, body)
})
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"create-upload-failed",
"creating the upload session failed unexpectedly",
),
}
}
fn create_upload_blocking(
root: &FsRoot,
uploads: &crate::fs::UploadStore,
audit: &crate::audit::AuditSink,
identity: Option<crate::audit::Identity>,
body: CreateUpload,
) -> Response {
let resolved = match root.resolve_for_create(&body.path) {
Ok(path) => path,
Err(error) => return fs_error_response(error),
};
let dest_rel = match root.relative(&resolved) {
Some(rel) => rel,
None => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"path-resolution-failed",
"could not compute the destination's canonical path",
)
}
};
if is_reserved_path(&dest_rel) {
let response = reserved_path_response();
audit_upload_refusal(audit, identity, &dest_rel, &response, "reserved-path");
return response;
}
if let Ok(meta) = std::fs::symlink_metadata(&resolved) {
if meta.is_dir() {
let response = error_response(
StatusCode::CONFLICT,
"destination-is-directory",
"the destination path already exists as a directory",
);
audit_upload_refusal(
audit,
identity,
&dest_rel,
&response,
"destination-is-directory",
);
return response;
}
}
if body.sha256.len() != 64 || !body.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
let response = error_response(
StatusCode::BAD_REQUEST,
"bad-digest",
"sha256 must be 64 hexadecimal characters",
);
audit_upload_refusal(audit, identity, &dest_rel, &response, "bad-digest");
return response;
}
let dest_for_audit = dest_rel.clone();
sweep_expired_uploads(uploads, audit, crate::fs::SESSION_TTL);
if root.jail_path().is_none() {
let staging = crate::fs::UploadStore::staging_dir(root, &resolved);
record_orphans(
&crate::fs::sweep_orphan_parts_in(&staging, crate::fs::SESSION_TTL),
audit,
);
}
match uploads.create(
root,
&resolved,
dest_rel,
body.size,
body.sha256.to_ascii_lowercase(),
) {
Ok(upload_id) => {
audit.record(
crate::audit::AuditEvent::new("upload.start")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads")
.with_file(dest_for_audit, Some(body.size))
.with_upload_id(upload_id.clone()),
);
(
StatusCode::CREATED,
Json(UploadState {
upload_id,
offset: 0,
chunk_size: uploads.chunk_size(),
}),
)
.into_response()
}
Err(error) => {
let reason = upload_error_code(&error);
let response = upload_error_response(error);
audit_upload_refusal(audit, identity, &dest_for_audit, &response, reason);
response
}
}
}
fn audit_upload_refusal(
audit: &crate::audit::AuditSink,
identity: Option<crate::audit::Identity>,
dest_rel: &str,
response: &Response,
reason: &'static str,
) {
audit.record(
crate::audit::AuditEvent::new("upload.refused")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads")
.with_file(dest_rel.to_string(), None)
.with_denial(response.status().as_u16(), reason),
);
}
fn upload_error_code(error: &crate::fs::UploadError) -> &'static str {
use crate::fs::UploadError;
match error {
UploadError::NotFound => "no-such-upload",
UploadError::OffsetMismatch { .. } => "offset-mismatch",
UploadError::Conflict { .. } => "destination-busy",
UploadError::TooLarge => "chunk-too-large",
UploadError::SizeExceeded => "size-exceeded",
UploadError::TooManySessions => "too-many-uploads",
UploadError::Checksum { .. } => "checksum-mismatch",
UploadError::Io { .. } => "io-error",
}
}
pub async fn upload_status(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
) -> Response {
if state.fs.is_none() {
return fs_not_enabled();
}
match state.uploads.offset(&id) {
Some(offset) => Json(UploadState {
upload_id: id,
offset,
chunk_size: state.uploads.chunk_size(),
})
.into_response(),
None => upload_error_response(crate::fs::UploadError::NotFound),
}
}
pub async fn append_chunk(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
headers: axum::http::HeaderMap,
body: axum::body::Bytes,
) -> Response {
if state.fs.is_none() {
return fs_not_enabled();
}
let offset = match headers
.get("content-range")
.and_then(|v| v.to_str().ok())
.and_then(parse_content_range_start)
{
Some(offset) => offset,
None => {
return error_response(
StatusCode::BAD_REQUEST,
"bad-content-range",
"a Content-Range header of the form 'bytes <start>-<end>/<total>' is required",
)
}
};
let uploads = state.uploads.clone();
match tokio::task::spawn_blocking(move || append_chunk_blocking(&uploads, &id, offset, &body))
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"append-failed",
"writing the chunk failed unexpectedly",
),
}
}
fn append_chunk_blocking(
uploads: &crate::fs::UploadStore,
id: &str,
offset: u64,
body: &[u8],
) -> Response {
match uploads.append(id, offset, body) {
Ok(next) => Json(UploadState {
upload_id: id.to_string(),
offset: next,
chunk_size: uploads.chunk_size(),
})
.into_response(),
Err(error) => upload_error_response(error),
}
}
pub fn parse_content_range_start(header: &str) -> Option<u64> {
let spec = header.trim().strip_prefix("bytes ")?;
let (range, _total) = spec.split_once('/')?;
let (start, _end) = range.split_once('-')?;
start.trim().parse().ok()
}
pub async fn complete_upload(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
identity: Option<axum::Extension<crate::audit::Identity>>,
) -> Response {
let Some(root) = state.fs.clone() else {
return fs_not_enabled();
};
let uploads = state.uploads.clone();
let audit = state.audit.clone();
let identity = identity.map(|axum::Extension(id)| id);
match tokio::task::spawn_blocking(move || {
complete_upload_blocking(&root, &uploads, &audit, identity, &id)
})
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"complete-upload-failed",
"publishing the upload failed unexpectedly",
),
}
}
fn complete_upload_blocking(
root: &FsRoot,
uploads: &crate::fs::UploadStore,
audit: &crate::audit::AuditSink,
identity: Option<crate::audit::Identity>,
id: &str,
) -> Response {
let finished = match uploads.take_for_complete(id) {
Ok(finished) => finished,
Err(error) => {
if let crate::fs::UploadError::Checksum { ref dest_rel, .. } = error {
audit.record(
crate::audit::AuditEvent::new("upload.rejected")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads/{id}/complete")
.with_file(dest_rel.clone(), None)
.with_digest(false)
.with_upload_id(id),
);
}
return upload_error_response(error);
}
};
let destination = match root.resolve_for_create(&finished.dest_rel) {
Ok(path) => path,
Err(error) => {
std::fs::remove_file(&finished.part_path).ok();
uploads.release_destination(&finished.dest_rel);
let response = fs_error_response(error);
audit.record(
crate::audit::AuditEvent::new("upload.failed")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads/{id}/complete")
.with_file(finished.dest_rel.clone(), Some(finished.bytes))
.with_denial(response.status().as_u16(), "destination-resolve-failed")
.with_upload_id(id),
);
return response;
}
};
if let Some(parent) = destination.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
std::fs::remove_file(&finished.part_path).ok();
uploads.release_destination(&finished.dest_rel);
let status = match platform::is_out_of_space(&e) {
true => StatusCode::INSUFFICIENT_STORAGE,
false => StatusCode::INTERNAL_SERVER_ERROR,
};
audit.record(
crate::audit::AuditEvent::new("upload.failed")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads/{id}/complete")
.with_file(finished.dest_rel.clone(), Some(finished.bytes))
.with_denial(status.as_u16(), "directory-creation-failed")
.with_upload_id(id),
);
return error_response(
status,
"io-error",
&format!("could not create the destination directory: {e}"),
);
}
}
if let Err(e) = std::fs::rename(&finished.part_path, &destination) {
std::fs::remove_file(&finished.part_path).ok();
uploads.release_destination(&finished.dest_rel);
let status = match platform::is_out_of_space(&e) {
true => StatusCode::INSUFFICIENT_STORAGE,
false => StatusCode::INTERNAL_SERVER_ERROR,
};
audit.record(
crate::audit::AuditEvent::new("upload.failed")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads/{id}/complete")
.with_file(finished.dest_rel.clone(), Some(finished.bytes))
.with_denial(status.as_u16(), "rename-failed")
.with_upload_id(id),
);
return error_response(
status,
"io-error",
&format!("could not publish the upload: {e}"),
);
}
uploads.release_destination(&finished.dest_rel);
audit.record(
crate::audit::AuditEvent::new("upload.complete")
.with_identity(identity)
.with_route("POST /api/v1/fs/uploads/{id}/complete")
.with_file(finished.dest_rel.clone(), Some(finished.bytes))
.with_digest(true)
.with_upload_id(id),
);
Json(serde_json::json!({
"path": finished.dest_rel,
"size": finished.bytes,
"sha256": finished.digest,
}))
.into_response()
}
pub async fn cancel_upload(
State(state): State<AppState>,
axum::extract::Path(id): axum::extract::Path<String>,
identity: Option<axum::Extension<crate::audit::Identity>>,
) -> Response {
if state.fs.is_none() {
return fs_not_enabled();
}
let uploads = state.uploads.clone();
let audit = state.audit.clone();
let identity = identity.map(|axum::Extension(id)| id);
match tokio::task::spawn_blocking(move || {
cancel_upload_blocking(&uploads, &audit, identity, &id)
})
.await
{
Ok(response) => response,
Err(_) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"cancel-failed",
"cancelling the upload failed unexpectedly",
),
}
}
fn cancel_upload_blocking(
uploads: &crate::fs::UploadStore,
audit: &crate::audit::AuditSink,
identity: Option<crate::audit::Identity>,
id: &str,
) -> Response {
match uploads.cancel(id) {
Some((destination, bytes)) => {
audit.record(
crate::audit::AuditEvent::new("upload.cancel")
.with_identity(identity)
.with_route("DELETE /api/v1/fs/uploads/{id}")
.with_file(destination, Some(bytes))
.with_upload_id(id),
);
StatusCode::NO_CONTENT.into_response()
}
None => upload_error_response(crate::fs::UploadError::NotFound),
}
}
pub fn sweep_expired_uploads(
uploads: &crate::fs::UploadStore,
audit: &crate::audit::AuditSink,
ttl: std::time::Duration,
) -> usize {
let expired = uploads.sweep(ttl);
for (id, destination, bytes) in &expired {
audit.record(
crate::audit::AuditEvent::new("upload.expired")
.with_file(destination.clone(), Some(*bytes))
.with_upload_id(id.clone()),
);
}
expired.len()
}
pub fn sweep_orphaned_uploads(root: &FsRoot, audit: &crate::audit::AuditSink) -> usize {
let removed = crate::fs::sweep_orphan_parts(root);
record_orphans(&removed, audit);
removed.len()
}
fn record_orphans(removed: &[(String, u64)], audit: &crate::audit::AuditSink) {
for (upload_id, bytes) in removed {
let mut event =
crate::audit::AuditEvent::new("upload.orphaned").with_upload_id(upload_id.clone());
event.bytes = Some(*bytes);
audit.record(event);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ranges_parse_into_inclusive_bounds() {
use RangeOutcome::Satisfiable;
assert_eq!(parse_range("bytes=0-4", 11), Satisfiable(0, 4));
assert_eq!(parse_range("bytes=6-10", 11), Satisfiable(6, 10));
assert_eq!(parse_range("bytes=6-", 11), Satisfiable(6, 10));
assert_eq!(parse_range("bytes=-3", 11), Satisfiable(8, 10));
assert_eq!(parse_range("bytes=0-999", 11), Satisfiable(0, 10));
}
#[test]
fn a_well_formed_out_of_bounds_range_is_unsatisfiable() {
use RangeOutcome::Unsatisfiable;
assert_eq!(parse_range("bytes=11-20", 11), Unsatisfiable);
assert_eq!(parse_range("bytes=-0", 11), Unsatisfiable);
assert_eq!(parse_range("bytes=0-4", 0), Unsatisfiable);
}
#[test]
fn malformed_or_unrecognised_ranges_are_ignored_not_refused() {
use RangeOutcome::Ignore;
assert_eq!(parse_range("items=0-4", 11), Ignore); assert_eq!(parse_range("bytes=5-2", 11), Ignore); assert_eq!(parse_range("bytes=0-1,4-5", 11), Ignore); assert_eq!(parse_range("bytes=-", 11), Ignore); }
#[test]
fn resolve_limit_clamps_to_the_configured_bounds() {
assert_eq!(resolve_limit(None), DEFAULT_LIST_LIMIT);
assert_eq!(resolve_limit(Some(0)), 1);
assert_eq!(resolve_limit(Some(999_999)), MAX_LIST_LIMIT);
assert_eq!(resolve_limit(Some(50)), 50);
}
#[test]
fn refuse_if_reserved_fails_closed_when_relative_cannot_be_computed() {
let dir = tempfile::tempdir().expect("tempdir");
let root = FsRoot::new(dir.path()).expect("root");
let unrelated = std::env::temp_dir().join("definitely-not-under-the-root");
let response =
refuse_if_reserved(&root, &unrelated).expect("None must refuse, not silently allow");
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn content_range_yields_its_start_offset() {
assert_eq!(
parse_content_range_start("bytes 0-4194303/209715200"),
Some(0)
);
assert_eq!(
parse_content_range_start("bytes 4194304-8388607/209715200"),
Some(4194304)
);
assert_eq!(parse_content_range_start("bytes 0-4"), None);
assert_eq!(parse_content_range_start("items 0-4/9"), None);
}
#[test]
fn io_error_maps_to_507_only_when_the_raw_code_means_out_of_space() {
use crate::fs::UploadError;
#[cfg(unix)]
let out_of_space_code = libc::ENOSPC;
#[cfg(windows)]
let out_of_space_code = 112;
let out_of_space = upload_error_response(UploadError::Io {
detail: "no space left on device".to_string(),
raw_os_error: Some(out_of_space_code),
});
assert_eq!(out_of_space.status(), StatusCode::INSUFFICIENT_STORAGE);
let unrelated = upload_error_response(UploadError::Io {
detail: "permission denied".to_string(),
raw_os_error: Some(13),
});
assert_eq!(unrelated.status(), StatusCode::INTERNAL_SERVER_ERROR);
let no_code = upload_error_response(UploadError::Io {
detail: "internal lock poisoned".to_string(),
raw_os_error: None,
});
assert_eq!(no_code.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn etag_reflects_size_and_discriminates_on_mtime() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("a.bin");
std::fs::write(&path, b"hello").expect("write");
let meta = std::fs::metadata(&path).expect("metadata");
let etag = etag_for(&meta);
let inner = etag.trim_matches('"');
let parts: Vec<&str> = inner.split('-').collect();
assert_eq!(
parts.len(),
3,
"etag should be three hyphen-separated fields: {etag}"
);
assert_eq!(
u64::from_str_radix(parts[0], 16).expect("size field is hex"),
meta.len()
);
std::thread::sleep(std::time::Duration::from_millis(10));
std::fs::write(&path, b"HELLO").expect("rewrite, same size");
let meta2 = std::fs::metadata(&path).expect("metadata");
if mtime_ms(&meta) == mtime_ms(&meta2) {
return;
}
assert_ne!(
etag,
etag_for(&meta2),
"same-size file with a different mtime must get a different etag"
);
}
#[cfg(unix)]
#[test]
fn an_unreadable_nested_subdirectory_does_not_abort_the_whole_walk() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(dir.path().join("app/locked")).expect("mkdir locked");
std::fs::write(dir.path().join("app/locked/secret.txt"), b"x").expect("write secret");
std::fs::write(dir.path().join("app/visible.txt"), b"y").expect("write visible");
std::fs::write(dir.path().join("app/zzz.txt"), b"z").expect("write zzz");
let root = FsRoot::new(dir.path()).expect("root");
let locked = dir.path().join("app/locked");
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
.expect("chmod locked");
if std::fs::read_dir(&locked).is_ok() {
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
return;
}
let base = root.resolve_existing("app").expect("app resolves");
let mut collected: Vec<(String, std::path::PathBuf, std::fs::Metadata)> = Vec::new();
let result = walk(&root, &base, true, &mut collected);
std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
.expect("restore permissions");
assert!(
result.is_ok(),
"an unreadable nested subdirectory must not fail the whole walk"
);
let paths: Vec<&str> = collected.iter().map(|(p, _, _)| p.as_str()).collect();
assert!(paths.contains(&"app/visible.txt"));
assert!(paths.contains(&"app/zzz.txt"));
assert!(
paths.contains(&"app/locked"),
"the locked directory itself is still listed — only its contents are unreachable"
);
assert!(
!paths.iter().any(|p| p.starts_with("app/locked/")),
"contents of the unreadable subdirectory are simply absent, not fatal"
);
}
#[cfg(windows)]
#[test]
fn postcondition_catches_a_drive_prefix_join_even_without_check_component() {
let parent = std::path::Path::new(r"C:\root\app");
let named = parent.join("C:evil");
assert!(
!named.starts_with(parent),
"a drive-prefixed name must make `join` discard `parent`, or this guard has nothing to catch"
);
let ordinary = parent.join("real.txt");
assert!(ordinary.starts_with(parent));
}
}