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