Skip to main content

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