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