shell_tunnel/api/fs.rs
1//! Filesystem endpoints.
2//!
3//! Every path in here reaches the disk through `FsRoot` and by no other route.
4//! The handlers hold no path logic of their own — that separation is what makes
5//! the jail auditable by reading one file.
6
7use std::time::UNIX_EPOCH;
8
9use axum::extract::{Query, State};
10use axum::http::StatusCode;
11use axum::response::{IntoResponse, Response};
12use axum::Json;
13use serde::{Deserialize, Serialize};
14
15use super::handlers::AppState;
16use crate::fs::{platform, FsError, FsRoot};
17
18/// One filesystem entry, as reported by `stat` and by each `list` item.
19///
20/// The same shape in both so a consumer can hold list items and single lookups
21/// in one type.
22#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
23pub struct FsEntry {
24 /// Root-relative path with POSIX separators.
25 pub path: String,
26 /// Size in bytes. Zero for directories.
27 pub size: u64,
28 /// Modification time, Unix milliseconds.
29 pub mtime_ms: u64,
30 /// Whether this entry is a directory.
31 pub is_dir: bool,
32 /// Content hash, only when the caller asked for it.
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub sha256: Option<String>,
35}
36
37/// Query parameters shared by the single-path endpoints.
38#[derive(Debug, Deserialize)]
39pub struct PathQuery {
40 pub path: String,
41}
42
43/// Render a refusal as JSON with a machine-readable code.
44///
45/// The code matters more than the prose: a consumer decides whether to retry,
46/// re-authorise, or give up by reading it.
47pub fn error_response(status: StatusCode, code: &str, message: &str) -> Response {
48 (
49 status,
50 Json(serde_json::json!({ "error": code, "message": message })),
51 )
52 .into_response()
53}
54
55/// Map a jail refusal onto HTTP.
56///
57/// `Escapes` is 403 rather than 404 deliberately, and identically whether or
58/// not the target exists: a split between the two would tell a caller what
59/// lives outside the root.
60pub fn fs_error_response(error: FsError) -> Response {
61 match error {
62 FsError::Malformed(reason) => error_response(StatusCode::BAD_REQUEST, "bad-path", reason),
63 FsError::Escapes => error_response(
64 StatusCode::FORBIDDEN,
65 "path-escapes-root",
66 "path resolves outside the configured root",
67 ),
68 FsError::NotFound => error_response(
69 StatusCode::NOT_FOUND,
70 "not-found",
71 "no such file or directory",
72 ),
73 }
74}
75
76/// The refusal sent when no `--fs-root` was configured.
77///
78/// A function rather than a `Result`-returning guard: handlers return `Response`
79/// directly, so `?` never applies and a `Result` buys nothing — it only makes
80/// the error variant large enough to trip `clippy::result_large_err`, which
81/// invites boxing a problem that need not exist. Callers pair this with
82/// `let Some(root) = state.fs.clone() else { return fs_not_enabled(); }`.
83pub fn fs_not_enabled() -> Response {
84 error_response(
85 StatusCode::FORBIDDEN,
86 "fs-not-enabled",
87 "the filesystem API is disabled; start with --fs-root <path> to enable it",
88 )
89}
90
91/// Milliseconds since the Unix epoch, or zero when the clock says otherwise.
92pub fn mtime_ms(meta: &std::fs::Metadata) -> u64 {
93 meta.modified()
94 .ok()
95 .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
96 .map(|d| d.as_millis() as u64)
97 .unwrap_or(0)
98}
99
100/// Build an entry for one already-resolved path.
101pub fn entry_for(
102 root: &FsRoot,
103 absolute: &std::path::Path,
104 meta: &std::fs::Metadata,
105 sha256: Option<String>,
106) -> FsEntry {
107 FsEntry {
108 path: root.relative(absolute).unwrap_or_default(),
109 size: if meta.is_dir() { 0 } else { meta.len() },
110 mtime_ms: mtime_ms(meta),
111 is_dir: meta.is_dir(),
112 sha256,
113 }
114}
115
116/// Default page size for `list`.
117///
118/// The relay buffers whole bodies with an 8 MiB ceiling (`relay::MAX_BODY`), so
119/// an unpaginated listing of a real deployment tree would 413 — on exactly the
120/// tree sizes this endpoint exists to serve.
121pub const DEFAULT_LIST_LIMIT: usize = 1_000;
122
123/// Largest page a caller may ask for. Requests above it are clamped, not refused.
124pub const MAX_LIST_LIMIT: usize = 10_000;
125
126/// Resolve the requested page size against the configured bounds.
127///
128/// Clamped, not refused: a caller asking for zero or for more than the
129/// ceiling gets a valid page size instead of an error or an unbounded walk.
130/// Pure arithmetic on `requested`, independent of how large the tree being
131/// listed is — proving it holds does not require building a tree above
132/// `MAX_LIST_LIMIT`, only calling this with the numbers that matter.
133fn resolve_limit(requested: Option<usize>) -> usize {
134 requested
135 .unwrap_or(DEFAULT_LIST_LIMIT)
136 .clamp(1, MAX_LIST_LIMIT)
137}
138
139/// Where in-flight uploads are staged. Never reported by `list`.
140pub use crate::fs::UPLOAD_DIR;
141
142/// Whether a root-relative path names the upload staging directory itself, or
143/// anything inside it.
144///
145/// One helper rather than one inline copy per handler that takes a `path`.
146/// Before this existed, `list`'s walk hid the directory from listings and
147/// `create_upload` refused it as a destination, but `stat`, `download`, and
148/// `delete_file` checked nothing — each of those was added in a different
149/// task, correct in its own scope, and none of them noticed the other two
150/// routes exposing the same directory the first two were built to protect.
151/// A sixth route taking a `path` calls this too, instead of becoming the
152/// place someone forgets it a third time.
153fn is_reserved_path(rel: &str) -> bool {
154 rel == UPLOAD_DIR || rel.starts_with(&format!("{UPLOAD_DIR}/"))
155}
156
157/// The refusal for a path that names the upload staging directory, or
158/// something inside it. Same code and wording `create_upload` already used
159/// for refusing it as a destination — kept identical rather than letting each
160/// caller invent its own phrasing for the same condition.
161fn reserved_path_response() -> Response {
162 error_response(
163 StatusCode::FORBIDDEN,
164 "reserved-path",
165 "path resolves into the upload staging directory, which is reserved",
166 )
167}
168
169/// Refuse `resolved` if it names the reserved upload staging directory, or
170/// something inside it. `None` means proceed.
171///
172/// Every call site passes a path already established to be under `root` —
173/// via `resolve_existing` (`stat`, `download`), or via the postcondition just
174/// above this call in `delete_file_blocking` — so `root.relative` returning
175/// `None` here should not be reachable. Handled as a 500 rather than treated
176/// as "not reserved, proceed" regardless: `create_upload_blocking`'s own
177/// `dest_rel` binding faces the identical call and already answers `None`
178/// with a 500 rather than silently continuing, and this follows that
179/// precedent rather than the opposite one. A broken invariant must not
180/// degrade into serving or removing the very file this check exists to
181/// refuse — that would be fail-*open*, the wrong direction for a guard whose
182/// only job is refusing.
183///
184/// **Existence-gated, deliberately — and that leaves a residual oracle.**
185/// This guard only runs once a caller's path has already resolved to
186/// something that exists (`resolve_existing`'s own 404 answers a
187/// non-existent path first, before this ever sees it). So a caller probing
188/// `.shell-tunnel-uploads/up-{serial:016x}.part` for a serial that never
189/// existed gets 404, while the same probe against a serial with a session
190/// currently in flight gets 403 `reserved-path`. Session ids are a
191/// predictable per-process counter, so that pair of outcomes lets a holder
192/// of `fs.read`/`fs.write` enumerate *which* session ids are live right now.
193/// Accepted, not overlooked: what leaks is presence alone — never the
194/// staged content (closed by this guard) and never the upload's destination
195/// path (which lives only in the in-memory session and was never
196/// derivable from the staging filename either way) — to a caller who
197/// already holds root-wide read or delete via that same capability. The
198/// asymmetry this guard exists to close (content exposure, cross-session
199/// deletion) is a materially different severity than a presence bit.
200///
201/// The alternative — checking before resolution, directly on the caller's
202/// raw string — was considered and rejected. Matching the unresolved string
203/// is bypassable by spelling (`./`, backslashes, a `.` component), the same
204/// aliasing class `two_sessions_for_aliased_spellings_of_one_destination_are_refused`
205/// exists to cover for the upload destination claim key; making it reliable
206/// would mean canonicalising independently of `resolve_existing`, i.e. a
207/// second canonicalisation on every `stat`/`download` call — a real cost on
208/// what is otherwise the hot read path — to close a leak that only ever
209/// reveals a boolean, against a caller who is not thereby granted anything
210/// they could not already reach.
211fn refuse_if_reserved(root: &FsRoot, resolved: &std::path::Path) -> Option<Response> {
212 match root.relative(resolved) {
213 Some(rel) if is_reserved_path(&rel) => Some(reserved_path_response()),
214 Some(_) => None,
215 None => Some(error_response(
216 StatusCode::INTERNAL_SERVER_ERROR,
217 "path-resolution-failed",
218 "could not compute the entry's canonical path",
219 )),
220 }
221}
222
223/// Query parameters for `list`.
224#[derive(Debug, Deserialize)]
225pub struct ListQuery {
226 pub path: String,
227 #[serde(default)]
228 pub recursive: bool,
229 /// Only `sha256` is understood; anything else is ignored.
230 #[serde(default)]
231 pub hash: Option<String>,
232 /// Resume point: the opaque token from the previous page's `next_cursor`.
233 ///
234 /// Echo it back verbatim. It is not a path, and a hand-built value is
235 /// refused with `400 bad-cursor`.
236 #[serde(default)]
237 pub cursor: Option<String>,
238 #[serde(default)]
239 pub limit: Option<usize>,
240}
241
242/// One page of entries.
243#[derive(Debug, Clone, Serialize, Deserialize)]
244pub struct ListResponse {
245 pub entries: Vec<FsEntry>,
246 /// Pass back as `cursor` to continue. `None` means this was the last page.
247 #[serde(skip_serializing_if = "Option::is_none")]
248 pub next_cursor: Option<String>,
249}
250
251/// `GET /api/v1/fs/list` — one page of a directory's contents.
252///
253/// Paging is by opaque cursor, which encodes the last path returned rather than
254/// an offset: entries are ordered by path, so a file added or removed mid-walk
255/// shifts every offset but cannot invalidate a path.
256///
257/// The encoding is what makes the token opaque, and it is not decoration. A raw
258/// path on the wire passes through form-urlencoded decoding, where `+` becomes a
259/// space — so a file named `data+1.csv` at a page boundary produced a cursor that
260/// decoded to a name sorting *before* the real entry, and a client looping until
261/// `next_cursor` was `None` re-fetched the same page forever.
262pub async fn list(State(state): State<AppState>, Query(query): Query<ListQuery>) -> Response {
263 let Some(root) = state.fs.clone() else {
264 return fs_not_enabled();
265 };
266
267 // Resolving `path`, walking the tree, and hashing whole files are all
268 // blocking I/O. Same convention as `execution::executor::execute`
269 // (`src/execution/executor.rs:209-215`): run it on `spawn_blocking` so a
270 // large tree, a slow filesystem, or — absent the `is_file()` guard below
271 // — a FIFO can never starve the tokio worker pool that also runs
272 // `/health` and the accept loop.
273 match tokio::task::spawn_blocking(move || list_blocking(&root, &query)).await {
274 Ok(response) => response,
275 Err(_) => error_response(
276 StatusCode::INTERNAL_SERVER_ERROR,
277 "list-failed",
278 "listing the directory failed unexpectedly",
279 ),
280 }
281}
282
283/// The synchronous body of `list`. Blocking throughout — see `list`, which
284/// runs this via `spawn_blocking` rather than directly on the async runtime.
285fn list_blocking(root: &FsRoot, query: &ListQuery) -> Response {
286 let base = match root.resolve_existing(&query.path) {
287 Ok(path) => path,
288 Err(error) => return fs_error_response(error),
289 };
290
291 match std::fs::metadata(&base) {
292 Ok(meta) if meta.is_dir() => {}
293 Ok(_) => {
294 return error_response(
295 StatusCode::BAD_REQUEST,
296 "not-a-directory",
297 "path is a file; use /api/v1/fs/stat for a single entry",
298 )
299 }
300 Err(_) => return fs_error_response(FsError::NotFound),
301 }
302
303 let limit = resolve_limit(query.limit);
304 let want_hash = query.hash.as_deref() == Some("sha256");
305
306 let mut collected: Vec<(String, std::path::PathBuf, std::fs::Metadata)> = Vec::new();
307 if let Err(WalkError::Unreadable) = walk(root, &base, query.recursive, &mut collected) {
308 return error_response(StatusCode::FORBIDDEN, "unreadable", "directory unreadable");
309 }
310 collected.sort_by(|a, b| a.0.cmp(&b.0));
311
312 // Strictly greater than the cursor, so the page boundary cannot repeat an
313 // entry or skip one.
314 let start = match query.cursor.as_deref() {
315 Some(token) => match decode_cursor(token) {
316 Some(cursor) => {
317 collected.partition_point(|(path, _, _)| path.as_str() <= cursor.as_str())
318 }
319 None => {
320 return error_response(
321 StatusCode::BAD_REQUEST,
322 "bad-cursor",
323 "cursor is not a value this endpoint produced",
324 )
325 }
326 },
327 None => 0,
328 };
329
330 let end = (start + limit).min(collected.len());
331 // `saturating_sub` rather than `end - 1`: the index is safe only because the
332 // clamp keeps `limit` at 1 or more, which is a guarantee living in a
333 // different expression. A future edit that relaxes the clamp would turn this
334 // into a panic, and a panicking handler is a 500.
335 let next_cursor =
336 (end < collected.len()).then(|| encode_cursor(&collected[end.saturating_sub(1)].0));
337
338 let mut entries = Vec::with_capacity(end.saturating_sub(start));
339 for (relative, absolute, meta) in &collected[start..end] {
340 // Hashing is per page, never per tree: a recursive hashed walk of a
341 // large root would otherwise outrun the relay's 120s request timeout.
342 let sha256 = match want_hash && !meta.is_dir() {
343 // `walk` produced this path from a directory scan, so it has never
344 // been through the jail — `relative()` is a lexical strip_prefix and
345 // `DirEntry::metadata` is lstat, so a symlink looks in-root while
346 // `File::open` would follow it out. Re-resolve, and hash only what
347 // the jail hands back.
348 true => match root.resolve_existing(relative) {
349 Ok(canonical) => match std::fs::metadata(&canonical) {
350 // A FIFO or character device never reaches EOF, so hashing
351 // one blocks forever. Only regular files are hashable.
352 Ok(target) if target.is_file() => crate::fs::sha256::hash_file(&canonical).ok(),
353 _ => None,
354 },
355 Err(_) => None,
356 },
357 false => None,
358 };
359 entries.push(entry_for(root, absolute, meta, sha256));
360 }
361
362 Json(ListResponse {
363 entries,
364 next_cursor,
365 })
366 .into_response()
367}
368
369/// Encode a relative path as an opaque cursor.
370///
371/// Not for confidentiality — hex has no special characters, and that is the
372/// point: axum's `Query` decodes form-urlencoded input, where `+` means a
373/// space, and `+` is a legal, ordinary filename character (`libstdc++`). A
374/// cursor built from the raw path would decode back to a different string
375/// than the one that produced it, sort earlier than the real entry, and
376/// repeat the same page forever. Hex removes the ambiguity instead of
377/// chasing every character `Query` might reinterpret.
378fn encode_cursor(path: &str) -> String {
379 let mut out = String::with_capacity(path.len() * 2);
380 for byte in path.as_bytes() {
381 out.push_str(&format!("{byte:02x}"));
382 }
383 out
384}
385
386/// Decode a cursor produced by `encode_cursor`. `None` for anything else —
387/// including a raw path a caller might paste in by hand — so a malformed
388/// cursor is refused with 400 `bad-cursor` rather than silently resetting to
389/// page one.
390fn decode_cursor(token: &str) -> Option<String> {
391 if token.is_empty() || token.len() % 2 != 0 {
392 return None;
393 }
394 let mut bytes = Vec::with_capacity(token.len() / 2);
395 for pair in token.as_bytes().chunks(2) {
396 let hi = (pair[0] as char).to_digit(16)?;
397 let lo = (pair[1] as char).to_digit(16)?;
398 bytes.push((hi * 16 + lo) as u8);
399 }
400 String::from_utf8(bytes).ok()
401}
402
403/// The one fatal outcome `walk` can report.
404///
405/// A dedicated enum rather than `Result<(), Response>`: the latter trips
406/// `clippy::result_large_err` (a `Response` is well over the 128-byte
407/// threshold) for exactly the reason `fs_not_enabled`'s doc comment already
408/// explains — the caller builds the actual `Response` once it knows which
409/// refusal applies.
410enum WalkError {
411 Unreadable,
412}
413
414/// Collect entries under `base`, skipping the upload staging directory.
415///
416/// Only `base` itself being unreadable is fatal, and only to the caller of
417/// this exact invocation — `list`'s top-level call turns that into a 403.
418/// Below that, nothing is fatal: an entry whose metadata cannot be read is
419/// skipped, and so is a nested subdirectory that fails to open. A
420/// permission-restricted subdirectory is ordinary in a real deployment tree;
421/// one bad subtree, however deep, must not discard everything already
422/// collected from the rest of the walk.
423///
424/// **`base` must already have been resolved through `root`** — as
425/// `list_blocking` does with `resolve_existing` before calling this. Every
426/// entry is named by `root.relative`, which is a pure `strip_prefix` and does
427/// no resolution of its own, so a `base` that merely *points* inside the root
428/// without being its canonical form yields `None` for every entry and this
429/// returns `Ok(())` with **nothing collected** — an empty listing rather than
430/// an error. That failure is invisible on a platform where the paths in play
431/// are already canonical and loud on one where they are not: passing an
432/// unresolved temp-dir path here read as a product bug on macOS, where
433/// `/var/folders/…` canonicalises to `/private/var/folders/…`, while the same
434/// code was silently fine on Linux. Resolve first; do not hand this a path
435/// assembled by `join`.
436fn walk(
437 root: &FsRoot,
438 base: &std::path::Path,
439 recursive: bool,
440 out: &mut Vec<(String, std::path::PathBuf, std::fs::Metadata)>,
441) -> Result<(), WalkError> {
442 let read = std::fs::read_dir(base).map_err(|_| WalkError::Unreadable)?;
443
444 for entry in read.flatten() {
445 let absolute = entry.path();
446 let Some(relative) = root.relative(&absolute) else {
447 continue;
448 };
449 if is_reserved_path(&relative) {
450 continue;
451 }
452 let Ok(meta) = entry.metadata() else {
453 continue;
454 };
455 let is_dir = meta.is_dir();
456 out.push((relative, absolute.clone(), meta));
457 if recursive && is_dir {
458 // Discarded, not propagated with `?`: only the top-level `base`
459 // being unreadable is fatal (see the doc comment above).
460 let _ = walk(root, &absolute, true, out);
461 }
462 }
463 Ok(())
464}
465
466/// A validator that changes whenever the bytes at a path might have changed.
467///
468/// Size and mtime on every platform, plus the inode on Unix. Windows has no
469/// equivalent reachable from `std::fs::metadata`, so the validator is weaker
470/// there — stated rather than papered over, because a validator that claims
471/// more than the platform delivers is worse than one that is honest.
472pub fn etag_for(meta: &std::fs::Metadata) -> String {
473 format!(
474 "\"{:x}-{:x}-{:x}\"",
475 meta.len(),
476 mtime_ms(meta),
477 platform::file_identity(meta)
478 )
479}
480
481/// The outcome of interpreting a `Range` header against a file of `size` bytes.
482///
483/// RFC 9110 §14.2 requires two different failure shapes to produce two
484/// different responses: an unrecognised range unit, or a syntactically
485/// invalid `bytes` spec, must be *ignored* — served as the whole file, 200 —
486/// while only a well-formed `bytes` range that names bytes the file does not
487/// have is "not satisfiable" (416). Collapsing both into one `Option::None`,
488/// as an earlier version of this function did, served 416 for `Range:
489/// items=0-4`, which the RFC forbids.
490#[derive(Debug, Clone, Copy, PartialEq, Eq)]
491pub enum RangeOutcome {
492 /// No usable range: fall through to serving the whole file, exactly as
493 /// if `Range` had not been sent at all.
494 Ignore,
495 /// A well-formed `bytes` range outside the file's current length.
496 Unsatisfiable,
497 /// A well-formed, in-bounds range: inclusive `(start, end)`.
498 Satisfiable(u64, u64),
499}
500
501/// Parse a single-range `Range` header against a file of `size` bytes.
502///
503/// Only one range is supported: a multi-range spec is syntactically valid
504/// but this implementation has no multipart body to serve it with, so it is
505/// treated the same as an unrecognised unit — ignored, not refused with 416
506/// for something the client asked for correctly.
507pub fn parse_range(header: &str, size: u64) -> RangeOutcome {
508 use RangeOutcome::{Ignore, Satisfiable, Unsatisfiable};
509
510 let Some(spec) = header.trim().strip_prefix("bytes=") else {
511 return Ignore; // unrecognised unit
512 };
513 if spec.contains(',') {
514 return Ignore; // multipart; unsupported, but not the client's fault
515 }
516 let Some((from, to)) = spec.split_once('-') else {
517 return Ignore;
518 };
519
520 let (start, end) = match (from.trim(), to.trim()) {
521 ("", "") => return Ignore,
522 // `bytes=-N`: the last N bytes. `saturating_sub` throughout rather
523 // than an early `size == 0` guard: an empty file and a suffix of `0`
524 // both collapse to `start >= size` below — the same "nothing to
525 // serve" outcome either way, reached without a special case.
526 ("", suffix) => {
527 let Ok(n) = suffix.parse::<u64>() else {
528 return Ignore;
529 };
530 (size.saturating_sub(n), size.saturating_sub(1))
531 }
532 (first, "") => {
533 let Ok(start) = first.parse::<u64>() else {
534 return Ignore;
535 };
536 (start, size.saturating_sub(1))
537 }
538 (first, last) => {
539 let (Ok(start), Ok(end)) = (first.parse::<u64>(), last.parse::<u64>()) else {
540 return Ignore;
541 };
542 // `last-byte-pos < first-byte-pos` is an invalid byte-range-spec
543 // (RFC 9110 §14.1.2) — a syntax problem, not an out-of-bounds one.
544 if end < start {
545 return Ignore;
546 }
547 (start, end)
548 }
549 };
550
551 if start >= size {
552 return Unsatisfiable;
553 }
554 Satisfiable(start, end.min(size - 1))
555}
556
557/// `GET /api/v1/fs/file` — the whole file, or a range of it.
558///
559/// `HEAD` reaches this handler too: `axum`'s `get()` serves it automatically,
560/// running the same handler and discarding the body. Without the `method`
561/// check below, that meant loading the entire file into a `Vec` purely to
562/// throw it away — waste on every call, and an amplification on a large file.
563/// `include_body` skips every read while still computing the same status and
564/// headers a `GET` would.
565pub async fn download(
566 State(state): State<AppState>,
567 method: axum::http::Method,
568 headers: axum::http::HeaderMap,
569 Query(query): Query<PathQuery>,
570) -> Response {
571 let Some(root) = state.fs.clone() else {
572 return fs_not_enabled();
573 };
574
575 // Everything this handler needs from the headers, extracted before
576 // handing off to `spawn_blocking` — the blocking body below has no access
577 // to the request beyond what it is passed.
578 let header_str = |name: axum::http::HeaderName| {
579 headers
580 .get(name)
581 .and_then(|v| v.to_str().ok())
582 .map(|s| s.to_string())
583 };
584 let range_header = header_str(axum::http::header::RANGE);
585 let if_range_header = header_str(axum::http::header::IF_RANGE);
586 let include_body = method != axum::http::Method::HEAD;
587
588 // Resolving the path, `stat`-ing it, and reading the bytes (whole file or
589 // a span) are all blocking I/O. Same convention as `execution::executor::execute`
590 // (`src/execution/executor.rs:209-215`) and `list`/`list_blocking` above:
591 // run it on `spawn_blocking` so a large file, a slow filesystem, or —
592 // absent the `is_file()` guard below — a FIFO or character device can
593 // never starve the tokio worker pool that also runs `/health` and the
594 // accept loop.
595 match tokio::task::spawn_blocking(move || {
596 download_blocking(
597 &root,
598 &query,
599 range_header.as_deref(),
600 if_range_header.as_deref(),
601 include_body,
602 )
603 })
604 .await
605 {
606 Ok(response) => response,
607 Err(_) => error_response(
608 StatusCode::INTERNAL_SERVER_ERROR,
609 "download-failed",
610 "reading the file failed unexpectedly",
611 ),
612 }
613}
614
615/// The synchronous body of `download`. Blocking throughout — see `download`,
616/// which runs this via `spawn_blocking` rather than directly on the async
617/// runtime.
618///
619/// `include_body` is `false` for `HEAD`: every status code and header below
620/// is computed exactly as for `GET`, but no file content is read, and
621/// `content-length` is set explicitly from metadata rather than left to be
622/// inferred from an (empty) body.
623fn download_blocking(
624 root: &FsRoot,
625 query: &PathQuery,
626 range_header: Option<&str>,
627 if_range_header: Option<&str>,
628 include_body: bool,
629) -> Response {
630 let resolved = match root.resolve_existing(&query.path) {
631 Ok(path) => path,
632 Err(error) => return fs_error_response(error),
633 };
634
635 // Same reservation `create_upload` enforces on the way in and `list`
636 // enforces on the way out: without it, a predictable session id
637 // (`up-{serial:016x}.part`, a per-process counter from zero) let an
638 // `fs.read` token read another caller's in-progress partial upload —
639 // exactly the exposure `crate::fs::transfer`'s module doc says the
640 // staging design prevents.
641 if let Some(response) = refuse_if_reserved(root, &resolved) {
642 return response;
643 }
644
645 // Metadata of the path the jail handed back, not the caller's string —
646 // and `is_file()` gates every read below. A FIFO blocks `File::open`
647 // indefinitely and a character device never reaches EOF, so without this
648 // check a path like `/dev/zero` inside the root would hang the request
649 // forever instead of failing fast. The message says "not a regular
650 // file" rather than "a directory": a FIFO or character device hits this
651 // same arm, and a message that named only directories would be wrong for
652 // them.
653 let meta = match std::fs::metadata(&resolved) {
654 Ok(meta) if meta.is_file() => meta,
655 Ok(_) => {
656 return error_response(
657 StatusCode::BAD_REQUEST,
658 "not-a-file",
659 "path is not a regular file; if it is a directory, use /api/v1/fs/list",
660 )
661 }
662 Err(_) => return fs_error_response(FsError::NotFound),
663 };
664
665 let size = meta.len();
666 let etag = etag_for(&meta);
667
668 // A stale `If-Range` means the caller's prefix belongs to a different file.
669 // Serving the range anyway would let them stitch two files together and
670 // notice only when the checksum failed, if they checked at all.
671 let range_allowed = match if_range_header {
672 Some(sent) => sent == etag,
673 None => true,
674 };
675
676 let requested = range_header
677 .filter(|_| range_allowed)
678 .map(|raw| parse_range(raw, size));
679
680 match requested {
681 Some(RangeOutcome::Satisfiable(start, end)) => {
682 let length = end - start + 1;
683 let bytes = if include_body {
684 match read_span(&resolved, start, length) {
685 Ok(bytes) => bytes,
686 Err(_) => return fs_error_response(FsError::NotFound),
687 }
688 } else {
689 Vec::new()
690 };
691 (
692 StatusCode::PARTIAL_CONTENT,
693 [
694 ("content-type", "application/octet-stream".to_string()),
695 ("accept-ranges", "bytes".to_string()),
696 ("etag", etag),
697 ("content-range", format!("bytes {start}-{end}/{size}")),
698 ("content-length", length.to_string()),
699 ],
700 bytes,
701 )
702 .into_response()
703 }
704 Some(RangeOutcome::Unsatisfiable) => (
705 StatusCode::RANGE_NOT_SATISFIABLE,
706 [("content-range", format!("bytes */{size}"))],
707 )
708 .into_response(),
709 // `Ignore` (unrecognised unit, or a syntactically invalid `bytes`
710 // spec — RFC 9110 §14.2) falls through to the whole file exactly as
711 // `None` (no `Range` header at all) does.
712 Some(RangeOutcome::Ignore) | None => {
713 let bytes = if include_body {
714 match std::fs::read(&resolved) {
715 Ok(bytes) => bytes,
716 Err(_) => return fs_error_response(FsError::NotFound),
717 }
718 } else {
719 Vec::new()
720 };
721 // `bytes.len()`, not the `size` read from `metadata` earlier: a
722 // concurrent writer can truncate or extend the file between that
723 // `metadata` call and this `std::fs::read` (Tasks 5-6 add upload
724 // routes into this same root, so this is not hypothetical). Using
725 // the length of what was actually just read means the header can
726 // never disagree with the body hyper is about to frame it around.
727 // `HEAD` never reads, so it has no body length to take instead —
728 // `size` from metadata is exactly what RFC 9110 §9.3.2 asks a
729 // `HEAD` response to report.
730 let content_length = if include_body {
731 bytes.len() as u64
732 } else {
733 size
734 };
735 (
736 StatusCode::OK,
737 [
738 ("content-type", "application/octet-stream".to_string()),
739 ("accept-ranges", "bytes".to_string()),
740 ("etag", etag),
741 ("content-length", content_length.to_string()),
742 ],
743 bytes,
744 )
745 .into_response()
746 }
747 }
748}
749
750/// Read `length` bytes starting at `start`.
751fn read_span(path: &std::path::Path, start: u64, length: u64) -> std::io::Result<Vec<u8>> {
752 use std::io::{Read, Seek, SeekFrom};
753
754 let mut file = std::fs::File::open(path)?;
755 file.seek(SeekFrom::Start(start))?;
756 let mut buffer = vec![0_u8; length as usize];
757 file.read_exact(&mut buffer)?;
758 Ok(buffer)
759}
760
761/// `DELETE /api/v1/fs/file` — remove one named entry.
762///
763/// Only a *real* directory is refused. Recursive removal is a destructive
764/// operation that wants the guards (dry-run, backup, approval) this layer
765/// does not have; a convenience flag here would hand out that power without
766/// them.
767///
768/// Everything else the entry could be — a regular file, a symlink (to a file
769/// or to a directory), a FIFO, a socket, a device node — is removed. Unlike
770/// `download`, this handler never reads the entry's contents, so
771/// `download`'s reason for gating on `is_file()` (a FIFO never reaches EOF)
772/// does not apply here; and refusing a non-regular entry would leave it
773/// permanently undeletable through this API, the same trap that decided the
774/// symlink question below in favour of acting on the named entry.
775///
776/// Accepted limitation: the paragraph above holds only for a link that
777/// itself resolves inside the root. A symlink pointing outside the root, or
778/// a dangling one, stays undeletable through this route — both are refused
779/// with `Escapes` before the named-entry logic below ever runs, because the
780/// jail's verdict on the full path is final, and reaching either kind of
781/// link would mean overriding it. That is the property every other route in
782/// this feature rests on, so it is not relaxed here just to reach a broken
783/// link; an operator has to remove those directly.
784pub async fn delete_file(
785 State(state): State<AppState>,
786 identity: Option<axum::Extension<crate::audit::Identity>>,
787 Query(query): Query<PathQuery>,
788) -> Response {
789 let Some(root) = state.fs.clone() else {
790 return fs_not_enabled();
791 };
792 let audit = state.audit.clone();
793 let identity = identity.map(|axum::Extension(id)| id);
794
795 // Resolving the path and removing the file are both blocking I/O. Same
796 // convention as `list`/`list_blocking` and `download`/`download_blocking`
797 // above (`src/execution/executor.rs:209-215`): run it on `spawn_blocking`
798 // so a slow filesystem can never starve the tokio worker pool that also
799 // runs `/health` and the accept loop. `audit.record` is blocking too
800 // (`AuditSink::record` opens/writes/flushes a file), so it is recorded
801 // from `delete_file_blocking` rather than back here — same reason the
802 // upload handlers thread it into their own `_blocking` bodies.
803 match tokio::task::spawn_blocking(move || delete_file_blocking(&root, &audit, identity, &query))
804 .await
805 {
806 Ok(response) => response,
807 Err(_) => error_response(
808 StatusCode::INTERNAL_SERVER_ERROR,
809 "delete-panicked",
810 "removing the file failed unexpectedly",
811 ),
812 }
813}
814
815/// Split a root-relative request path into (parent, last component).
816///
817/// `"."` names the root itself when there is no separator — `resolve_existing`
818/// already gives that string a defined meaning (`src/fs/root.rs:113`), so this
819/// reuses it rather than inventing a second convention for "no parent".
820fn split_last_component(rel: &str) -> (&str, &str) {
821 match rel.rfind(['/', '\\']) {
822 Some(idx) => (&rel[..idx], &rel[idx + 1..]),
823 None => (".", rel),
824 }
825}
826
827/// The synchronous body of `delete_file`. Blocking throughout — see
828/// `delete_file`, which runs this via `spawn_blocking` rather than directly on
829/// the async runtime.
830///
831/// Deliberately does not act on what `resolve_existing(&query.path)` returns:
832/// that path follows symlinks all the way to their target
833/// (`src/fs/root.rs:122-132`), so for a same-root symlink it would remove
834/// whatever the link points to and leave the link itself behind — dangling,
835/// and undeletable afterward, since `resolve_existing` refuses every dangling
836/// symlink (`src/fs/root.rs:145-147`). A caller who named a link would see a
837/// different file disappear and the one they named survive.
838///
839/// Instead: resolve the request path in full once, purely to get
840/// `FsRoot`'s Malformed/Escapes/NotFound verdict exactly as every other
841/// route does. Then split the *request string* into its parent and final
842/// component, resolve only the parent through the jail, and rejoin the
843/// literal final component onto that canonical parent. The result names
844/// whatever the caller actually asked for — a symlink stays a symlink — while
845/// every directory on the way there has still been walked through
846/// `FsRoot::resolve_existing` and had `check_component` applied to it.
847///
848/// This does not weaken containment: splitting a string that has already
849/// passed the first resolution cannot manufacture a component that didn't
850/// already pass `check_component` during that walk. It only decides which of
851/// two jail-approved paths to act on — the request's own final component, not
852/// whatever that component's target happens to be.
853///
854/// The very first line below is what enforces `delete_file`'s documented
855/// "accepted limitation": a symlink pointing outside the root, or a
856/// dangling one, is refused with `Escapes` right there, before any of the
857/// named-entry logic that follows ever runs.
858fn delete_file_blocking(
859 root: &FsRoot,
860 audit: &crate::audit::AuditSink,
861 identity: Option<crate::audit::Identity>,
862 query: &PathQuery,
863) -> Response {
864 if let Err(error) = root.resolve_existing(&query.path) {
865 return fs_error_response(error);
866 }
867
868 let (parent_rel, name) = split_last_component(&query.path);
869
870 // `name` can be `..` (`path=app/..` passes the full-path resolution
871 // above — `resolve_existing` walks it back up to a real directory, not
872 // out of the root, so nothing refuses it there). `named` below is built
873 // with a lexical `PathBuf::join`, which never resolves `..`, so joining
874 // `..` onto an in-root `parent` produces a path whose *actual* location —
875 // once something reads it — is one level above `parent`, without ever
876 // having gone through the jail. Today the directory refusal further down
877 // happens to catch this anyway, since `X/..` always names a directory;
878 // that is an accident of recursive removal not being supported yet, not
879 // a reason, so it is checked here explicitly instead: `..` is refused as
880 // a delete target outright, before it is ever joined.
881 //
882 // A containment check on the joined path instead of this would not work:
883 // `Path::starts_with` is a pure component-prefix comparison that does not
884 // resolve `..` either (verified: `Path::new("/root/app").join("..")
885 // .starts_with("/root")` is `true`), so `named` always "starts with"
886 // `root` regardless of a trailing `..`. And canonicalising `named` to get
887 // a real answer would defeat the reason this function builds it lexically
888 // in the first place — it would resolve the very symlink this handler
889 // exists to leave untouched.
890 //
891 // A *`parent`-based* postcondition check (`named.starts_with(&parent)`,
892 // below, after `named` is built) is a different check from the
893 // `root`-based one just ruled out — it does not rule out `..` either
894 // (`join("..")` on Unix does not collapse, so `named` is literally
895 // `parent/..` and does start with `parent`), so this guard stays
896 // load-bearing for `..` regardless.
897 if name == ".." {
898 return fs_error_response(FsError::Escapes);
899 }
900
901 // `.` is the other value `FsRoot::components` never runs `check_component`
902 // over, and it fails the same way `..` did above, through a different
903 // mechanism: `PathBuf::join` absorbs a trailing `.` on a verbatim path,
904 // and `resolve_existing`'s result *is* verbatim on Windows (`canonicalize`
905 // returns `\\?\C:\...`). So for `path=link/.` where `link` is a
906 // same-root symlink, `parent = resolve_existing("link")` is the link's
907 // *followed target*, and `parent.join(".")` collapses right back onto
908 // that target — reproduced on this box: `canonicalize(link).join(".") ==
909 // canonicalize(link)`, both the link's target, with no `.` component
910 // surviving to distinguish them. That reintroduces exactly the defect
911 // this handler exists to avoid, through a spelling `..`'s guard does not
912 // cover. `Malformed`, not `Escapes`: naming an entry as `.` is not an
913 // escape, just not a name.
914 if name == "." {
915 return fs_error_response(FsError::Malformed(
916 "delete target must name an entry, not `.`",
917 ));
918 }
919
920 let parent = match root.resolve_existing(parent_rel) {
921 Ok(path) => path,
922 Err(error) => return fs_error_response(error),
923 };
924 let named = parent.join(name);
925
926 // `named` above assumes `join` appends exactly one ordinary component. It
927 // does not: `PathBuf::join` discards `parent` entirely for an argument
928 // carrying a Windows drive prefix (verified: `Path::new(r"C:\root\app")
929 // .join("C:evil")` is `"C:evil"`, `parent` gone). A `name` containing `:`
930 // would drop the jail-resolved parent and hand `remove_file` an arbitrary
931 // drive-relative path.
932 //
933 // Checked as a postcondition of the join rather than as a precondition on
934 // `name`, deliberately: `platform::check_component` already rejects `:`
935 // (for Alternate Data Streams, an unrelated reason), and an earlier
936 // version of this guard called it here directly — but that only restates
937 // the dependency, it does not remove it. Relax `:` in `check_component`
938 // for its own purpose (a plausible future change, since Unix has no
939 // Alternate Data Streams to protect) and a precondition re-running the
940 // same rule is defeated identically; this postcondition is not, because
941 // it does not consult that rule at all — it only asks whether the result
942 // of the join still extends `parent`, which is true or false independent
943 // of why `check_component` currently rejects `:`.
944 //
945 // Not currently reachable from any request that also passes the
946 // full-path resolution at the top of this function: that resolution
947 // already runs `check_component` over every component including this
948 // one, so `path=app/C:evil` is refused there today regardless of this
949 // line. Kept anyway as the thing that keeps working if that upstream
950 // check's scope narrows for a reason that has nothing to do with this
951 // handler.
952 if !named.starts_with(&parent) {
953 return fs_error_response(FsError::Escapes);
954 }
955
956 // Same reservation `create_upload` enforces on the way in and `list`
957 // enforces on the way out: without it, a predictable session id let an
958 // `fs.write` token delete another caller's in-progress `.part` file —
959 // the open staging handle survives the removal on Windows, so the
960 // upload later fails `complete` with an opaque 500 instead of the
961 // caller ever seeing a clean refusal.
962 if let Some(response) = refuse_if_reserved(root, &named) {
963 return response;
964 }
965
966 // `symlink_metadata` (lstat), not `metadata` (stat): the latter follows
967 // the link and would report a symlink as whatever it points to, making a
968 // symlink-to-directory indistinguishable from a real directory below.
969 let meta = match std::fs::symlink_metadata(&named) {
970 Ok(meta) => meta,
971 Err(_) => return fs_error_response(FsError::NotFound),
972 };
973
974 // Refused only when `named` is a *real* directory. A symlink is never
975 // refused here regardless of what it points to — removing a link is
976 // removing one directory entry, not a recursive walk, so it carries none
977 // of the risk the directory refusal above exists to guard against.
978 if meta.is_dir() {
979 return error_response(
980 StatusCode::BAD_REQUEST,
981 "not-a-file",
982 "path is a directory; recursive removal is not supported",
983 );
984 }
985
986 match platform::remove_entry(&named, &meta) {
987 Ok(()) => {
988 // `query.path` as the caller spelled it, not a canonical form —
989 // unlike `upload.start`/`upload.complete`, which need to agree on
990 // one spelling to correlate two events for the *same* session,
991 // this is the only event this deletion will ever produce, so
992 // there is nothing to correlate it with. Recording the raw
993 // request path here also matches what this handler actually acts
994 // on: `delete_file_blocking`'s own doc comment above explains why
995 // it deliberately targets the named entry (`query.path`'s own
996 // final component) rather than whatever a symlink resolves to.
997 audit.record(
998 crate::audit::AuditEvent::new("fs.delete")
999 .with_identity(identity)
1000 .with_route("DELETE /api/v1/fs/file")
1001 .with_file(query.path.clone(), None),
1002 );
1003 StatusCode::NO_CONTENT.into_response()
1004 }
1005 Err(e) => error_response(
1006 StatusCode::INTERNAL_SERVER_ERROR,
1007 "delete-failed",
1008 &format!("could not remove the file: {e}"),
1009 ),
1010 }
1011}
1012
1013/// `GET /api/v1/fs/stat` — one entry, file or directory.
1014///
1015/// Resolving the path and reading its metadata are both blocking I/O. Same
1016/// convention as `list`/`list_blocking`, `download`/`download_blocking`, and
1017/// `delete_file`/`delete_file_blocking` above (`src/execution/executor.rs:209-215`):
1018/// run it on `spawn_blocking` so a slow filesystem can never starve the tokio
1019/// worker pool that also runs `/health` and the accept loop. This was, until
1020/// now, the one route in this module that called `resolve_existing` and
1021/// `std::fs::metadata` directly on the async runtime.
1022pub async fn stat(State(state): State<AppState>, Query(query): Query<PathQuery>) -> Response {
1023 let Some(root) = state.fs.clone() else {
1024 return fs_not_enabled();
1025 };
1026
1027 match tokio::task::spawn_blocking(move || stat_blocking(&root, &query)).await {
1028 Ok(response) => response,
1029 Err(_) => error_response(
1030 StatusCode::INTERNAL_SERVER_ERROR,
1031 "stat-failed",
1032 "reading the entry failed unexpectedly",
1033 ),
1034 }
1035}
1036
1037/// The synchronous body of `stat`. Blocking throughout — see `stat`, which
1038/// runs this via `spawn_blocking` rather than directly on the async runtime.
1039fn stat_blocking(root: &FsRoot, query: &PathQuery) -> Response {
1040 let resolved = match root.resolve_existing(&query.path) {
1041 Ok(path) => path,
1042 Err(error) => return fs_error_response(error),
1043 };
1044
1045 // Same reservation `create_upload` enforces on the way in and `list`
1046 // enforces on the way out: a caller must not be able to read an
1047 // in-progress upload's staging file, or its metadata, by guessing a
1048 // session id — session ids are a predictable per-process counter.
1049 if let Some(response) = refuse_if_reserved(root, &resolved) {
1050 return response;
1051 }
1052
1053 let meta = match std::fs::metadata(&resolved) {
1054 Ok(meta) => meta,
1055 Err(_) => return fs_error_response(FsError::NotFound),
1056 };
1057
1058 // `file_identity` is unused here but keeps the platform module honest about
1059 // being the only place that reaches for OS-specific metadata.
1060 let _ = platform::file_identity(&meta);
1061
1062 Json(entry_for(root, &resolved, &meta, None)).into_response()
1063}
1064
1065/// Body of `POST /api/v1/fs/uploads`.
1066#[derive(Debug, Deserialize)]
1067pub struct CreateUpload {
1068 /// Destination, root-relative.
1069 pub path: String,
1070 /// Total size the caller intends to send.
1071 pub size: u64,
1072 /// SHA-256 of the whole file, lowercase hex.
1073 pub sha256: String,
1074}
1075
1076/// Reply describing a session's current state.
1077#[derive(Debug, Serialize)]
1078pub struct UploadState {
1079 pub upload_id: String,
1080 /// Next byte the server expects.
1081 pub offset: u64,
1082 /// Largest chunk the caller may send.
1083 ///
1084 /// Advertised rather than assumed: the ceiling depends on the path the
1085 /// request travelled, and a client that guesses will guess wrong on one of
1086 /// them.
1087 pub chunk_size: usize,
1088}
1089
1090/// Map an upload refusal onto HTTP.
1091fn upload_error_response(error: crate::fs::UploadError) -> Response {
1092 use crate::fs::UploadError;
1093 match error {
1094 UploadError::NotFound => error_response(
1095 StatusCode::NOT_FOUND,
1096 "no-such-upload",
1097 "unknown, completed, or expired upload session",
1098 ),
1099 UploadError::OffsetMismatch { expected } => (
1100 StatusCode::CONFLICT,
1101 Json(serde_json::json!({
1102 "error": "offset-mismatch",
1103 "message": "chunk does not continue from the session offset",
1104 "offset": expected,
1105 })),
1106 )
1107 .into_response(),
1108 UploadError::Conflict => error_response(
1109 StatusCode::CONFLICT,
1110 "destination-busy",
1111 "another upload session is already targeting this path",
1112 ),
1113 UploadError::TooLarge => error_response(
1114 StatusCode::PAYLOAD_TOO_LARGE,
1115 "chunk-too-large",
1116 "chunk exceeds the advertised chunk_size",
1117 ),
1118 // Distinct code from `TooLarge`: that one is "this single chunk is
1119 // bigger than the configured chunk_size", a protocol-level ceiling
1120 // unrelated to any particular session. This one is "the bytes this
1121 // session has now received, plus this chunk, exceed what *this*
1122 // session declared at creation" — a session-level contract
1123 // violation. Conflating the two would leave a client unable to tell
1124 // "resize your chunk" from "abort, you have already overrun what
1125 // you said you'd send".
1126 UploadError::SizeExceeded => error_response(
1127 StatusCode::PAYLOAD_TOO_LARGE,
1128 "declared-size-exceeded",
1129 "chunk would exceed the size declared when the session was created",
1130 ),
1131 // Distinct from `Conflict` (409, same path contested): this is a
1132 // capacity refusal, not a path collision, so it gets its own code and
1133 // status — a client should back off and retry rather than pick a
1134 // different destination.
1135 UploadError::TooManySessions => error_response(
1136 StatusCode::TOO_MANY_REQUESTS,
1137 "too-many-uploads",
1138 "too many upload sessions are open; finish or cancel one and retry",
1139 ),
1140 UploadError::Checksum {
1141 expected, actual, ..
1142 } => (
1143 StatusCode::UNPROCESSABLE_ENTITY,
1144 Json(serde_json::json!({
1145 "error": "checksum-mismatch",
1146 "message": "the assembled bytes do not match the declared digest",
1147 "expected": expected,
1148 "actual": actual,
1149 })),
1150 )
1151 .into_response(),
1152 // 507 vs. 500 decided on the numeric OS error code, never on
1153 // `detail`'s text: an earlier version matched substrings like
1154 // "space" or "full" in the rendered message, which is
1155 // locale-dependent (the OS renders `io::Error`'s `Display` in the
1156 // system locale) and would also trip on an ordinary error that
1157 // happens to name a directory "full". For a transfer API this
1158 // distinction is worth making — "the disk is full, retry after
1159 // freeing space" and "the server has a bug, file a report" are two
1160 // answers a client acts on completely differently, and a
1161 // `sha256`-verified GB-scale upload is exactly where a full disk is
1162 // a likely failure, not an edge case.
1163 //
1164 // `platform::is_out_of_space` takes `&io::Error`, which this arm no
1165 // longer has — `UploadError::Io` carries only the numeric
1166 // `raw_os_error`, not the original error, so the error is
1167 // reconstructed from that code purely to hand it to the one
1168 // existing predicate rather than duplicating its comparison here.
1169 UploadError::Io {
1170 detail,
1171 raw_os_error,
1172 } => {
1173 let out_of_space = raw_os_error
1174 .map(std::io::Error::from_raw_os_error)
1175 .is_some_and(|e| platform::is_out_of_space(&e));
1176 let status = match out_of_space {
1177 true => StatusCode::INSUFFICIENT_STORAGE,
1178 false => StatusCode::INTERNAL_SERVER_ERROR,
1179 };
1180 error_response(status, "io-error", &detail)
1181 }
1182 }
1183}
1184
1185/// `POST /api/v1/fs/uploads` — open a session.
1186///
1187/// Resolving the destination, creating the staging directory, and opening the
1188/// staging file (`UploadStore::create`) are all blocking I/O — same
1189/// convention as `list`/`list_blocking` and `download`/`download_blocking`
1190/// above (`src/execution/executor.rs:209-215`): run it on `spawn_blocking` so
1191/// a slow filesystem can never starve the tokio worker pool that also runs
1192/// `/health` and the accept loop.
1193pub async fn create_upload(
1194 State(state): State<AppState>,
1195 identity: Option<axum::Extension<crate::audit::Identity>>,
1196 Json(body): Json<CreateUpload>,
1197) -> Response {
1198 let Some(root) = state.fs.clone() else {
1199 return fs_not_enabled();
1200 };
1201 let uploads = state.uploads.clone();
1202 let audit = state.audit.clone();
1203 let identity = identity.map(|axum::Extension(id)| id);
1204
1205 match tokio::task::spawn_blocking(move || {
1206 create_upload_blocking(&root, &uploads, &audit, identity, body)
1207 })
1208 .await
1209 {
1210 Ok(response) => response,
1211 Err(_) => error_response(
1212 StatusCode::INTERNAL_SERVER_ERROR,
1213 "create-upload-failed",
1214 "creating the upload session failed unexpectedly",
1215 ),
1216 }
1217}
1218
1219/// The synchronous body of `create_upload`. Blocking throughout — see
1220/// `create_upload`, which runs this via `spawn_blocking` rather than directly
1221/// on the async runtime. `audit` is threaded in as a parameter rather than
1222/// recorded back on the async side, because `AuditSink::record` itself does
1223/// blocking file I/O (open/write/flush) — recording it here keeps that I/O on
1224/// the same blocking-pool thread as everything else this function does,
1225/// instead of adding a second blocking call directly on the tokio runtime.
1226fn create_upload_blocking(
1227 root: &FsRoot,
1228 uploads: &crate::fs::UploadStore,
1229 audit: &crate::audit::AuditSink,
1230 identity: Option<crate::audit::Identity>,
1231 body: CreateUpload,
1232) -> Response {
1233 // Validate the destination before claiming anything, so a bad path cannot
1234 // leave a staging file or a claim behind.
1235 let resolved = match root.resolve_for_create(&body.path) {
1236 Ok(path) => path,
1237 Err(error) => return fs_error_response(error),
1238 };
1239
1240 // Canonical, not the raw request string: `body.path` is whatever the
1241 // caller spelled it (`./app/x.bin`, `app\x.bin`, `app//x.bin` — the last
1242 // one is refused by `resolve_for_create` itself, since `check_component`
1243 // rejects the empty component it produces). Two different spellings of
1244 // one destination discarding `resolved` here (an earlier version did)
1245 // would claim it under two different keys, so both sessions proceed,
1246 // both eventually `complete`, and both `rename` onto the same file —
1247 // exactly the last-writer-wins data loss `UploadStore`'s destination
1248 // claim exists to prevent. `root.relative` on the path `resolve_for_create`
1249 // already produced is this function's only source of that canonical
1250 // form.
1251 let dest_rel = match root.relative(&resolved) {
1252 Some(rel) => rel,
1253 // `resolve_for_create` already established that `resolved` is under
1254 // `root`, so `relative` returning `None` here should not be
1255 // reachable — handled rather than unwrapped so a future change to
1256 // either function cannot turn this into a panic.
1257 None => {
1258 return error_response(
1259 StatusCode::INTERNAL_SERVER_ERROR,
1260 "path-resolution-failed",
1261 "could not compute the destination's canonical path",
1262 )
1263 }
1264 };
1265
1266 // The upload staging directory is reserved: `list` deliberately hides it
1267 // (`src/api/fs.rs`'s `walk`), so a file published inside it could never be
1268 // reported back through this API, and a destination shaped like
1269 // `up-{serial:016x}.part` could collide with a future session's own
1270 // staging file, making that session's `create_new` fail for a reason
1271 // that has nothing to do with it.
1272 if is_reserved_path(&dest_rel) {
1273 return reserved_path_response();
1274 }
1275
1276 // Checked before the digest/size validation below, and before claiming
1277 // anything: a real directory at the destination is not something
1278 // `rename` at `complete` time can replace (`EISDIR`/`ENOTDIR`), so
1279 // finding out only then means the client has already uploaded the whole
1280 // file for nothing. `symlink_metadata` (lstat), not `metadata` (stat): a
1281 // *symlink* to a directory is not refused here — `rename` never follows
1282 // a symlink on either operand (see `complete_upload_blocking`'s doc
1283 // comment), so it simply replaces the link rather than failing.
1284 if let Ok(meta) = std::fs::symlink_metadata(&resolved) {
1285 if meta.is_dir() {
1286 return error_response(
1287 StatusCode::CONFLICT,
1288 "destination-is-directory",
1289 "the destination path already exists as a directory",
1290 );
1291 }
1292 }
1293
1294 if body.sha256.len() != 64 || !body.sha256.chars().all(|c| c.is_ascii_hexdigit()) {
1295 return error_response(
1296 StatusCode::BAD_REQUEST,
1297 "bad-digest",
1298 "sha256 must be 64 hexadecimal characters",
1299 );
1300 }
1301
1302 // Cloned before the move below: `dest_rel` is canonical (see the comment
1303 // on its own `let` above), and the audit event must record that same
1304 // canonical form rather than `body.path` as spelled by the caller — a
1305 // session's `start` and `complete` events need to agree on `file` so the
1306 // two can be correlated even when the request used a different spelling
1307 // (`./x.bin` vs. `x.bin`) than `complete`'s response does.
1308 let dest_for_audit = dest_rel.clone();
1309
1310 // Opportunistic reclamation used to live inside `UploadStore::create`
1311 // itself, unaudited (see that method's own doc comment for why it moved).
1312 // Sweeping here, immediately before `create`, preserves the ordering the
1313 // old internal call existed for: stale capacity is reclaimed before the
1314 // cap check inside `create` runs, so a session old enough to matter is
1315 // freed the moment somebody next asks for a new one.
1316 sweep_expired_uploads(uploads, audit, crate::fs::SESSION_TTL);
1317
1318 match uploads.create(root, dest_rel, body.size, body.sha256.to_ascii_lowercase()) {
1319 Ok(upload_id) => {
1320 audit.record(
1321 crate::audit::AuditEvent::new("upload.start")
1322 .with_identity(identity)
1323 .with_route("POST /api/v1/fs/uploads")
1324 .with_file(dest_for_audit, Some(body.size))
1325 .with_upload_id(upload_id.clone()),
1326 );
1327 (
1328 StatusCode::CREATED,
1329 Json(UploadState {
1330 upload_id,
1331 offset: 0,
1332 chunk_size: uploads.chunk_size(),
1333 }),
1334 )
1335 .into_response()
1336 }
1337 Err(error) => upload_error_response(error),
1338 }
1339}
1340
1341/// `GET /api/v1/fs/uploads/{id}` — where to resume from.
1342///
1343/// Reads only in-memory session state (`UploadStore::offset`), so unlike the
1344/// other four upload routes this never touches disk and stays directly on the
1345/// async runtime rather than going through `spawn_blocking`.
1346pub async fn upload_status(
1347 State(state): State<AppState>,
1348 axum::extract::Path(id): axum::extract::Path<String>,
1349) -> Response {
1350 if state.fs.is_none() {
1351 return fs_not_enabled();
1352 }
1353 match state.uploads.offset(&id) {
1354 Some(offset) => Json(UploadState {
1355 upload_id: id,
1356 offset,
1357 chunk_size: state.uploads.chunk_size(),
1358 })
1359 .into_response(),
1360 None => upload_error_response(crate::fs::UploadError::NotFound),
1361 }
1362}
1363
1364/// `PATCH /api/v1/fs/uploads/{id}` — append one chunk.
1365///
1366/// The offset comes from `Content-Range` rather than the body, so a chunk that
1367/// arrives twice is refused by position instead of being appended again.
1368///
1369/// Writing the chunk to the staging file (`UploadStore::append`) is blocking
1370/// disk I/O — `spawn_blocking`, same convention as the other routes here. The
1371/// route this handler serves also carries `DefaultBodyLimit::max(MAX_CHUNK_SIZE)`
1372/// (`src/api/router.rs`), raising axum-core's own 2 MiB default so a chunk at
1373/// the advertised `chunk_size` (4 MiB) reaches this handler at all, rather than
1374/// being cut off by axum before `append`'s own `TooLarge` check ever runs.
1375pub async fn append_chunk(
1376 State(state): State<AppState>,
1377 axum::extract::Path(id): axum::extract::Path<String>,
1378 headers: axum::http::HeaderMap,
1379 body: axum::body::Bytes,
1380) -> Response {
1381 if state.fs.is_none() {
1382 return fs_not_enabled();
1383 }
1384
1385 let offset = match headers
1386 .get("content-range")
1387 .and_then(|v| v.to_str().ok())
1388 .and_then(parse_content_range_start)
1389 {
1390 Some(offset) => offset,
1391 None => {
1392 return error_response(
1393 StatusCode::BAD_REQUEST,
1394 "bad-content-range",
1395 "a Content-Range header of the form 'bytes <start>-<end>/<total>' is required",
1396 )
1397 }
1398 };
1399
1400 let uploads = state.uploads.clone();
1401 match tokio::task::spawn_blocking(move || append_chunk_blocking(&uploads, &id, offset, &body))
1402 .await
1403 {
1404 Ok(response) => response,
1405 Err(_) => error_response(
1406 StatusCode::INTERNAL_SERVER_ERROR,
1407 "append-failed",
1408 "writing the chunk failed unexpectedly",
1409 ),
1410 }
1411}
1412
1413/// The synchronous body of `append_chunk`. Blocking throughout — see
1414/// `append_chunk`, which runs this via `spawn_blocking` rather than directly
1415/// on the async runtime.
1416fn append_chunk_blocking(
1417 uploads: &crate::fs::UploadStore,
1418 id: &str,
1419 offset: u64,
1420 body: &[u8],
1421) -> Response {
1422 match uploads.append(id, offset, body) {
1423 Ok(next) => Json(UploadState {
1424 upload_id: id.to_string(),
1425 offset: next,
1426 chunk_size: uploads.chunk_size(),
1427 })
1428 .into_response(),
1429 Err(error) => upload_error_response(error),
1430 }
1431}
1432
1433/// The start offset named by a `Content-Range` request header.
1434pub fn parse_content_range_start(header: &str) -> Option<u64> {
1435 let spec = header.trim().strip_prefix("bytes ")?;
1436 let (range, _total) = spec.split_once('/')?;
1437 let (start, _end) = range.split_once('-')?;
1438 start.trim().parse().ok()
1439}
1440
1441/// `POST /api/v1/fs/uploads/{id}/complete` — verify and publish.
1442///
1443/// Verifying the checksum, resolving the destination, creating its parent
1444/// directory, and the rename itself are all blocking I/O — `spawn_blocking`,
1445/// same convention as the other routes here.
1446pub async fn complete_upload(
1447 State(state): State<AppState>,
1448 axum::extract::Path(id): axum::extract::Path<String>,
1449 identity: Option<axum::Extension<crate::audit::Identity>>,
1450) -> Response {
1451 let Some(root) = state.fs.clone() else {
1452 return fs_not_enabled();
1453 };
1454 let uploads = state.uploads.clone();
1455 let audit = state.audit.clone();
1456 let identity = identity.map(|axum::Extension(id)| id);
1457
1458 match tokio::task::spawn_blocking(move || {
1459 complete_upload_blocking(&root, &uploads, &audit, identity, &id)
1460 })
1461 .await
1462 {
1463 Ok(response) => response,
1464 Err(_) => error_response(
1465 StatusCode::INTERNAL_SERVER_ERROR,
1466 "complete-upload-failed",
1467 "publishing the upload failed unexpectedly",
1468 ),
1469 }
1470}
1471
1472/// The synchronous body of `complete_upload`. Blocking throughout — see
1473/// `complete_upload`, which runs this via `spawn_blocking` rather than
1474/// directly on the async runtime. `audit` is threaded in for the same reason
1475/// `create_upload_blocking` takes it: `AuditSink::record` is itself blocking
1476/// I/O, and this function already runs on the blocking pool.
1477///
1478/// `UploadStore::take_for_complete` deliberately keeps `finished.dest_rel`'s
1479/// claim alive on success (see its doc comment), so every exit path below
1480/// calls `release_destination` exactly once — whether the rename lands or
1481/// not — instead of relying on `take_for_complete` to have released it
1482/// already.
1483fn complete_upload_blocking(
1484 root: &FsRoot,
1485 uploads: &crate::fs::UploadStore,
1486 audit: &crate::audit::AuditSink,
1487 identity: Option<crate::audit::Identity>,
1488 id: &str,
1489) -> Response {
1490 let finished = match uploads.take_for_complete(id) {
1491 Ok(finished) => finished,
1492 // `take_for_complete` already removed the session from the map before
1493 // returning this error (`src/fs/transfer.rs`'s checksum-mismatch
1494 // branch), so this is terminal for the session, not a state a later
1495 // sweep could also see and double-record.
1496 Err(error) => {
1497 if let crate::fs::UploadError::Checksum { ref dest_rel, .. } = error {
1498 audit.record(
1499 crate::audit::AuditEvent::new("upload.rejected")
1500 .with_identity(identity)
1501 .with_route("POST /api/v1/fs/uploads/{id}/complete")
1502 .with_file(dest_rel.clone(), None)
1503 .with_digest(false)
1504 .with_upload_id(id),
1505 );
1506 }
1507 return upload_error_response(error);
1508 }
1509 };
1510
1511 // From here on, `take_for_complete` has already removed the session and
1512 // the destination's claim survives only until `release_destination` is
1513 // called below — so every exit path, success or failure, is terminal for
1514 // this upload and must leave its own event. `upload.failed` (distinct
1515 // from `upload.rejected` above): these are IO failures on the server's
1516 // own publication step, not a contract violation by the caller.
1517 let destination = match root.resolve_for_create(&finished.dest_rel) {
1518 Ok(path) => path,
1519 Err(error) => {
1520 std::fs::remove_file(&finished.part_path).ok();
1521 uploads.release_destination(&finished.dest_rel);
1522 let response = fs_error_response(error);
1523 audit.record(
1524 crate::audit::AuditEvent::new("upload.failed")
1525 .with_identity(identity)
1526 .with_route("POST /api/v1/fs/uploads/{id}/complete")
1527 .with_file(finished.dest_rel.clone(), Some(finished.bytes))
1528 .with_denial(response.status().as_u16(), "destination-resolve-failed")
1529 .with_upload_id(id),
1530 );
1531 return response;
1532 }
1533 };
1534
1535 if let Some(parent) = destination.parent() {
1536 if let Err(e) = std::fs::create_dir_all(parent) {
1537 std::fs::remove_file(&finished.part_path).ok();
1538 uploads.release_destination(&finished.dest_rel);
1539 // Consults the same `platform::is_out_of_space` predicate
1540 // `upload_error_response` uses for `UploadError::Io`, rather
1541 // than a second, undifferentiated 500 — a full disk publishing
1542 // a GB-scale, checksum-verified upload is exactly the likely
1543 // failure this distinction exists for, not an edge case, and it
1544 // would be dishonest to make it 507 for `append`'s writes but
1545 // not for the one this function does itself.
1546 let status = match platform::is_out_of_space(&e) {
1547 true => StatusCode::INSUFFICIENT_STORAGE,
1548 false => StatusCode::INTERNAL_SERVER_ERROR,
1549 };
1550 audit.record(
1551 crate::audit::AuditEvent::new("upload.failed")
1552 .with_identity(identity)
1553 .with_route("POST /api/v1/fs/uploads/{id}/complete")
1554 .with_file(finished.dest_rel.clone(), Some(finished.bytes))
1555 .with_denial(status.as_u16(), "directory-creation-failed")
1556 .with_upload_id(id),
1557 );
1558 return error_response(
1559 status,
1560 "io-error",
1561 &format!("could not create the destination directory: {e}"),
1562 );
1563 }
1564 }
1565
1566 // Rename is the publication step: until it runs the destination holds the
1567 // old file or nothing, never a half-written one. `rename` never follows a
1568 // symlink on either operand, so even if `destination`'s final component
1569 // became a symlink between the `resolve_for_create` above and this call,
1570 // the rename simply replaces that directory entry rather than writing
1571 // through it — no additional guard is needed for that case.
1572 if let Err(e) = std::fs::rename(&finished.part_path, &destination) {
1573 std::fs::remove_file(&finished.part_path).ok();
1574 uploads.release_destination(&finished.dest_rel);
1575 // Same reasoning as the `create_dir_all` failure above: consult the
1576 // predicate rather than always answering 500.
1577 let status = match platform::is_out_of_space(&e) {
1578 true => StatusCode::INSUFFICIENT_STORAGE,
1579 false => StatusCode::INTERNAL_SERVER_ERROR,
1580 };
1581 audit.record(
1582 crate::audit::AuditEvent::new("upload.failed")
1583 .with_identity(identity)
1584 .with_route("POST /api/v1/fs/uploads/{id}/complete")
1585 .with_file(finished.dest_rel.clone(), Some(finished.bytes))
1586 .with_denial(status.as_u16(), "rename-failed")
1587 .with_upload_id(id),
1588 );
1589 return error_response(
1590 status,
1591 "io-error",
1592 &format!("could not publish the upload: {e}"),
1593 );
1594 }
1595 uploads.release_destination(&finished.dest_rel);
1596
1597 audit.record(
1598 crate::audit::AuditEvent::new("upload.complete")
1599 .with_identity(identity)
1600 .with_route("POST /api/v1/fs/uploads/{id}/complete")
1601 .with_file(finished.dest_rel.clone(), Some(finished.bytes))
1602 .with_digest(true)
1603 .with_upload_id(id),
1604 );
1605
1606 Json(serde_json::json!({
1607 "path": finished.dest_rel,
1608 "size": finished.bytes,
1609 "sha256": finished.digest,
1610 }))
1611 .into_response()
1612}
1613
1614/// `DELETE /api/v1/fs/uploads/{id}` — abandon a session.
1615///
1616/// Removing the staging file (`UploadStore::cancel`) is blocking I/O —
1617/// `spawn_blocking`, same convention as the other routes here.
1618pub async fn cancel_upload(
1619 State(state): State<AppState>,
1620 axum::extract::Path(id): axum::extract::Path<String>,
1621 identity: Option<axum::Extension<crate::audit::Identity>>,
1622) -> Response {
1623 if state.fs.is_none() {
1624 return fs_not_enabled();
1625 }
1626 let uploads = state.uploads.clone();
1627 let audit = state.audit.clone();
1628 let identity = identity.map(|axum::Extension(id)| id);
1629 match tokio::task::spawn_blocking(move || {
1630 cancel_upload_blocking(&uploads, &audit, identity, &id)
1631 })
1632 .await
1633 {
1634 Ok(response) => response,
1635 Err(_) => error_response(
1636 StatusCode::INTERNAL_SERVER_ERROR,
1637 "cancel-failed",
1638 "cancelling the upload failed unexpectedly",
1639 ),
1640 }
1641}
1642
1643/// The synchronous body of `cancel_upload`. Blocking throughout — see
1644/// `cancel_upload`, which runs this via `spawn_blocking` rather than directly
1645/// on the async runtime.
1646///
1647/// An explicit cancel is as terminal as a sweep-driven expiry, and without a
1648/// recorded event here the trail would show a session starting and then
1649/// nothing — indistinguishable from one still in progress. `UploadStore::cancel`
1650/// returns `(destination, bytes_received)` for the same reason `sweep` returns
1651/// `(id, destination, bytes_received)`: the primary question an audit trail
1652/// answers is "what happened to this file", and a reader grepping for a path
1653/// would otherwise see `upload.start` and then silence for a cancelled
1654/// session, the same failure this task's sweep-driven `upload.expired` event
1655/// exists to rule out.
1656fn cancel_upload_blocking(
1657 uploads: &crate::fs::UploadStore,
1658 audit: &crate::audit::AuditSink,
1659 identity: Option<crate::audit::Identity>,
1660 id: &str,
1661) -> Response {
1662 match uploads.cancel(id) {
1663 Some((destination, bytes)) => {
1664 audit.record(
1665 crate::audit::AuditEvent::new("upload.cancel")
1666 .with_identity(identity)
1667 .with_route("DELETE /api/v1/fs/uploads/{id}")
1668 .with_file(destination, Some(bytes))
1669 .with_upload_id(id),
1670 );
1671 StatusCode::NO_CONTENT.into_response()
1672 }
1673 None => upload_error_response(crate::fs::UploadError::NotFound),
1674 }
1675}
1676
1677/// Drop upload sessions idle past `ttl`, recording a terminal event for each.
1678///
1679/// A session that starts and never ends leaves a trail showing only a
1680/// beginning. Sweeping silently would make every abandoned transfer look, in
1681/// the log, exactly like one still in progress.
1682///
1683/// Takes `uploads`/`audit` directly rather than `&AppState`: this is the only
1684/// state either caller needs, and one of those callers is
1685/// `create_upload_blocking`, which already holds both as separate parameters
1686/// (see that function's own signature) rather than an `AppState`. Matching
1687/// shapes means the opportunistic sweep that used to live inside
1688/// `UploadStore::create` can call this exactly the way the periodic sweeper
1689/// in `main.rs` does, instead of needing a second, `AppState`-shaped variant.
1690///
1691/// Plain and synchronous rather than `async` or `spawn_blocking`-wrapped
1692/// itself: `UploadStore::sweep` removes staging files and `audit.record`
1693/// writes to a file, both blocking I/O, but which runtime this runs on is the
1694/// caller's decision to make — a periodic task on the async runtime needs to
1695/// wrap this in `spawn_blocking` (see `main.rs`); `create_upload_blocking`
1696/// calls it directly because it is already running on the blocking pool
1697/// itself; a test calling it directly from a `#[tokio::test]` body does not
1698/// need that ceremony to observe what it records.
1699///
1700/// The recorded event carries no `route` (there is no request driving this —
1701/// it runs off a timer, or opportunistically off an unrelated request) and no
1702/// `identity` (the session was opened by some caller, long since
1703/// disconnected; nothing here still knows who that was). Every other event
1704/// this task adds carries both.
1705pub fn sweep_expired_uploads(
1706 uploads: &crate::fs::UploadStore,
1707 audit: &crate::audit::AuditSink,
1708 ttl: std::time::Duration,
1709) -> usize {
1710 let expired = uploads.sweep(ttl);
1711 for (id, destination, bytes) in &expired {
1712 audit.record(
1713 crate::audit::AuditEvent::new("upload.expired")
1714 .with_file(destination.clone(), Some(*bytes))
1715 .with_upload_id(id.clone()),
1716 );
1717 }
1718 expired.len()
1719}
1720
1721/// Remove `.part` staging files left behind by a previous run, recording a
1722/// terminal event for each.
1723///
1724/// Thin wrapper over `crate::fs::sweep_orphan_parts`: that function stays
1725/// audit-agnostic (it lives in `crate::fs`, which has no `AuditSink`), and
1726/// this is where the recording happens instead — same split as
1727/// `sweep_expired_uploads` over `UploadStore::sweep`.
1728///
1729/// The recorded `upload.orphaned` event carries `upload_id` but not `file`:
1730/// the destination lived only in the in-memory session a restart already
1731/// discarded before this ever runs, so there is nothing left to attach as
1732/// `file`. It also carries neither `route` nor `identity`, for the same
1733/// reason `upload.expired` does not — nothing here was driven by a request.
1734/// A reader correlates an orphan back to the `upload.start` that does have
1735/// the destination by matching `upload_id` between the two events.
1736pub fn sweep_orphaned_uploads(root: &FsRoot, audit: &crate::audit::AuditSink) -> usize {
1737 let removed = crate::fs::sweep_orphan_parts(root);
1738 for (upload_id, bytes) in &removed {
1739 // Built without `with_file`: that builder always sets `file`
1740 // alongside `bytes`, and this event deliberately carries no `file` —
1741 // there is nothing left here to attach one from. `bytes` is set
1742 // directly on the field instead (both `pub` within the crate).
1743 let mut event =
1744 crate::audit::AuditEvent::new("upload.orphaned").with_upload_id(upload_id.clone());
1745 event.bytes = Some(*bytes);
1746 audit.record(event);
1747 }
1748 removed.len()
1749}
1750
1751#[cfg(test)]
1752mod tests {
1753 use super::*;
1754
1755 #[test]
1756 fn ranges_parse_into_inclusive_bounds() {
1757 use RangeOutcome::Satisfiable;
1758
1759 assert_eq!(parse_range("bytes=0-4", 11), Satisfiable(0, 4));
1760 assert_eq!(parse_range("bytes=6-10", 11), Satisfiable(6, 10));
1761 // Open-ended: to the last byte.
1762 assert_eq!(parse_range("bytes=6-", 11), Satisfiable(6, 10));
1763 // Suffix: the last N bytes.
1764 assert_eq!(parse_range("bytes=-3", 11), Satisfiable(8, 10));
1765 // Clamped to the file, not refused.
1766 assert_eq!(parse_range("bytes=0-999", 11), Satisfiable(0, 10));
1767 }
1768
1769 #[test]
1770 fn a_well_formed_out_of_bounds_range_is_unsatisfiable() {
1771 use RangeOutcome::Unsatisfiable;
1772
1773 // First-byte-pos at or past the current length: well-formed, but the
1774 // file does not have those bytes.
1775 assert_eq!(parse_range("bytes=11-20", 11), Unsatisfiable);
1776 // A suffix of 0 bytes names nothing the file can supply.
1777 assert_eq!(parse_range("bytes=-0", 11), Unsatisfiable);
1778 // An empty file satisfies no byte-range-spec at all.
1779 assert_eq!(parse_range("bytes=0-4", 0), Unsatisfiable);
1780 }
1781
1782 #[test]
1783 fn malformed_or_unrecognised_ranges_are_ignored_not_refused() {
1784 use RangeOutcome::Ignore;
1785
1786 // RFC 9110 §14.2: an unrecognised unit or a syntactically invalid
1787 // `bytes` spec must be ignored — served as the whole file (200) —
1788 // not refused with 416. Only a well-formed, out-of-bounds `bytes`
1789 // range is 416 (see `a_well_formed_out_of_bounds_range_is_unsatisfiable`).
1790 assert_eq!(parse_range("items=0-4", 11), Ignore); // unrecognised unit
1791 assert_eq!(parse_range("bytes=5-2", 11), Ignore); // last-byte-pos < first-byte-pos
1792 assert_eq!(parse_range("bytes=0-1,4-5", 11), Ignore); // multipart, unsupported
1793 assert_eq!(parse_range("bytes=-", 11), Ignore); // empty suffix
1794 }
1795
1796 #[test]
1797 fn resolve_limit_clamps_to_the_configured_bounds() {
1798 assert_eq!(resolve_limit(None), DEFAULT_LIST_LIMIT);
1799 // Lower bound: zero must not mean "empty page".
1800 assert_eq!(resolve_limit(Some(0)), 1);
1801 // Ceiling: a caller asking for far more than the max is clamped, not
1802 // refused and not served unbounded.
1803 assert_eq!(resolve_limit(Some(999_999)), MAX_LIST_LIMIT);
1804 // Pass-through within bounds.
1805 assert_eq!(resolve_limit(Some(50)), 50);
1806 }
1807
1808 /// `refuse_if_reserved`'s `None` arm — `root.relative` failing to strip
1809 /// the root prefix — has no route to it through any HTTP request: every
1810 /// call site passes a path already established to be under `root`. That
1811 /// is exactly why it needs a direct test: nothing at the HTTP level can
1812 /// ever exercise it, so a silent flip from this 500 to "not reserved,
1813 /// proceed" (fail-open, serving or removing a file this check exists to
1814 /// refuse) would ship with the whole suite green.
1815 #[test]
1816 fn refuse_if_reserved_fails_closed_when_relative_cannot_be_computed() {
1817 let dir = tempfile::tempdir().expect("tempdir");
1818 let root = FsRoot::new(dir.path()).expect("root");
1819
1820 // A path with no relationship to `root` at all — `relative` returns
1821 // `None` for it the same way it would for any path this function's
1822 // callers should never be able to construct.
1823 let unrelated = std::env::temp_dir().join("definitely-not-under-the-root");
1824
1825 let response =
1826 refuse_if_reserved(&root, &unrelated).expect("None must refuse, not silently allow");
1827 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
1828 }
1829
1830 #[test]
1831 fn content_range_yields_its_start_offset() {
1832 assert_eq!(
1833 parse_content_range_start("bytes 0-4194303/209715200"),
1834 Some(0)
1835 );
1836 assert_eq!(
1837 parse_content_range_start("bytes 4194304-8388607/209715200"),
1838 Some(4194304)
1839 );
1840 assert_eq!(parse_content_range_start("bytes 0-4"), None);
1841 assert_eq!(parse_content_range_start("items 0-4/9"), None);
1842 }
1843
1844 /// `platform::is_out_of_space`'s own unit tests (`src/fs/platform.rs`)
1845 /// prove the predicate is correct against the raw codes; this proves
1846 /// `upload_error_response` actually *wires* it in — that a
1847 /// `raw_os_error` meaning "out of space" reaches `507`, an unrelated
1848 /// one stays `500`, and no code at all (the `poisoned()` case, which
1849 /// never had an underlying `io::Error`) also stays `500`.
1850 #[test]
1851 fn io_error_maps_to_507_only_when_the_raw_code_means_out_of_space() {
1852 use crate::fs::UploadError;
1853
1854 #[cfg(unix)]
1855 let out_of_space_code = libc::ENOSPC;
1856 #[cfg(windows)]
1857 let out_of_space_code = 112; // ERROR_DISK_FULL
1858
1859 let out_of_space = upload_error_response(UploadError::Io {
1860 detail: "no space left on device".to_string(),
1861 raw_os_error: Some(out_of_space_code),
1862 });
1863 assert_eq!(out_of_space.status(), StatusCode::INSUFFICIENT_STORAGE);
1864
1865 // A real but unrelated OS error must not be mistaken for it.
1866 let unrelated = upload_error_response(UploadError::Io {
1867 detail: "permission denied".to_string(),
1868 raw_os_error: Some(13),
1869 });
1870 assert_eq!(unrelated.status(), StatusCode::INTERNAL_SERVER_ERROR);
1871
1872 // No OS code at all (e.g. `poisoned()`'s synthetic error) must not
1873 // default to the space-exhausted branch either.
1874 let no_code = upload_error_response(UploadError::Io {
1875 detail: "internal lock poisoned".to_string(),
1876 raw_os_error: None,
1877 });
1878 assert_eq!(no_code.status(), StatusCode::INTERNAL_SERVER_ERROR);
1879 }
1880
1881 #[test]
1882 fn etag_reflects_size_and_discriminates_on_mtime() {
1883 let dir = tempfile::tempdir().expect("tempdir");
1884 let path = dir.path().join("a.bin");
1885 std::fs::write(&path, b"hello").expect("write");
1886 let meta = std::fs::metadata(&path).expect("metadata");
1887 let etag = etag_for(&meta);
1888
1889 // Shape: `"<size>-<mtime>-<identity>"`, three hex fields.
1890 let inner = etag.trim_matches('"');
1891 let parts: Vec<&str> = inner.split('-').collect();
1892 assert_eq!(
1893 parts.len(),
1894 3,
1895 "etag should be three hyphen-separated fields: {etag}"
1896 );
1897 assert_eq!(
1898 u64::from_str_radix(parts[0], 16).expect("size field is hex"),
1899 meta.len()
1900 );
1901
1902 // Rewriting with different content of the *same* length changes only
1903 // the mtime (and, on Unix, nothing else — same inode). If the etag
1904 // did not change too, a caller could not tell a mutated same-size
1905 // file from the original — exactly the failure `If-Range` depends on
1906 // this function to prevent.
1907 std::thread::sleep(std::time::Duration::from_millis(10));
1908 std::fs::write(&path, b"HELLO").expect("rewrite, same size");
1909 let meta2 = std::fs::metadata(&path).expect("metadata");
1910
1911 if mtime_ms(&meta) == mtime_ms(&meta2) {
1912 // Coarse filesystem clock; the two writes landed in the same
1913 // millisecond. Nothing to compare — skip rather than flake.
1914 return;
1915 }
1916 assert_ne!(
1917 etag,
1918 etag_for(&meta2),
1919 "same-size file with a different mtime must get a different etag"
1920 );
1921 }
1922
1923 /// Regression for the bug where a nested `read_dir` failure propagated
1924 /// with `?` all the way out of `walk`, discarding every entry the walk
1925 /// had already collected. Only the top-level directory being unreadable
1926 /// should be fatal; a permission-restricted subdirectory further down is
1927 /// ordinary in a real deployment tree.
1928 ///
1929 /// `#[cfg(unix)]` because removing read permission from a directory has
1930 /// no direct `std::fs` equivalent on Windows (ACLs, not a mode bit) —
1931 /// same constraint the existing `a_symlink_out_of_the_root_is_refused`
1932 /// test in `src/fs/root.rs` already accepts.
1933 #[cfg(unix)]
1934 #[test]
1935 fn an_unreadable_nested_subdirectory_does_not_abort_the_whole_walk() {
1936 use std::os::unix::fs::PermissionsExt;
1937
1938 let dir = tempfile::tempdir().expect("tempdir");
1939 std::fs::create_dir_all(dir.path().join("app/locked")).expect("mkdir locked");
1940 std::fs::write(dir.path().join("app/locked/secret.txt"), b"x").expect("write secret");
1941 std::fs::write(dir.path().join("app/visible.txt"), b"y").expect("write visible");
1942 std::fs::write(dir.path().join("app/zzz.txt"), b"z").expect("write zzz");
1943
1944 let root = FsRoot::new(dir.path()).expect("root");
1945 let locked = dir.path().join("app/locked");
1946 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000))
1947 .expect("chmod locked");
1948
1949 // A privileged account (root, or a runner that ignores the mode bit)
1950 // can still read a "locked" directory — nothing to verify then.
1951 if std::fs::read_dir(&locked).is_ok() {
1952 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)).ok();
1953 return;
1954 }
1955
1956 // Resolved through the root rather than assembled with `join`, which
1957 // is what `list_blocking` does and what `walk` documents as its
1958 // precondition. Handing `walk` a raw `dir.path().join("app")` made
1959 // this test fail on macOS for a reason that had nothing to do with
1960 // permissions: `FsRoot::new` canonicalises, `/var/folders/…` becomes
1961 // `/private/var/folders/…`, and `root.relative` (a pure
1962 // `strip_prefix`) then returned `None` for every entry — so the walk
1963 // collected nothing at all and the assertion below read as "the
1964 // unreadable subdirectory aborted the walk". It had not; the test was
1965 // asking about a tree the root could not name. Linux hid this because
1966 // `/tmp` canonicalises to itself.
1967 let base = root.resolve_existing("app").expect("app resolves");
1968
1969 let mut collected: Vec<(String, std::path::PathBuf, std::fs::Metadata)> = Vec::new();
1970 let result = walk(&root, &base, true, &mut collected);
1971
1972 // Restore permissions before any assertion can panic and leak a
1973 // directory the temp-dir cleanup would otherwise be unable to remove.
1974 std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755))
1975 .expect("restore permissions");
1976
1977 assert!(
1978 result.is_ok(),
1979 "an unreadable nested subdirectory must not fail the whole walk"
1980 );
1981 let paths: Vec<&str> = collected.iter().map(|(p, _, _)| p.as_str()).collect();
1982 assert!(paths.contains(&"app/visible.txt"));
1983 assert!(paths.contains(&"app/zzz.txt"));
1984 assert!(
1985 paths.contains(&"app/locked"),
1986 "the locked directory itself is still listed — only its contents are unreachable"
1987 );
1988 assert!(
1989 !paths.iter().any(|p| p.starts_with("app/locked/")),
1990 "contents of the unreadable subdirectory are simply absent, not fatal"
1991 );
1992 }
1993
1994 /// Direct check of the postcondition `delete_file_blocking` relies on to
1995 /// catch a drive-prefix `name` — no HTTP request reaches this today (the
1996 /// full-path resolution ahead of it already refuses `:` via
1997 /// `platform::check_component`, see
1998 /// `delete_refuses_a_path_component_containing_a_colon` in
1999 /// `tests/fs_api.rs`), so this pins the raw `Path` arithmetic the guard
2000 /// depends on instead of contorting an HTTP test to reach it.
2001 ///
2002 /// Deliberately does not call `platform::check_component` at all: the
2003 /// whole point of checking this as a postcondition of `join` is that it
2004 /// holds independent of whatever `check_component` currently rejects.
2005 ///
2006 /// `#[cfg(windows)]`: the discarding behavior is specific to Windows path
2007 /// prefixes (a drive letter, or a UNC/verbatim root). `:` has no special
2008 /// meaning to `Path` on Unix, so `join` there only ever appends.
2009 #[cfg(windows)]
2010 #[test]
2011 fn postcondition_catches_a_drive_prefix_join_even_without_check_component() {
2012 let parent = std::path::Path::new(r"C:\root\app");
2013 let named = parent.join("C:evil");
2014 assert!(
2015 !named.starts_with(parent),
2016 "a drive-prefixed name must make `join` discard `parent`, or this guard has nothing to catch"
2017 );
2018
2019 // The ordinary case the postcondition must not disturb: a plain
2020 // filename still extends `parent` as expected.
2021 let ordinary = parent.join("real.txt");
2022 assert!(ordinary.starts_with(parent));
2023 }
2024}