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